@oj-module/cli 0.2.6 → 0.2.14

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 (169) hide show
  1. package/README.md +1 -1
  2. package/devkit/README.md +70 -24
  3. package/devkit/framework-dev.md +14 -5
  4. package/devkit/manual.md +28 -23
  5. package/devkit/module-dev-and-cdn-playground.md +197 -0
  6. package/devkit/sql-builder-missing-capabilities-pr.md +268 -0
  7. package/package.json +2 -2
  8. package/shell-dist/assets/runtime.js +130 -45
  9. package/shell-dist/index.html +2 -2
  10. package/shell-dist/versions.json +3 -3
  11. package/src/__tests__/build-backend.test.ts +64 -0
  12. package/src/__tests__/versions.test.ts +26 -0
  13. package/src/build.ts +9 -0
  14. package/src/bundle.ts +8 -5
  15. package/src/contract/__tests__/handler-scan.test.ts +29 -0
  16. package/src/contract/__tests__/market-client.integration.test.ts +170 -0
  17. package/src/contract/handler-scan.ts +35 -4
  18. package/src/dev.ts +10 -2
  19. package/src/index.ts +9 -3
  20. package/src/init.ts +24 -0
  21. package/src/lifecycle/__tests__/bundle.test.ts +3 -3
  22. package/src/market/__tests__/bootstrap.test.ts +204 -0
  23. package/src/market/__tests__/build-index.test.ts +154 -0
  24. package/src/market/__tests__/cdn-layout.test.ts +96 -0
  25. package/src/market/__tests__/failure-playbook.test.ts +33 -0
  26. package/src/market/__tests__/fixture.test.ts +48 -0
  27. package/src/market/__tests__/publish.test.ts +109 -0
  28. package/src/market/__tests__/report.test.ts +51 -0
  29. package/src/market/__tests__/setup.test.ts +114 -0
  30. package/src/market/__tests__/verify.test.ts +111 -0
  31. package/src/market/bootstrap.ts +255 -0
  32. package/src/market/build-index.ts +159 -0
  33. package/src/market/cdn-layout.ts +122 -0
  34. package/src/market/command.ts +139 -0
  35. package/src/market/failure-playbook.ts +57 -0
  36. package/src/market/fixture.ts +138 -0
  37. package/src/market/index-builder.ts +76 -0
  38. package/src/market/publish.ts +156 -0
  39. package/src/market/report.ts +67 -0
  40. package/src/market/scan.ts +99 -0
  41. package/src/market/setup.ts +144 -0
  42. package/src/market/trial.ts +121 -0
  43. package/src/market/verify.ts +298 -0
  44. package/src/market/walkthrough.ts +506 -0
  45. package/src/oj.ts +3 -1
  46. package/src/preview-config.ts +177 -0
  47. package/src/preview.ts +106 -56
  48. package/src/sign.ts +21 -6
  49. package/src/usage.ts +22 -2
  50. package/src/vendor.ts +4 -2
  51. package/src/versions.ts +28 -0
  52. package/templates/api/.ojm-api-exempt.json +6 -2
  53. package/templates/api/config.yaml +24 -2
  54. package/templates/api/src/_platform/README.md +31 -0
  55. package/templates/api/src/_platform/_lifecycle/atomic.ts +1 -1
  56. package/templates/api/src/_platform/_lifecycle/history.ts +10 -5
  57. package/templates/api/src/_platform/_lifecycle/installer.ts +4 -4
  58. package/templates/api/src/_platform/_lifecycle/sha384.ts +1 -1
  59. package/templates/api/src/_platform/_lifecycle/steps/download.ts +1 -1
  60. package/templates/api/src/_platform/_lifecycle/steps/extract.ts +8 -8
  61. package/templates/api/src/_platform/_lifecycle/steps/register.ts +57 -29
  62. package/templates/api/src/_platform/_lifecycle/trust.ts +1 -1
  63. package/templates/api/src/_platform/_lifecycle/uninstall.ts +29 -56
  64. package/templates/api/src/_platform/_lifecycle/verify-checks.ts +2 -2
  65. package/templates/api/src/_platform/_shared/service/{contacts.ts → domain/contacts.ts} +20 -13
  66. package/templates/api/src/_platform/_shared/service/{roles.ts → domain/roles.ts} +18 -19
  67. package/templates/api/src/_platform/_shared/service/{settings.ts → domain/settings.ts} +6 -6
  68. package/templates/api/src/_platform/_shared/service/{solution-perms.ts → domain/solution-perms.ts} +25 -22
  69. package/templates/api/src/_platform/_shared/service/{users.ts → domain/users.ts} +20 -26
  70. package/templates/api/src/_platform/_shared/service/{access.ts → infra/access.ts} +5 -8
  71. package/templates/api/src/_platform/_shared/service/{audit.ts → infra/audit.ts} +26 -26
  72. package/templates/api/src/_platform/_shared/service/infra/oplog.ts +40 -0
  73. package/templates/api/src/_platform/_shared/service/{query.ts → infra/query.ts} +18 -0
  74. package/templates/api/src/_platform/_shared/service/{rate.ts → infra/rate.ts} +16 -13
  75. package/templates/api/src/_platform/_shared/site-facade.ts +1 -1
  76. package/templates/api/src/_platform/contacts/api.ts +2 -2
  77. package/templates/api/src/_platform/contacts/detail/api.ts +2 -2
  78. package/templates/api/src/_platform/fixtures/l1-test-user.sql +13 -0
  79. package/templates/api/src/_platform/manifest.yaml +4 -1
  80. package/templates/api/src/_platform/market/api.ts +54 -16
  81. package/templates/api/src/_platform/migrations/0013__users_profile.sql +4 -0
  82. package/templates/api/src/_platform/schema.yaml +26 -0
  83. package/templates/api/src/_platform/solutions/api.ts +13 -12
  84. package/templates/api/src/auth/_shared/ldap-login.ts +11 -7
  85. package/templates/api/src/auth/_shared/login-throttle.ts +2 -2
  86. package/templates/api/src/auth/_shared/session.ts +26 -22
  87. package/templates/api/src/auth/api.ts +1 -1
  88. package/templates/api/src/demo/todos/api.ts +8 -8
  89. package/templates/api/src/home/line/api.ts +4 -4
  90. package/templates/api/src/home/pie/api.ts +4 -4
  91. package/templates/api/src/notification/_shared/notify.ts +97 -57
  92. package/templates/api/src/notification/api.ts +1 -1
  93. package/templates/api/src/notification/manifest.yaml +1 -0
  94. package/templates/api/src/notification/migrations/0004__notifications_broadcast_hidden.sql +12 -0
  95. package/templates/api/src/notification/schema.yaml +7 -0
  96. package/templates/api/src/notification/seed.sql +5 -4
  97. package/templates/api/src/personal-center/profile/api.ts +63 -0
  98. package/templates/api/src/personal-center/upload/api.ts +1 -1
  99. package/templates/api/src/system/login-methods/api.ts +1 -1
  100. package/templates/api/src/system/oplog/api.ts +3 -3
  101. package/templates/api/src/system/permissions/api.ts +2 -2
  102. package/templates/api/src/system/roles/api.ts +2 -2
  103. package/templates/api/src/system/roles/bindings/api.ts +2 -2
  104. package/templates/api/src/system/settings/api.ts +2 -2
  105. package/templates/api/src/system/users/api.ts +2 -2
  106. package/templates/api/src/system/users/forbidden/api.ts +2 -2
  107. package/templates/api/src/system/users/password/api.ts +2 -2
  108. package/templates/api/src/system/users/roles/api.ts +2 -2
  109. package/templates/api/src/web/user-info/api.ts +4 -5
  110. package/templates/api/tests/auth-session.spec.ts +1 -1
  111. package/templates/api/tests/auth-throttle.spec.ts +1 -1
  112. package/templates/api/tests/fixture.ts +8 -16
  113. package/templates/api/tests/guard.spec.ts +1 -1
  114. package/templates/api/tests/l1-builder.test.ts +91 -0
  115. package/templates/api/tests/lifecycle/installer.spec.ts +1 -4
  116. package/templates/api/tests/lifecycle/market-api.spec.ts +2 -4
  117. package/templates/api/tests/lifecycle/steps-fs.spec.ts +5 -8
  118. package/templates/api/tests/lifecycle/trust.spec.ts +1 -1
  119. package/templates/api/tests/lifecycle/uninstall.spec.ts +29 -89
  120. package/templates/api/tests/query-utils.spec.ts +85 -1
  121. package/templates/api/tests/rbac-audit.spec.ts +1 -1
  122. package/templates/api/tests/rbac-contacts.spec.ts +2 -2
  123. package/templates/api/tests/rbac-guard.spec.ts +1 -1
  124. package/templates/api/tests/rbac-roles.spec.ts +2 -2
  125. package/templates/api/tests/rbac-settings.spec.ts +66 -21
  126. package/templates/api/tests/rbac-solution-perms.spec.ts +1 -1
  127. package/templates/api/tests/rbac-users.spec.ts +1 -1
  128. package/templates/api/tsconfig.json +8 -0
  129. package/templates/cdn/README.md +48 -0
  130. package/templates/env.d.ts +2 -0
  131. package/templates/global.d.ts +850 -0
  132. package/templates/init.include.yaml +14 -2
  133. package/templates/logs/task-events.jsonl +0 -0
  134. package/templates/openapi.json +580 -23
  135. package/templates/package.json +2 -1
  136. package/templates/web/src/_platform/__client/api.schemas.ts +97 -0
  137. package/templates/web/src/_platform/__client/api.ts +152 -39
  138. package/templates/web/src/notification/entry.ts +46 -9
  139. package/templates/web/src/notification/locales/en-US.json +19 -0
  140. package/templates/web/src/notification/locales/zh-CN.json +19 -0
  141. package/templates/web/src/notification/pages/center/index.tsx +131 -0
  142. package/templates/web/src/oj-market/__tests__/CardGrid.test.tsx +19 -0
  143. package/templates/web/src/oj-market/__tests__/InstallDialog.test.tsx +3 -0
  144. package/templates/web/src/oj-market/__tests__/RollbackDialog.test.tsx +1 -0
  145. package/templates/web/src/oj-market/__tests__/marketClient.fake.ts +1 -0
  146. package/templates/web/src/oj-market/api/client.ts +5 -105
  147. package/templates/web/src/oj-market/api/defaultClient.ts +111 -5
  148. package/templates/web/src/oj-market/components/CardGrid.tsx +4 -1
  149. package/templates/web/src/oj-market/components/CardItem.tsx +9 -5
  150. package/templates/web/src/oj-market/components/DetailDrawer.tsx +7 -3
  151. package/templates/web/src/oj-market/components/InstallDialog.tsx +1 -1
  152. package/templates/web/src/oj-market/entry.ts +8 -2
  153. package/templates/web/src/oj-market/hooks/useMarket.ts +4 -1
  154. package/templates/web/src/oj-market/locales/en-US.json +2 -1
  155. package/templates/web/src/oj-market/locales/zh-CN.json +2 -1
  156. package/templates/web/src/oj-market/pages/MarketApp.tsx +16 -1
  157. package/templates/web/src/personal-center/__client/api.schemas.ts +9 -0
  158. package/templates/web/src/personal-center/__client/api.ts +25 -1
  159. package/templates/web/src/personal-center/entry.ts +5 -0
  160. package/templates/web/src/personal-center/pages/my-profile/index.tsx +25 -3
  161. package/templates/web/src/system/entry.ts +2 -1
  162. package/templates/web.config.ts +13 -10
  163. package/vendor/host-versions.json +3 -3
  164. package/templates/api/src/_platform/_shared/service/oplog.ts +0 -47
  165. package/templates/api/src/notifications/api.ts +0 -32
  166. package/templates/api/src/notifications/manifest.yaml +0 -5
  167. package/templates/web/src/notifications/__client/api.schemas.ts +0 -8
  168. package/templates/web/src/notifications/__client/api.ts +0 -80
  169. /package/templates/api/src/_platform/_shared/service/{guard.ts → infra/guard.ts} +0 -0
@@ -7,9 +7,9 @@
7
7
  "react-router": "7.18.2",
8
8
  "react-router/dom": "7.18.2",
9
9
  "@tanstack/react-query": "5.102.8",
10
- "@oj-module/runtime": "0.2.6",
11
- "@oj-module/runtime/contract": "0.2.6",
12
- "@oj-module/runtime/contract/errors": "0.2.6",
10
+ "@oj-module/runtime": "0.2.14",
11
+ "@oj-module/runtime/contract": "0.2.14",
12
+ "@oj-module/runtime/contract/errors": "0.2.14",
13
13
  "@plane/kit": "0.1.20",
14
14
  "@plane/editor": "0.1.20",
15
15
  "antd": "6.6.1",
@@ -0,0 +1,64 @@
1
+ // buildBackend spec:宿主 versions.json 双落点(方案安装的 C2-09 前置)。
2
+ //
3
+ // 安装器按 B-11 布局读 `<api>/dist/versions.json`(与 manifests.yaml 同层)做 peerRuntime
4
+ // 强制比对;而站点壳的 versions.json 落在 web/dist。两者不同源时,新工程 `ojm build`
5
+ // 完仍会在 preflight 被 C2-09 拒装——这条用例钉住「同源双写」,防止回归。
6
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { describe, expect, it } from "vitest";
10
+
11
+ import { buildBackend } from "../build";
12
+
13
+ /**
14
+ * 假 oj 二进制:只需成功退出——本 spec 验的是 buildBackend 在 oj build 之后的收尾动作
15
+ * (versions.json 同源落地),oj build 自身的产物由用例预先放好模拟。
16
+ */
17
+ function fakeOj(projectRoot: string): void {
18
+ mkdirSync(join(projectRoot, "bin"), { recursive: true });
19
+ const bin = join(projectRoot, "bin", "oj");
20
+ writeFileSync(bin, "#!/bin/sh\nexit 0\n");
21
+ chmodSync(bin, 0o755);
22
+ }
23
+
24
+ function newProject(): string {
25
+ const root = mkdtempSync(join(tmpdir(), "ojm-build-backend-"));
26
+ mkdirSync(join(root, "api", "src", "demo"), { recursive: true });
27
+ writeFileSync(join(root, "api", "src", "demo", "manifest.yaml"), "name: demo\nversion: 0.1.0\n", "utf-8");
28
+ writeFileSync(join(root, "api", "config.yaml"), "server:\n port: 9778\n", "utf-8");
29
+ // 模拟 oj build 已产出的 manifests.yaml(验证收尾动作不破坏既有产物)
30
+ mkdirSync(join(root, "api", "dist"), { recursive: true });
31
+ writeFileSync(join(root, "api", "dist", "manifests.yaml"), "demo: 0.1.0\n", "utf-8");
32
+ fakeOj(root);
33
+ return root;
34
+ }
35
+
36
+ describe("given 宿主工程有 api/src When ojm build 后端", () => {
37
+ it("then api/dist/versions.json 与 shell-dist 同源落地(安装器 C2-09 前置)", async () => {
38
+ const root = newProject();
39
+
40
+ await buildBackend(root);
41
+
42
+ const hostVersions = join(root, "api", "dist", "versions.json");
43
+ expect(existsSync(hostVersions)).toBe(true);
44
+ const parsed = JSON.parse(readFileSync(hostVersions, "utf-8")) as Record<string, string>;
45
+ expect(typeof parsed["@oj-module/runtime"]).toBe("string");
46
+ });
47
+
48
+ it("then 既有 oj build 产物不被覆盖(manifests.yaml 原样保留)", async () => {
49
+ const root = newProject();
50
+
51
+ await buildBackend(root);
52
+
53
+ expect(readFileSync(join(root, "api", "dist", "manifests.yaml"), "utf-8")).toBe("demo: 0.1.0\n");
54
+ });
55
+ });
56
+
57
+ describe("given 纯前端工程(无 api/src)When ojm build 后端", () => {
58
+ it("then 跳过构建、不产出 api/dist", async () => {
59
+ const root = mkdtempSync(join(tmpdir(), "ojm-build-backend-fe-"));
60
+
61
+ expect(await buildBackend(root)).toBe(false);
62
+ expect(existsSync(join(root, "api", "dist"))).toBe(false);
63
+ });
64
+ });
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { hostArtifactFreshnessError } from "../versions";
4
+
5
+ describe("hostArtifactFreshnessError(源码形态宿主产物过期守卫)", () => {
6
+ it("版本一致 → 通过", () => {
7
+ expect(hostArtifactFreshnessError("0.2.11", "0.2.11")).toBeUndefined();
8
+ });
9
+
10
+ it("任一侧缺失 → 跳过守卫", () => {
11
+ expect(hostArtifactFreshnessError(undefined, "0.2.11")).toBeUndefined();
12
+ expect(hostArtifactFreshnessError("0.2.11", undefined)).toBeUndefined();
13
+ });
14
+
15
+ it("宿主产物落后于 workspace runtime → 报真因并指向 build:shell", () => {
16
+ const err = hostArtifactFreshnessError("0.2.10", "0.2.11");
17
+ expect(err).toContain("shell-dist/versions.json)过期");
18
+ expect(err).toContain("0.2.10");
19
+ expect(err).toContain("0.2.11");
20
+ expect(err).toContain("build:shell");
21
+ });
22
+
23
+ it("宿主产物领先(跨仓消费旧版 runtime)→ 同样拦截", () => {
24
+ expect(hostArtifactFreshnessError("0.2.12", "0.2.11")).toContain("过期");
25
+ });
26
+ });
package/src/build.ts CHANGED
@@ -511,6 +511,15 @@ export async function buildBackend(projectRoot: string): Promise<boolean> {
511
511
  // SyntaxError: Invalid left-hand side in assignment(端点 500)。
512
512
  // 0.1.32 / 0.1.50 均复现。产物体积换正确性,等上游修好再撤这个旗标。
513
513
  execFileSync(oj, ["build", ...configArgs, "-d", apiSrc, "-o", apiDist, "--no-minify"], { stdio: "inherit" });
514
+ // 宿主 versions.json 双落点:站点壳从 shell-dist 拷进 web/dist(mergeShellSite),
515
+ // 而方案安装器读的是 `<api>/dist/versions.json`(B-11:与 manifests.yaml 同层,用于
516
+ // peerRuntime 强制比对)。两处必须同源,否则新工程 `ojm build` 完仍装不上方案——
517
+ // preflight 直接 C2-09 fail-closed「无法读取宿主 runtime 版本」。
518
+ const versionsSrc = path.join(resolveShellDist(), "versions.json");
519
+ if (fs.existsSync(versionsSrc)) {
520
+ fs.mkdirSync(apiDist, { recursive: true });
521
+ fs.copyFileSync(versionsSrc, path.join(apiDist, "versions.json"));
522
+ }
514
523
  return true;
515
524
  }
516
525
 
package/src/bundle.ts CHANGED
@@ -116,8 +116,14 @@ export async function buildBundle(projectRoot: string, opts: { out?: string } =
116
116
  }
117
117
  }
118
118
 
119
- /** C2-31 分层核对:模块级 manifest 仅 oj 原生键,且 name/version/desc 与 bundle 级逐一相等(oj 启动强校验同源) */
120
- function checkModuleManifest(built: string, name: string, version: string, summary: string): string[] {
119
+ /**
120
+ * C2-31 分层核对:模块级 manifest 仅 oj 原生键,且 name/version 与 bundle 级逐一相等
121
+ * (oj 启动强校验同源)。
122
+ *
123
+ * 变更(2026-10-21):`desc === summary` 的等值要求已移除(与安装侧 extract.ts 同步)——
124
+ * 那是构建期映射的产物而不是安全边界,保留它会让 desc 与 summary 一字之差的包直接装不上。
125
+ */
126
+ function checkModuleManifest(built: string, name: string, version: string, _summary: string): string[] {
121
127
  const p = path.join(built, "manifest.yaml");
122
128
  let doc: unknown;
123
129
  try {
@@ -137,9 +143,6 @@ function checkModuleManifest(built: string, name: string, version: string, summa
137
143
  if (mm.version !== version) {
138
144
  out.push(`模块级 version「${String(mm.version)}」必须 === bundle version「${version}」`);
139
145
  }
140
- if (mm.desc !== summary) {
141
- out.push(`模块级 desc「${String(mm.desc)}」必须 === bundle summary「${summary}」(安装器写 desc 即取 summary)`);
142
- }
143
146
  return out;
144
147
  }
145
148
 
@@ -51,6 +51,35 @@ export default createMarketApi({} as Env);
51
51
  ]);
52
52
  });
53
53
 
54
+ it("标识符默认导出指回工厂调用:const x = createXxx(env); export default x(market/api.ts 真形态)", () => {
55
+ // 真实模板形态:工厂调用赋给 const,再 export default 该标识符。
56
+ // 此前 scanHandlerFile 只认「default 导出 = 调用表达式」,认不出
57
+ // 「default 导出 = 标识符 → 指向一次工厂调用」,导致 market 整族 route 漏登。
58
+ const rows = scan("api.ts", `
59
+ import type { Env } from "./types";
60
+ export function createMarketApi(env: Env) {
61
+ return {
62
+ "install": {
63
+ async post() { return {}; },
64
+ },
65
+ "verify": {
66
+ async post() { return {}; },
67
+ last: {
68
+ async get() { return {}; },
69
+ },
70
+ },
71
+ };
72
+ }
73
+ const marketApi = createMarketApi({} as Env);
74
+ export default marketApi;
75
+ `);
76
+ expect(rows.map(r => `${r.method} ${r.full}`).sort()).toEqual([
77
+ "GET _platform/market/verify/last",
78
+ "POST _platform/market/install",
79
+ "POST _platform/market/verify",
80
+ ]);
81
+ });
82
+
54
83
  it("无 return 对象 / 未知 callee / 点分 callee 不可解析 → 零行(不炸不猜)", () => {
55
84
  expect(scan("a.ts", "export default createMissingApi({});")).toEqual([]);
56
85
  expect(scan("b.ts", `
@@ -0,0 +1,170 @@
1
+ // ScopedRequestLike 真源在 runtime contract(生成物 __client/api.ts 也从这里 import,
2
+ // 自身不再 re-export——旧写法 type import 自 __client 会在再生后 TS2305)。
3
+ import type { ScopedRequestLike } from "@oj-module/runtime/contract/errors";
4
+
5
+ // 集成回归:真实 httpMarketClient(统一后委托 ojm 生成 client)的委托行为。
6
+ // 经 bindRequest 注入 mock request,验证每条 MarketClient 方法 → 正确 URL/body/query + 返回形状;
7
+ // 并验证 index 的 503「未配置市场地址」→ [] 空态(PRD §5.3)。
8
+ //
9
+ // 不加载 @oj-module/runtime 重依赖(antd / pro-components 在 node 环境无法 import),仅桩 request、
10
+ // 透传真实 zod(供生成 client 的 DEV 响应校验走真实路径)。ContractApiError 由轻量子路径提供。
11
+ import { beforeEach, describe, expect, it, vi } from "vitest";
12
+ import { bindRequest } from "../../../templates/web/src/_platform/__client/api";
13
+ import { httpMarketClient } from "../../../templates/web/src/oj-market/api/defaultClient";
14
+
15
+ // index() 直连 request(不走生成 client),故把桩 request 做成可变实现,测试内统一指向同一 mock。
16
+ const { requestImpl } = vi.hoisted(() => ({
17
+ requestImpl: {
18
+ get: () => { throw new Error("placeholder"); },
19
+ post: () => { throw new Error("placeholder"); },
20
+ put: () => { throw new Error("placeholder"); },
21
+ delete: () => { throw new Error("placeholder"); },
22
+ patch: () => { throw new Error("placeholder"); },
23
+ head: () => { throw new Error("placeholder"); },
24
+ },
25
+ }));
26
+
27
+ vi.mock("@oj-module/runtime", async () => {
28
+ const { z } = await import("zod");
29
+ return {
30
+ z,
31
+ // defaultClient 顶层 bindRequest(request) 用到;测试内立即被 mock 覆盖,不会真发请求。
32
+ request: requestImpl,
33
+ };
34
+ });
35
+
36
+ interface Call {
37
+ method: string
38
+ url: string
39
+ opts?: { searchParams?: Record<string, string | number | boolean>, json?: unknown }
40
+ }
41
+
42
+ function mockRequest(responder: (c: Call) => { status?: number, data?: unknown }) {
43
+ const calls: Call[] = [];
44
+ const make = (method: string) => (url: string, opts?: Call["opts"]) => {
45
+ calls.push({ method, url, opts });
46
+ const r = responder({ method, url, opts });
47
+ if (r.status && r.status >= 400) {
48
+ // ky 形态:HTTPError 携带 response.status(httpMarketClient.isUnconfigured 依赖它)
49
+ throw Object.assign(new Error(`HTTP ${r.status}`), { response: { status: r.status } });
50
+ }
51
+ return { json: () => Promise.resolve({ code: 0, data: r.data ?? null }) };
52
+ };
53
+ const req = {
54
+ get: make("get"),
55
+ post: make("post"),
56
+ put: make("put"),
57
+ delete: make("delete"),
58
+ patch: make("patch"),
59
+ head: make("head"),
60
+ } as unknown as ScopedRequestLike;
61
+ return { calls, req };
62
+ }
63
+
64
+ // 让 index() 直连的 request 与生成 client 的 bindRequest 指向同一 mock,行为一致。
65
+ // 桥接:占位方法零参签名 ↔ ScopedRequestLike 真实签名,经 any 双向打通(测试桩,无运行时差异)。
66
+ function useMock(m: ReturnType<typeof mockRequest>) {
67
+ bindRequest(m.req);
68
+ const req = m.req as unknown as Record<keyof typeof requestImpl, () => never>;
69
+ for (const k of Object.keys(requestImpl) as Array<keyof typeof requestImpl>)
70
+ requestImpl[k] = req[k];
71
+ }
72
+
73
+ describe("httpMarketClient · 统一委托生成 client 集成回归", () => {
74
+ let mock: ReturnType<typeof mockRequest>;
75
+
76
+ beforeEach(() => {
77
+ mock = mockRequest(() => ({ data: {} }));
78
+ useMock(mock);
79
+ });
80
+
81
+ it("meta() → GET _platform/market/meta,返回 { apiVersion }", async () => {
82
+ mock = mockRequest(() => ({ data: { apiVersion: "1.2.3" } }));
83
+ useMock(mock);
84
+ const r = await httpMarketClient.meta();
85
+ expect(mock.calls).toEqual([{ method: "get", url: "_platform/market/meta", opts: undefined }]);
86
+ expect(r).toEqual({ apiVersion: "1.2.3" });
87
+ });
88
+
89
+ it("index() → GET _platform/market/index,返回 IndexRow[]", async () => {
90
+ const rows = [{ name: "a", latest: "1.0", installed: "1.0", enabled: true, upgradable: false }];
91
+ mock = mockRequest(() => ({ data: rows }));
92
+ useMock(mock);
93
+ const r = await httpMarketClient.index();
94
+ expect(mock.calls[0]).toMatchObject({ method: "get", url: "_platform/market/index" });
95
+ expect(r).toEqual(rows);
96
+ });
97
+
98
+ it("index() 遇 503(未配置市场地址)→ [] 空态,不抛(PRD §5.3)", async () => {
99
+ mock = mockRequest(c => (c.url === "_platform/market/index" ? { status: 503 } : { data: {} }));
100
+ useMock(mock);
101
+ const r = await httpMarketClient.index();
102
+ expect(r).toEqual([]);
103
+ // 503 是配置态而非故障:silentError 抑制全局 toast + retry:0 不重试(原
104
+ // market-client-unconfigured 用例的断言并入此处——工厂形态退役后唯一活口)
105
+ expect(mock.calls).toHaveLength(1);
106
+ expect(mock.calls[0].opts).toMatchObject({ silentError: true, retry: 0 });
107
+ });
108
+
109
+ it("install(input) → POST _platform/market/install,body 含 name/version/source", async () => {
110
+ mock = mockRequest(() => ({ data: { task_id: "t-1" } }));
111
+ useMock(mock);
112
+ const r = await httpMarketClient.install({ name: "alpha", version: "2.0", source: "file" });
113
+ expect(mock.calls[0]).toMatchObject({ method: "post", url: "_platform/market/install" });
114
+ expect(mock.calls[0].opts?.json).toEqual({ name: "alpha", version: "2.0", source: "file" });
115
+ expect(r).toEqual({ task_id: "t-1" });
116
+ });
117
+
118
+ it("install(input) 缺省 version/source → null / cdn 兜底", async () => {
119
+ mock = mockRequest(() => ({ data: { task_id: "t-2" } }));
120
+ useMock(mock);
121
+ await httpMarketClient.install({ name: "beta" });
122
+ expect(mock.calls[0].opts?.json).toEqual({ name: "beta", version: null, source: "cdn" });
123
+ });
124
+
125
+ it("task(id) → GET _platform/market/task? 带 id", async () => {
126
+ const rec = { id: "t-1", name: "a", version: "1.0", status: "done", stage: "done", progress: 100, message: "ok", stages: [], startedAt: 1 };
127
+ mock = mockRequest(() => ({ data: rec }));
128
+ useMock(mock);
129
+ const r = await httpMarketClient.task("t-1");
130
+ expect(mock.calls[0]).toMatchObject({ method: "get", url: "_platform/market/task" });
131
+ expect(mock.calls[0].opts?.searchParams).toEqual({ id: "t-1" });
132
+ expect(r).toEqual(rec);
133
+ });
134
+
135
+ it("enable(input) → POST _platform/market/enable,body {name,enabled}", async () => {
136
+ mock = mockRequest(() => ({ data: { ok: true, message: "done" } }));
137
+ useMock(mock);
138
+ const r = await httpMarketClient.enable({ name: "a", enabled: false });
139
+ expect(mock.calls[0]).toMatchObject({ method: "post", url: "_platform/market/enable" });
140
+ expect(mock.calls[0].opts?.json).toEqual({ name: "a", enabled: false });
141
+ expect(r).toEqual({ ok: true, message: "done" });
142
+ });
143
+
144
+ it("history() → GET _platform/market/history,返回 MarketHistory[]", async () => {
145
+ const hist = [{ id: 1, solution: "a", version: "1.0", action: "install", status: "ok", operator: null, message: null, created_at: 1 }];
146
+ mock = mockRequest(() => ({ data: hist }));
147
+ useMock(mock);
148
+ const r = await httpMarketClient.history();
149
+ expect(mock.calls[0]).toMatchObject({ method: "get", url: "_platform/market/history" });
150
+ expect(r).toEqual(hist);
151
+ });
152
+
153
+ it("uninstallImpact(name) → GET _platform/market/uninstall-impact? 带 name", async () => {
154
+ mock = mockRequest(() => ({ data: { name: "a", affectedRoles: 2, affectedUsers: 5 } }));
155
+ useMock(mock);
156
+ const r = await httpMarketClient.uninstallImpact("a");
157
+ expect(mock.calls[0]).toMatchObject({ method: "get", url: "_platform/market/uninstall-impact" });
158
+ expect(mock.calls[0].opts?.searchParams).toEqual({ name: "a" });
159
+ expect(r).toEqual({ name: "a", affectedRoles: 2, affectedUsers: 5 });
160
+ });
161
+
162
+ it("uninstall(name) → POST _platform/market/uninstall,body {name}", async () => {
163
+ mock = mockRequest(() => ({ data: { ok: true, message: "removed" } }));
164
+ useMock(mock);
165
+ const r = await httpMarketClient.uninstall("a");
166
+ expect(mock.calls[0]).toMatchObject({ method: "post", url: "_platform/market/uninstall" });
167
+ expect(mock.calls[0].opts?.json).toEqual({ name: "a" });
168
+ expect(r).toEqual({ ok: true, message: "removed" });
169
+ });
170
+ });
@@ -94,6 +94,20 @@ function collectObjectDecls(sf: ts.SourceFile): Map<string, ts.ObjectLiteralExpr
94
94
  return decls;
95
95
  }
96
96
 
97
+ /** 顶层 `const x = createXxx(env)` 调用声明表:工厂形态经标识符引用(`export default x`)时的解析依据 */
98
+ function collectCallDecls(sf: ts.SourceFile): Map<string, ts.CallExpression> {
99
+ const decls = new Map<string, ts.CallExpression>();
100
+ for (const stmt of sf.statements) {
101
+ if (!ts.isVariableStatement(stmt))
102
+ continue;
103
+ for (const decl of stmt.declarationList.declarations) {
104
+ if (ts.isIdentifier(decl.name) && decl.initializer && ts.isCallExpression(decl.initializer))
105
+ decls.set(decl.name.text, decl.initializer);
106
+ }
107
+ }
108
+ return decls;
109
+ }
110
+
97
111
  /** verb 键 → 实现函数名(供 `.route` 语句按函数名查找;内联写法回落键名) */
98
112
  function implNameOf(prop: ts.ObjectLiteralElementLike): string | undefined {
99
113
  if (ts.isShorthandPropertyAssignment(prop))
@@ -141,9 +155,16 @@ function defaultExportObject(sf: ts.SourceFile, decls: Map<string, ts.ObjectLite
141
155
  * 是调用表达式,此前扫描器不认,route-unregistered 守卫对 market/solutions 全族失效):
142
156
  * 解析同文件同名函数声明体内 return 的对象字面量(运行时 oj 内省执行工厂拿到该对象,静态
143
157
  * 扫描镜像此语义)。支持多个 return;对象内标识符引用仍解析文件级顶层 const。
158
+ * 补全(P2-14 形态遗漏):`export default createXxxApi(marketEnv())` 中 `call.expression`
159
+ * 是**带实参的调用表达式**,函数名落在 `call.expression.expression`(createXxxApi);
160
+ * 旧逻辑直接 dottedNameOf(call.expression) 对 CallExpression 返回 undefined → 扫成 0 路由,
161
+ * 致使 ojm api --check 把 market 全族端点判为「未实现 / stub 缺失」。此处穿透一层取被调函数名。
144
162
  */
145
163
  function factoryReturnObjects(sf: ts.SourceFile, call: ts.CallExpression): ts.ObjectLiteralExpression[] {
146
- const callee = dottedNameOf(call.expression);
164
+ // `export default createXxxApi(marketEnv())`:`call.expression` 为带实参的调用表达式,
165
+ // 函数名在其 `.expression`;`export default obj.createXxxApi(marketEnv())` 同样穿透。
166
+ const target = ts.isCallExpression(call.expression) ? call.expression.expression : call.expression;
167
+ const callee = dottedNameOf(target);
147
168
  if (callee === undefined)
148
169
  return [];
149
170
  for (const stmt of sf.statements) {
@@ -189,6 +210,7 @@ export function scanHandlerFile(filePath: string, apiSrcDir: string): HandlerRow
189
210
  const sf = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
190
211
  const routes = collectRouteDecls(sf);
191
212
  const decls = collectObjectDecls(sf);
213
+ const callDecls = collectCallDecls(sf);
192
214
  const relDir = relative(apiSrcDir, dirname(filePath)).split(sep).join("/");
193
215
  const rows: HandlerRow[] = [];
194
216
  const seen = new Set<ts.Node>();
@@ -251,10 +273,19 @@ export function scanHandlerFile(filePath: string, apiSrcDir: string): HandlerRow
251
273
  walk(root, "", filePrefix, 1);
252
274
  }
253
275
  else {
254
- // P2-14:工厂形态(CallExpression)——组前缀从目录镜像起算(同内联字面量形态)
276
+ // 工厂形态(P2-14 + 补全):export default 后接
277
+ // - CallExpression 直调:createXxx(env) / createXxx(marketEnv())
278
+ // - Identifier 引用:const api = createXxx(env); export default api
279
+ // 组前缀从目录镜像起算(同内联字面量形态)。
255
280
  for (const stmt of sf.statements) {
256
- if (ts.isExportAssignment(stmt) && !stmt.isExportEquals && ts.isCallExpression(stmt.expression)) {
257
- for (const obj of factoryReturnObjects(sf, stmt.expression))
281
+ if (!ts.isExportAssignment(stmt) || stmt.isExportEquals)
282
+ continue;
283
+ const expr = stmt.expression;
284
+ const callExpr = ts.isCallExpression(expr)
285
+ ? expr
286
+ : (ts.isIdentifier(expr) ? callDecls.get(expr.text) : undefined);
287
+ if (callExpr) {
288
+ for (const obj of factoryReturnObjects(sf, callExpr))
258
289
  walk(obj, "", "", 1);
259
290
  }
260
291
  }
package/src/dev.ts CHANGED
@@ -183,11 +183,19 @@ export async function devServer(projectRoot: string, opts: DevOptions = {}): Pro
183
183
 
184
184
  // 终端 Ctrl+C / `kill`(SIGINT / SIGTERM)终止 dev 进程时,后端 oj 必须随之消亡:
185
185
  // 起一个统一优雅停机,先停 oj 再退出;并打印后端终止信息。
186
+ // 终端 Ctrl-C 是进程组信号:外层 bin 转发一份 + 组送达一份,内层会收到
187
+ // 两个 SIGINT——once 首次触发即摘除,第二个走默认动作杀死本进程,
188
+ // shutdownDev 跑不完(与 preview 同根因,2026-10-19 冒烟实测)。on +
189
+ // 幂等守卫吞掉重复信号。
190
+ let shuttingDown = false;
186
191
  const shutdown = () => {
192
+ if (shuttingDown)
193
+ return;
194
+ shuttingDown = true;
187
195
  void shutdownDev(server, oj, hub);
188
196
  };
189
- process.once("SIGINT", shutdown);
190
- process.once("SIGTERM", shutdown);
197
+ process.on("SIGINT", shutdown);
198
+ process.on("SIGTERM", shutdown);
191
199
 
192
200
  // 兜底:dev 进程以任何非优雅路径退出(未走 SIGINT/SIGTERM,如未捕获异常)时,
193
201
  // 同步强杀 oj,确保它不孤儿化继续占端口(shutdownDev 已停则 kill 为 no-op)。
package/src/index.ts CHANGED
@@ -7,7 +7,7 @@ import { buildBundle } from "./bundle";
7
7
  import { devServer } from "./dev";
8
8
  import { mergeManifests, printInfo } from "./info";
9
9
  import { initProject } from "./init";
10
- import { previewServer } from "./preview";
10
+ import { runPreview } from "./preview";
11
11
  import { usageText } from "./usage";
12
12
  import { vendorCommand } from "./vendor";
13
13
  import { readCliVersion } from "./versions";
@@ -51,8 +51,8 @@ async function main() {
51
51
  }
52
52
  case "preview": {
53
53
  const previewPortArg = Number(process.argv[3]);
54
- const previewPort = Number.isFinite(previewPortArg) && previewPortArg > 0 ? previewPortArg : undefined;
55
- await previewServer(projectRoot, { port: previewPort, ojStatic: process.argv.includes("--oj-static") });
54
+ const previewPort = Number.isInteger(previewPortArg) && previewPortArg > 0 ? previewPortArg : undefined;
55
+ await runPreview(projectRoot, { port: previewPort });
56
56
  break;
57
57
  }
58
58
  case "dev": {
@@ -161,6 +161,12 @@ async function main() {
161
161
  publishCommand(positionals[0] ?? "", { id, key, check });
162
162
  break;
163
163
  }
164
+ case "market": {
165
+ // 本地模拟 CDN 与市场链路(setup / publish / build-index)
166
+ const { runMarket } = await import("./market/command");
167
+ await runMarket(process.argv.slice(3));
168
+ break;
169
+ }
164
170
  default: {
165
171
  if (command && OJ_PASSTHROUGH.has(command)) {
166
172
  const { runOjSubcommand } = await import("./oj-cmd");
package/src/init.ts CHANGED
@@ -98,6 +98,13 @@ export async function initProject(destDir: string, opts: InitOptions = {}): Prom
98
98
  fs.mkdirSync(skillDir, { recursive: true });
99
99
  for (const name of ["SKILL.md", "api-manual.md"])
100
100
  copyIfAbsent(path.join(devkit, name), path.join(skillDir, name), report);
101
+ // v0.1.82 起 devkit 新增两份专题指南(api-manual/SKILL 均引用它们):
102
+ // SQL 构造器能力矩阵/方言差异、oj test L1 测试指南。它们是**增量文档**、
103
+ // 不影响 ojm 运行,故「有则拷、无则记跳过」——bin/devkit 来自用户本地
104
+ // oj 版本(MIN_OJ_VERSION 只钉到 0.1.73),旧版 devkit 没有这两个文件,
105
+ // 为两份文档把整个 init 打挂不值当(用户升 oj 后重跑 init 即补上)。
106
+ for (const name of ["sql-builder-guide.md", "oj-test-guide.md"])
107
+ copyIfPresent(path.join(devkit, name), path.join(skillDir, name), report);
101
108
  copyIfAbsent(path.join(devkit, "global.d.ts"), path.join(destDir, "global.d.ts"), report);
102
109
  }
103
110
  else {
@@ -367,6 +374,21 @@ function copyIfAbsent(src: string, dest: string, report: InitReport): void {
367
374
  report.created.push(path.relative(process.cwd(), dest) || path.basename(dest));
368
375
  }
369
376
 
377
+ /**
378
+ * 同 copyIfAbsent,但**源缺失**时记一条跳过而非抛错。
379
+ *
380
+ * 用于从 bin/devkit 拷第三方版本化内容(两份专题指南):devkit 随用户本地 oj
381
+ * 版本而变,旧版没有新文件属正常版本 skew,不是安装包损坏——缺了只少份文档,
382
+ * 不该让 init 失败。源在而目标已存在仍走 copyIfAbsent 的幂等跳过。
383
+ */
384
+ function copyIfPresent(src: string, dest: string, report: InitReport): void {
385
+ if (!fs.existsSync(src)) {
386
+ report.skipped.push(`${path.basename(src)}(bin/devkit 无此文件,跳过)`);
387
+ return;
388
+ }
389
+ copyIfAbsent(src, dest, report);
390
+ }
391
+
370
392
  /**
371
393
  * 新工程 .gitignore:不存在则生成;已存在则幂等跳过(用户规则永不覆盖)。
372
394
  *
@@ -378,6 +400,8 @@ const SCAFFOLD_GITIGNORE = `# 由 ojm init 生成。
378
400
  node_modules/
379
401
  bin/
380
402
  .ojm-tmp-*
403
+ # ojm preview 的派生 config(临时产物,进程退出即删;见 preview-config.ts)
404
+ api/.ojm-preview.yaml
381
405
  # 本地 sqlite 与运行日志
382
406
  api/db.sqlite
383
407
  api/db.sqlite-shm
@@ -33,7 +33,7 @@ afterEach(() => {
33
33
 
34
34
  /** 预置「已构建」产物:oj build(api/dist/<name>-<version>/)与站点构建(web/dist/modules/) */
35
35
  function makePrebuiltArtifacts(): void {
36
- // oj build 产物(模块级 manifest 仅 oj 原生七键;name/version/desc 与 bundle 级逐一相等,C2-31)
36
+ // oj build 产物(模块级 manifest 仅 oj 原生七键;name/version 与 bundle 级逐一相等,C2-31)
37
37
  const apiDist = join(project, "api/dist/demo-solution-0.1.0");
38
38
  mkdirSync(join(apiDist, "ping"), { recursive: true });
39
39
  mkdirSync(join(apiDist, "migrations"), { recursive: true });
@@ -101,9 +101,9 @@ describe("buildBundle · 校验先于打包(fail-fast)", () => {
101
101
  await expect(pack()).rejects.toThrow(/manifest\.yaml/);
102
102
  });
103
103
 
104
- it("c2-31 分层核对:模块级 desc ≠ bundle summary → 拒绝", async () => {
104
+ it("c2-31 分层核对:模块级 desc ≠ bundle summary → 放行(等值要求已移除)", async () => {
105
105
  writeFileSync(join(project, "api/dist/demo-solution-0.1.0/manifest.yaml"), "name: demo-solution\ndesc: 不一致描述\nversion: 0.1.0\n");
106
- await expect(pack()).rejects.toThrow(/C2-31/);
106
+ await expect(pack()).resolves.toMatchObject({ name: "demo-solution" });
107
107
  });
108
108
 
109
109
  it("c2-31 分层核对:api/dist 缺 <name>-<version>/ → 可读报错指明期望产物路径", async () => {