@oj-module/cli 0.2.62 → 0.2.63

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.
package/devkit/SKILL.md CHANGED
@@ -201,6 +201,7 @@ description: 用 ojm 开发前后端一体工程时使用——新增/修改前
201
201
  | dev 日志 route error(Cannot find module)/ 端点 404 | handler 相对 import 多一层——按**目录**解析 | 改用 `#/<模块名>/…` 别名(与深度无关);存量代码按 manual §1.7 深度表改 |
202
202
  | 限速/节流/计数真实请求下永不累积(测试全绿) | 模块级 `Map`/`let` 不跨请求存活(每请求重建模块) | 状态落库(模板迁移 0009 先例)或 oj 持久面 |
203
203
  | `ojm build` 报 `introspect api.ts: jwt not configured` | 模块级语句碰运行时全局,introspect 空沙箱炸 | 移进 handler 函数体内(manual §1.7-4) |
204
+ | `ojm build` 报「密封值解不开」/「本机没有能解开的私钥」 | 出包预检 fail-closed:合并产物兜底 + tracked 非 jwt 红线(manual §10.1) | jwt 坏了删 `config.local.yaml` 的 `auth.jwt_secret` 行重跑 `ojm dev`;非 jwt 取配对私钥或 local 覆盖该字段 |
204
205
  | 页签怎么点都关不掉、无报错提示 | `beforeClose` 守卫 fail-safe 否决(守卫抛错按取消处理) | 先查控制台 `[tabs] beforeClose` 报错;确认弹窗逻辑与 `resolve(true/false)` 收口(manual §6.4) |
205
206
  | 组织架构读面 403「该部门不在可见范围内」 | `scope` 默认 `self`,只看得到自己那条部门路径 | 换 `scope=all`(需权限点 `platform.org.read.all`),或改问自己所在部门(manual §14.2) |
206
207
  | 组织架构读面 400「只展开自己所在部门」 | `includeSub` 用在了祖先/兄弟部门上(self 档只允许展开自己所在部门) | 收窄到「我所在部门」,或申请 `scope=all`(manual §14.2) |
package/devkit/manual.md CHANGED
@@ -1583,6 +1583,8 @@ export default async (path: string, html: string) => {
1583
1583
  | dev 起不来,日志一堆 route error(Cannot find module) | handler 相对 import 多写了一层——按**目录**解析不是按文件(§1.7-2) | 按 §1.7 深度表改 |
1584
1584
  | 限速/节流/去重窗口在真实请求下永不累积(测试却全绿) | 模块级 `Map`/`let` 不跨请求存活——oj 每请求重建模块(§1.7-3) | 状态落库(模板 0009 先例)或走 oj 持久面 |
1585
1585
  | `ojm build` 报 `introspect api.ts: Error: jwt not configured`(dev 模式正常) | 模块级语句触碰运行时全局,introspect 空沙箱里炸(§1.7-4) | 移进 handler 函数体内按需执行 |
1586
+ | `ojm build` 报「config 里的密封值解不开,已停在出包前:.ojm-tmp-config.yaml」 | 合并产物里有解不开的密文:dev jwt 坏了,或 `config.local.yaml` 覆盖带进了外来 `ENC[...]`(预检兜底——oj build 读不动 config 退出码仍是 0,只 warn 两行) | 报错里附了 oj 原话与「单独验一条」的命令逐条排查;jwt 坏了删 `config.local.yaml` 的 `auth.jwt_secret` 行,重跑 `ojm dev` 重新密封 |
1587
+ | `ojm build` 报「配置中 /… 是 ENC[...] 密文,但本机没有能解开的私钥」 | tracked config 里有**非 jwt** 的生产密文(如落库加密键;红线,dev 不会用随机密钥重封——否则破坏已加密数据) | 向团队取配对私钥放到 `config/secrets-private.pem`,或在 `config.local.yaml` 覆盖该字段为可解密的明文/ENC |
1586
1588
  | 点登录页「企业微信」后页面只闪一下、Network 里一个 auth 请求都没有 | 整页跳转拼成了**站点 base**(根部署下得到 `/auth/wecom/login`,被 SPA 兜底页吞掉) | 改用 `resolveApiBase()` 拼后端端点:`window.location.assign(resolveApiBase() + wecomAuthorizePath(corp))` |
1587
1589
  | 企微登录恒 400 `企业微信未配置:config vars 缺少 …` | 部署常量没填全(该来源的凭据任一为空即整体拒服务) | 代开发见 `wecom-daikaifa.md` §2.1,企业内部应用见 `wecom-internal-app.md` §2.1 的凭据表与缺项后果 |
1588
1590
 
@@ -1682,13 +1684,29 @@ export default async (path: string, html: string) => {
1682
1684
  极端情况下(3s 未退)升级 `SIGKILL`,并在进程退出时以 `kill()` 强杀作为最后手段,防止 oj 孤儿化。
1683
1685
  - 内置 `openapi.json` watch:规范变更 → 自动重跑 `ojm api` → 产物写入 `web/` 触发模块重建 + 刷新。
1684
1686
  **新模块目录的诞生不在 watch 范围**——新建后端模块目录要先重启 `ojm dev`(目录注册发生在启动期)。
1687
+ - **配置合并(方案 A',与 `ojm build` 同链路)**:起 oj 前自动供给本机密钥——`config/secrets-{private,public}.pem`
1688
+ 缺失则 `oj secret keygen`;`config.local.yaml`(gitignored、可手编)缺 dev `jwt_secret` 则随机生成并密封。
1689
+ tracked `config.yaml` 与 local 深合并成 `.ojm-tmp-config.yaml`(git 不跟踪、与 config.yaml 同目录)交给
1690
+ `oj serve -c`——tracked 里的 prod `ENC[...]` 仅作 prod 资产,jwt 被本机 dev 密文覆盖;**非 jwt**
1691
+ 的 ENC 解不开会红线报错(dev 不会用随机密钥重封,防破坏已加密落库数据)。
1685
1692
 
1686
1693
  #### `ojm build`
1687
1694
 
1688
1695
  构建后端与前端产物(设计 §5):
1689
1696
 
1690
1697
  1. **后端**:`oj build`——生成 `api/dist/routes.js`(目录镜像路由 + 参数路由在此确定);
1691
- 2. **前端**:模块构建 + **全站合并**到 `web/dist/`(仅 `build` 做清场合并)。
1698
+ 2. **前端**:模块构建 + **全站合并**到 `web/dist/`(仅 `build` 做清场合并);
1699
+ 3. **配置**:与 dev 同一条合并链路(见 `ojm dev`)——tracked `config.yaml` + gitignored
1700
+ `config.local.yaml` 深合并成 `.ojm-tmp-config.yaml`,`-c` 指向合并产物。全新 clone 的机器只有
1701
+ 本机 dev 密钥对,tracked 的 prod `ENC[...]`(如 init 密封的 `auth.jwt_secret`)按设计解不开——
1702
+ 合并链路里 jwt 已被本机 dev 密文覆盖,build 不再误杀;编译本身不需要密文明文,产物也不嵌
1703
+ config(部署侧自带)。
1704
+
1705
+ > **出包预检(fail-closed)**:合并产物里每条 `ENC[...]` 先 `oj secret open` 试解一把——oj build
1706
+ > 读不动 config 只 warn 两行、退出码仍是 0(`sql_guard` 关、`tasks` 镜像目录回落默认),ojm 必须
1707
+ > 在出包前拦住。tracked 里**非 jwt** 的 `ENC[...]` 解不开则红线报错(真实生产数据密钥,dev 不会
1708
+ > 用随机密钥重封)——把配对私钥放到 `config/secrets-private.pem`,或在 `config.local.yaml` 覆盖
1709
+ > 该字段。处置速查见 §8 排障表。
1692
1710
 
1693
1711
  > release 下只有 `routes.js` 里的路由存在,目录镜像路由不生效——所以参数路由要先
1694
1712
  > `ojm build` 再测(见 §7)。
@@ -15,6 +15,7 @@
15
15
  | 模块在哪 | 工程内源码:`src/A/api` + `src/A/web`(历史布局:`api/src/A` + `web/src/A`) | 一个 `.tgz` 包(`ojm build --bundle` 产出) |
16
16
  | 谁登记它 | `ojm build` 扫描 `src/*/web/entry.ts`(`web.config.ts` 可选覆盖)→ 生成 `web/dist/modules.json` | 安装器往目标站点 upsert |
17
17
  | 跑起来靠 | `ojm dev` 直跑源码,改一行刷新即见 | 安装器十阶段(下载→验签→解压→迁移→注册→热更→冒烟) |
18
+ | config 读哪份 | tracked `config.yaml` + gitignored `config.local.yaml` 深合并成 `.ojm-tmp-config.yaml`——dev 与 build 同一条链路,jwt 被本机 dev 密文覆盖 | 产物不嵌 config,部署侧自带自己的 config(tracked prod `ENC[...]` 那份) |
18
19
  | 典型场景 | 你在写它 | 别人(或另一个站点)装它 |
19
20
 
20
21
  一句话:**开发态没有「安装」这个动作**。`ojm dev` 起的站点直接扫描 `src/` 产生路由(`oj build --module-root src`)、读 `modules.json` 装配菜单——你写的模块 A 此刻「已经在跑」,只是还没打过包。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@oj-module/cli",
3
3
  "type": "module",
4
- "version": "0.2.62",
4
+ "version": "0.2.63",
5
5
  "description": "React Antd 模块工程命令行工具(含预构建宿主 shell-dist)",
6
6
  "license": "MIT",
7
7
  "exports": {
@@ -49,7 +49,7 @@
49
49
  ],
50
50
  "dependencies": {
51
51
  "@faker-js/faker": "^10.4.0",
52
- "@oj-module/runtime": "0.2.62",
52
+ "@oj-module/runtime": "0.2.63",
53
53
  "@redocly/cli": "^2.51.0",
54
54
  "@tailwindcss/vite": "^4.3.0",
55
55
  "cac": "^6.7.14",
@@ -304,10 +304,10 @@ function getAppInfo() {
304
304
  return {
305
305
  "pkg": {
306
306
  "name": "@oj-module/runtime",
307
- "version": "0.2.62",
307
+ "version": "0.2.63",
308
308
  "license": "MIT"
309
309
  },
310
- "lastBuildTime": "2026-11-04 08:31:17"
310
+ "lastBuildTime": "2026-11-04 10:42:39"
311
311
  };
312
312
  }
313
313
  var init_get_app_info = __esmMin((() => {}));
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" href="/favicon.ico" />
7
7
  <title>React Antd Admin · Shell</title>
8
- <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-SJbJrNlpPRxj8pl6EctCJA=='; script-src-attr 'none'; connect-src 'self' data: https://modules.cdn.example.com; style-src 'self' https://modules.cdn.example.com 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-src https://ant.design https://react.dev https://condorheroblog.github.io; form-action 'none'; base-uri 'none'; object-src 'none'; upgrade-insecure-requests" />
9
- <script type="importmap" nonce="SJbJrNlpPRxj8pl6EctCJA==">{
8
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-wGgfKOcX0w89VQKxWfhA2A=='; script-src-attr 'none'; connect-src 'self' data: https://modules.cdn.example.com; style-src 'self' https://modules.cdn.example.com 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; frame-src https://ant.design https://react.dev https://condorheroblog.github.io; form-action 'none'; base-uri 'none'; object-src 'none'; upgrade-insecure-requests" />
9
+ <script type="importmap" nonce="wGgfKOcX0w89VQKxWfhA2A==">{
10
10
  "imports": {
11
11
  "react": "/assets/react.js",
12
12
  "react/jsx-runtime": "/assets/jsx-runtime.js",
@@ -130,7 +130,7 @@
130
130
  .boot-skeleton:after{content:"";width:18px;height:18px;margin-left:8px;border:2px solid #d9d9d9;border-top-color:#1677ff;border-radius:50%;animation:bs-spin .8s linear infinite}
131
131
  @keyframes bs-spin{to{transform:rotate(360deg)}}
132
132
  </style>
133
- <script nonce="SJbJrNlpPRxj8pl6EctCJA==" data-ojm-entry="/assets/index-BKtXxIXz.js">(function(){try{var ua=(navigator.userAgent||"").toLowerCase(),p=location.pathname;while(p.length>1&&p.charAt(p.length-1)==="/")p=p.slice(0,-1);if(ua.indexOf("wxwork")>=0&&p.slice(-6)==="/login"&&!new URLSearchParams(location.search).get("wecom_ticket")){var a=(typeof window.__OJ_API_BASE__==="string"&&window.__OJ_API_BASE__.trim())||"/api";while(a.length>1&&a.charAt(a.length-1)==="/")a=a.slice(0,-1);location.replace(a+"/auth/wecom/login");return}}catch(e){}var s=document.createElement("script");s.type="module";s.src=document.currentScript.getAttribute("data-ojm-entry");document.head.appendChild(s);})();</script>
133
+ <script nonce="wGgfKOcX0w89VQKxWfhA2A==" data-ojm-entry="/assets/index-BKtXxIXz.js">(function(){try{var ua=(navigator.userAgent||"").toLowerCase(),p=location.pathname;while(p.length>1&&p.charAt(p.length-1)==="/")p=p.slice(0,-1);if(ua.indexOf("wxwork")>=0&&p.slice(-6)==="/login"&&!new URLSearchParams(location.search).get("wecom_ticket")){var a=(typeof window.__OJ_API_BASE__==="string"&&window.__OJ_API_BASE__.trim())||"/api";while(a.length>1&&a.charAt(a.length-1)==="/")a=a.slice(0,-1);location.replace(a+"/auth/wecom/login");return}}catch(e){}var s=document.createElement("script");s.type="module";s.src=document.currentScript.getAttribute("data-ojm-entry");document.head.appendChild(s);})();</script>
134
134
  </head>
135
135
  <body>
136
136
  <div id="root"><div class="boot-skeleton">加载中…</div></div>
@@ -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.62",
11
- "@oj-module/runtime/contract": "0.2.62",
12
- "@oj-module/runtime/contract/errors": "0.2.62",
10
+ "@oj-module/runtime": "0.2.63",
11
+ "@oj-module/runtime/contract": "0.2.63",
12
+ "@oj-module/runtime/contract/errors": "0.2.63",
13
13
  "@plane/kit": "0.1.20",
14
14
  "@plane/editor": "0.1.20",
15
15
  "antd": "6.6.1",
@@ -1,35 +1,97 @@
1
- // buildBackend spec:宿主 versions.json 双落点(方案安装的 C2-09 前置)。
1
+ // buildBackend spec:宿主 versions.json 双落点(方案安装的 C2-09 前置)+ dev 合并链路。
2
2
  //
3
3
  // 安装器按 B-11 布局读 `dist/api/versions.json`(与 manifests.yaml 同层)做 peerRuntime
4
4
  // 强制比对;而站点壳的 versions.json 落在 dist/web。两者不同源时,新工程 `ojm build`
5
5
  // 完仍会在 preflight 被 C2-09 拒装——这条用例钉住「同源双写」,防止回归。
6
- import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
6
+ //
7
+ // 合并链路(2026-11-04):buildBackend 跑 oj build 前走与 dev 相同的 mergeConfigForDev——
8
+ // 全新 clone 的机器只有本机 dev 密钥对,tracked 的 prod ENC(jwt_secret)按设计解不开;
9
+ // 直接喂 tracked 会让密封值预检误杀(dev 能跑、build 必挂)。-c 指向 .ojm-tmp-config.yaml,
10
+ // 预检对合并产物执行(兜底);非 jwt 的 ENC 红线不变,fail-closed。
11
+ //
12
+ // 假 oj 用 vi.mock 进程内实现(不落盘 sh 桩):sh 脚本在 Windows 上无法被 execFileSync
13
+ // 直接 spawn(ENOENT),此前整条 spec 在 win32 基线即挂;进程内桩三平台通吃。
14
+ import fs from "node:fs";
7
15
  import { tmpdir } from "node:os";
8
16
  import { join } from "node:path";
9
- import { describe, expect, it } from "vitest";
17
+ import { beforeEach, describe, expect, it, vi } from "vitest";
10
18
 
11
19
  import { buildBackend } from "../build";
12
20
 
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);
21
+ // 假 oj 的可控行为(vi.mock 工厂经此桥读取;工厂不能引用普通顶层变量)
22
+ const fakeOj = vi.hoisted(() => ({
23
+ /** secret open 能解开的密文集合(真实 oj:与私钥配对的都能解) */
24
+ openable: new Set<string>(["ENC[FAKE]"]),
25
+ /** secret seal 的返回值(真实 oj:返回 ENC[...]) */
26
+ sealOutput: "ENC[FAKE]",
27
+ /** `build --help` 能力探针:是否支持 --module-root */
28
+ supportsModuleRoot: true,
29
+ /** 非 secret 调用的 argv 记录(真实 oj 的 bin/invoked 桩等价物) */
30
+ invoked: [] as string[],
31
+ }));
32
+
33
+ vi.mock("node:child_process", async (importOriginal) => {
34
+ const actual = await importOriginal<typeof import("node:child_process")>();
35
+ const mfs = await import("node:fs");
36
+ const mpath = await import("node:path");
37
+ const execFileSync = (file: string, args: readonly string[]): string => {
38
+ const a = [...args];
39
+ if (a[0] === "secret") {
40
+ if (a[1] === "keygen") {
41
+ // --out-dir <dir>:落两枚占位 pem(provision 的幂等探测只查存在与 seal→open 往返)
42
+ const dir = a[3]!;
43
+ mfs.mkdirSync(dir, { recursive: true });
44
+ mfs.writeFileSync(mpath.join(dir, "secrets-private.pem"), "FAKE-PRIVATE");
45
+ mfs.writeFileSync(mpath.join(dir, "secrets-public.pem"), "FAKE-PUBLIC");
46
+ return "";
47
+ }
48
+ if (a[1] === "seal") {
49
+ if (!fakeOj.sealOutput.startsWith("ENC["))
50
+ throw new Error("oj secret seal failed");
51
+ return fakeOj.sealOutput;
52
+ }
53
+ if (a[1] === "open") {
54
+ const value = a[a.length - 1]!;
55
+ if (fakeOj.openable.has(value))
56
+ return "";
57
+ const err = new Error("Command failed") as Error & { stderr: string };
58
+ err.stderr = "oj secret open: aes-gcm open failed (sealed value tampered?): aead::Error";
59
+ throw err;
60
+ }
61
+ }
62
+ if (a[0] === "build" && a[1] === "--help")
63
+ return fakeOj.supportsModuleRoot ? " --module-root <GLOB> 模块根 glob" : " -d, --dir <DIR>";
64
+ if (a[0] === "-V")
65
+ return "oj 0.1.84";
66
+ // 真正的 oj build:记 argv(secret 调用不记,便于断言 build 是否真被叫起)
67
+ fakeOj.invoked.push(a.join(" "));
68
+ return "";
69
+ };
70
+ return { ...actual, execFileSync };
71
+ });
72
+
73
+ beforeEach(() => {
74
+ fakeOj.openable = new Set<string>(["ENC[FAKE]"]);
75
+ fakeOj.sealOutput = "ENC[FAKE]";
76
+ fakeOj.supportsModuleRoot = true;
77
+ fakeOj.invoked = [];
78
+ });
79
+
80
+ /** bin/oj 只需存在(buildBackend 的 fs 探测;真正执行走上面的进程内假 oj) */
81
+ function stubOjBin(root: string): void {
82
+ fs.mkdirSync(join(root, "bin"), { recursive: true });
83
+ fs.writeFileSync(join(root, "bin", "oj"), "fake-oj-placeholder", "utf-8");
22
84
  }
23
85
 
24
86
  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");
87
+ const root = fs.mkdtempSync(join(tmpdir(), "ojm-build-backend-"));
88
+ fs.mkdirSync(join(root, "api", "src", "demo"), { recursive: true });
89
+ fs.writeFileSync(join(root, "api", "src", "demo", "manifest.yaml"), "name: demo\nversion: 0.1.0\n", "utf-8");
90
+ fs.writeFileSync(join(root, "api", "config.yaml"), "server:\n port: 9778\n", "utf-8");
29
91
  // 模拟 oj build 已产出的 manifests.yaml(验证收尾动作不破坏既有产物)
30
- mkdirSync(join(root, "dist", "api"), { recursive: true });
31
- writeFileSync(join(root, "dist", "api", "manifests.yaml"), "demo: 0.1.0\n", "utf-8");
32
- fakeOj(root);
92
+ fs.mkdirSync(join(root, "dist", "api"), { recursive: true });
93
+ fs.writeFileSync(join(root, "dist", "api", "manifests.yaml"), "demo: 0.1.0\n", "utf-8");
94
+ stubOjBin(root);
33
95
  return root;
34
96
  }
35
97
 
@@ -40,8 +102,8 @@ describe("given 宿主工程有 api/src When ojm build 后端", () => {
40
102
  await buildBackend(root);
41
103
 
42
104
  const hostVersions = join(root, "dist", "api", "versions.json");
43
- expect(existsSync(hostVersions)).toBe(true);
44
- const parsed = JSON.parse(readFileSync(hostVersions, "utf-8")) as Record<string, string>;
105
+ expect(fs.existsSync(hostVersions)).toBe(true);
106
+ const parsed = JSON.parse(fs.readFileSync(hostVersions, "utf-8")) as Record<string, string>;
45
107
  expect(typeof parsed["@oj-module/runtime"]).toBe("string");
46
108
  });
47
109
 
@@ -50,149 +112,132 @@ describe("given 宿主工程有 api/src When ojm build 后端", () => {
50
112
 
51
113
  await buildBackend(root);
52
114
 
53
- expect(readFileSync(join(root, "dist", "api", "manifests.yaml"), "utf-8")).toBe("demo: 0.1.0\n");
115
+ expect(fs.readFileSync(join(root, "dist", "api", "manifests.yaml"), "utf-8")).toBe("demo: 0.1.0\n");
116
+ });
117
+
118
+ it("then -c 指向合并产物(与 dev 同一条 mergeConfigForDev 链路)", async () => {
119
+ const root = newProject();
120
+
121
+ await buildBackend(root);
122
+
123
+ const merged = join(root, "api", ".ojm-tmp-config.yaml");
124
+ expect(fs.existsSync(merged)).toBe(true);
125
+ expect(fakeOj.invoked[0]).toContain(`-c ${merged}`);
54
126
  });
55
127
  });
56
128
 
57
129
  describe("given 纯前端工程(无 api/src)When ojm build 后端", () => {
58
130
  it("then 跳过构建、不产出 dist/api", async () => {
59
- const root = mkdtempSync(join(tmpdir(), "ojm-build-backend-fe-"));
131
+ const root = fs.mkdtempSync(join(tmpdir(), "ojm-build-backend-fe-"));
60
132
 
61
133
  expect(await buildBackend(root)).toBe(false);
62
- expect(existsSync(join(root, "dist", "api"))).toBe(false);
134
+ expect(fs.existsSync(join(root, "dist", "api"))).toBe(false);
63
135
  });
64
136
  });
65
137
 
66
- /**
67
- * 假 oj:把 argv 落盘供断言;`build --help` 的输出可切(模拟是否支持 --module-root)。
68
- * 能力探针会先跑 `build --help`,所以脚本必须区分这两次调用。
69
- */
70
- function fakeOjRecording(projectRoot: string, supportsModuleRoot: boolean): void {
71
- mkdirSync(join(projectRoot, "bin"), { recursive: true });
72
- const bin = join(projectRoot, "bin", "oj");
73
- writeFileSync(bin, [
74
- "#!/bin/sh",
75
- "if [ \"$1\" = \"build\" ] && [ \"$2\" = \"--help\" ]; then",
76
- ` ${supportsModuleRoot ? "echo ' --module-root <GLOB> 模块根 glob'" : "echo ' -d, --dir <DIR>'"}`,
77
- " exit 0",
78
- "fi",
79
- "if [ \"$1\" = \"-V\" ]; then echo 'oj 0.1.84'; exit 0; fi",
80
- "printf '%s\\n' \"$*\" > \"$(dirname \"$0\")/invoked\"",
81
- "exit 0",
82
- "",
83
- ].join("\n"), { mode: 0o755 });
84
- }
85
-
86
138
  /** 新布局工程:src/<name>/{api,web} + 仓库根 config.yaml */
87
139
  function newModuleRootProject(): string {
88
- const root = mkdtempSync(join(tmpdir(), "ojm-build-backend-mr-"));
89
- mkdirSync(join(root, "src", "wiki", "api"), { recursive: true });
90
- writeFileSync(join(root, "src", "wiki", "api", "manifest.yaml"), "name: wiki\nversion: 0.1.0\n", "utf-8");
91
- writeFileSync(join(root, "config.yaml"), "server:\n port: 9778\n", "utf-8");
140
+ const root = fs.mkdtempSync(join(tmpdir(), "ojm-build-backend-mr-"));
141
+ fs.mkdirSync(join(root, "src", "wiki", "api"), { recursive: true });
142
+ fs.writeFileSync(join(root, "src", "wiki", "api", "manifest.yaml"), "name: wiki\nversion: 0.1.0\n", "utf-8");
143
+ fs.writeFileSync(join(root, "config.yaml"), "server:\n port: 9778\n", "utf-8");
144
+ stubOjBin(root);
92
145
  return root;
93
146
  }
94
147
 
95
148
  describe("given module-root 布局 When ojm build 后端", () => {
96
149
  it("then 一次调用、单一输出根:--module-root src + -o dist/api(与 -d 互斥)", async () => {
97
150
  const root = newModuleRootProject();
98
- fakeOjRecording(root, true);
99
151
 
100
152
  expect(await buildBackend(root)).toBe(true);
101
153
 
102
154
  // 逐模块 `-o <tmp>` 会让每个 tmp 各写一份 manifests.yaml,锁与兄弟目录关系全丢
103
- const invoked = readFileSync(join(root, "bin", "invoked"), "utf-8").trim();
104
- expect(invoked).toBe(
105
- `build -c ${join(root, "config.yaml")} --module-root ${join(root, "src")} -o ${join(root, "dist", "api")} --no-minify`,
106
- );
107
- expect(existsSync(join(root, "dist", "api", "versions.json"))).toBe(true);
155
+ expect(fakeOj.invoked).toEqual([
156
+ `build -c ${join(root, ".ojm-tmp-config.yaml")} --module-root ${join(root, "src")} -o ${join(root, "dist", "api")} --no-minify`,
157
+ ]);
158
+ expect(fs.existsSync(join(root, "dist", "api", "versions.json"))).toBe(true);
108
159
  });
109
160
 
110
161
  it("then oj 缺 --module-root 能力时 fail-fast,不落任何产物", async () => {
111
162
  const root = newModuleRootProject();
112
- fakeOjRecording(root, false);
163
+ fakeOj.supportsModuleRoot = false;
113
164
 
114
165
  await expect(buildBackend(root)).rejects.toThrow(/不支持 --module-root/);
115
- expect(existsSync(join(root, "bin", "invoked"))).toBe(false); // 没跑 oj build
116
- expect(existsSync(join(root, "dist", "api"))).toBe(false);
166
+ expect(fakeOj.invoked).toEqual([]); // 没跑 oj build
167
+ expect(fs.existsSync(join(root, "dist", "api"))).toBe(false);
117
168
  });
118
169
  });
119
170
 
120
171
  /**
121
- * 假 oj:`secret open` 按 openOk 成败(失败时复述 oj 的真实 stderr),`build` 记 argv 后成功。
122
- * 只记非 secret 调用,便于断言「预检之后 build 是否真的跑了」。
172
+ * flat 布局 + tracked 里带「prod 密封」的 config(路径照 ojm init 的产物写)。
173
+ * ENC[AgE0…] 故意不在假 oj 的可解集合里——模拟「全新 clone、只有本机 dev 密钥对、
174
+ * 无生产私钥」的机器:本机密钥只解得开自己 seal 出的 ENC[FAKE]。
123
175
  */
124
- function fakeOjSeal(projectRoot: string, openOk: boolean): void {
125
- mkdirSync(join(projectRoot, "bin"), { recursive: true });
126
- const bin = join(projectRoot, "bin", "oj");
127
- writeFileSync(bin, [
128
- "#!/bin/sh",
129
- "if [ \"$1\" = \"secret\" ]; then",
130
- ...(openOk ? [] : [" echo 'oj secret open: aes-gcm open failed (sealed value tampered?): aead::Error' >&2"]),
131
- ` exit ${openOk ? 0 : 1}`,
132
- "fi",
133
- "printf '%s\\n' \"$*\" >> \"$(dirname \"$0\")/invoked\"",
134
- "exit 0",
135
- "",
136
- ].join("\n"), { mode: 0o755 });
137
- }
138
-
139
- /** flat 布局 + 已密封的 config(路径照 ojm init 的产物写) */
140
- function newSealedProject(): string {
176
+ function newSealedProject(extraTracked = ""): string {
141
177
  const root = newProject();
142
- writeFileSync(
178
+ fs.writeFileSync(
143
179
  join(root, "api", "config.yaml"),
144
180
  "auth:\n jwt_secret: \"ENC[AgE0ZMU2aDKo5CHab4Kl_TtJduXQLwheLVczTPcvm2g]\"\n"
145
- + "secrets:\n private_key_path: ./config/secrets-private.pem\n",
181
+ + `secrets:\n private_key_path: ./config/secrets-private.pem\n${
182
+ extraTracked}`,
146
183
  "utf-8",
147
184
  );
148
185
  return root;
149
186
  }
150
187
 
151
- describe("given config 含密封值 When ojm build 后端", () => {
152
- // oj build 读不动 config 时只 warn 两行、退出码 0,继续出包时 sql_guard 是关掉的。
153
- // ojm 必须在把 oj 叫起来之前就停下,否则「没跑过 tenant 校验的包」会流到部署机上。
154
- it("then 解不开就停在出包前,不当成一次成功构建", async () => {
188
+ describe("given 全新 clone、本机只有 dev 密钥对(tracked 的 prod jwt ENC 解不开)When ojm build 后端", () => {
189
+ // 核心回归:dev 能跑、build 必挂的不对称。合并链路里 jwt 被 config.local.yaml 的
190
+ // dev 密文覆盖,tracked 的 prod ENC 仅作 prod 资产被忽略——build 照常出包。
191
+ it("then tracked 的 prod jwt ENC 被 dev 密文覆盖,build 照常出包", async () => {
155
192
  const root = newSealedProject();
156
- fakeOjSeal(root, false);
157
-
158
- await expect(buildBackend(root)).rejects.toThrow(/密封值解不开/);
159
- await expect(buildBackend(root)).rejects.toThrow(/secrets-private\.pem/);
160
- expect(existsSync(join(root, "bin", "invoked"))).toBe(false); // 没跑 oj build
161
- });
162
-
163
- it("then 解不开时把 oj 的原话带上(排障不用再跑一遍)", async () => {
164
- const root = newSealedProject();
165
- fakeOjSeal(root, false);
166
-
167
- await expect(buildBackend(root)).rejects.toThrow(/aes-gcm open failed/);
168
- });
169
-
170
- it("then 能解开就照常出包", async () => {
171
- const root = newSealedProject();
172
- fakeOjSeal(root, true);
173
193
 
174
194
  expect(await buildBackend(root)).toBe(true);
175
- expect(readFileSync(join(root, "bin", "invoked"), "utf-8")).toContain("build -c ");
176
- });
177
-
178
- it("then config 无密封值时不额外调用 oj(预检零成本)", async () => {
179
- const root = newProject(); // config 只有 server.port
180
- fakeOjSeal(root, false);
181
195
 
182
- expect(await buildBackend(root)).toBe(true);
183
- expect(readFileSync(join(root, "bin", "invoked"), "utf-8")).not.toContain("secret");
196
+ const merged = fs.readFileSync(join(root, "api", ".ojm-tmp-config.yaml"), "utf-8");
197
+ expect(merged).toContain("ENC[FAKE]"); // dev jwt(本机密钥密封)
198
+ expect(merged).not.toContain("ENC[AgE0"); // tracked prod ENC 不进合并产物
199
+ expect(fakeOj.invoked[0]).toContain(`-c ${join(root, "api", ".ojm-tmp-config.yaml")}`);
184
200
  });
185
201
 
186
202
  it("then 注释掉的旧密文不算数(不因一行 `#` 挡住构建)", async () => {
187
203
  const root = newProject();
188
- writeFileSync(
204
+ fs.writeFileSync(
189
205
  join(root, "api", "config.yaml"),
190
206
  "# auth:\n# jwt_secret: \"ENC[AgE0ZMU2aDKo5CHab4Kl_TtJduXQLwheLVczTPcvm2g]\"\nserver:\n port: 9778\n",
191
207
  "utf-8",
192
208
  );
193
- fakeOjSeal(root, false);
194
209
 
195
210
  expect(await buildBackend(root)).toBe(true);
196
- expect(readFileSync(join(root, "bin", "invoked"), "utf-8")).not.toContain("secret");
211
+ expect(fakeOj.invoked[0]).toContain("build -c ");
212
+ });
213
+ });
214
+
215
+ describe("given 合并产物含解不开的密封值 When ojm build 后端", () => {
216
+ // 兜底双保险:config.local.yaml 覆盖进来的非 jwt 密文(local 不经 tracked 红线),
217
+ // 预检对合并产物逐条试解,解不开就停在出包前——oj build 读不动 config 只 warn 不失败。
218
+ it("then 停在出包前,不当成一次成功构建,且带上 oj 的原话", async () => {
219
+ const root = newSealedProject();
220
+ // 本机 dev 密钥由假 oj keygen 现场供给;local 覆盖带一条解不开的密文
221
+ fs.writeFileSync(
222
+ join(root, "api", "config.local.yaml"),
223
+ "notify:\n wecom_enc_key: \"ENC[BAD]\"\n",
224
+ "utf-8",
225
+ );
226
+
227
+ await expect(buildBackend(root)).rejects.toThrow(/密封值解不开/);
228
+ await expect(buildBackend(root)).rejects.toThrow(/aes-gcm open failed/);
229
+ expect(fakeOj.invoked).toEqual([]); // 没跑 oj build
230
+ });
231
+ });
232
+
233
+ describe("given tracked 含非 jwt 的解不开密文 When ojm build 后端", () => {
234
+ // 红线不降级:真实生产数据密钥(如 wecom_enc_key)解不开时 build 依然 fail-closed,
235
+ // 且不由 dev 随机密钥重封(防破坏已加密落库数据)。
236
+ it("then 红线抛错,要求提供真实私钥", async () => {
237
+ const root = newSealedProject("notify:\n wecom_enc_key: \"ENC[AgE0ZMU2aDKo5CHab4Kl_TtJduXQLwheLVczTPcvm2g]\"\n");
238
+
239
+ await expect(buildBackend(root)).rejects.toThrow(/没有能解开的私钥/);
240
+ await expect(buildBackend(root)).rejects.toThrow(/wecom_enc_key/);
241
+ expect(fakeOj.invoked).toEqual([]); // 没跑 oj build
197
242
  });
198
243
  });
@@ -5,6 +5,7 @@ import { execFileSync } from "node:child_process";
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  import { ALIAS_PATHS_FILE, syncAliasPaths } from "../alias-paths";
8
+ import { mergeConfigForDev } from "../dev-secrets";
8
9
  import { resolveConfigPath, tryResolveLayout } from "../layout";
9
10
  import { requireModuleRootCapability } from "../oj-caps";
10
11
  import { resolveOjBin } from "../vendor";
@@ -50,7 +51,15 @@ export async function buildBackend(projectRoot: string): Promise<boolean> {
50
51
  // <project>/config/ 下——漏传会静默读不到配置,连带 tasks 镜像目录回落默认 "tasks"、
51
52
  // sql_guard 声明校验不生效(oj 只 warn 两行,极易被忽略)。
52
53
  const configPath = resolveConfigPath(projectRoot);
53
- const configArgs = fs.existsSync(configPath) ? ["-c", configPath] : [];
54
+ // 与 dev 同一条合并链路(方案 A'):tracked + gitignored config.local.yaml 深合并 →
55
+ // .ojm-tmp-config.yaml,-c 指向合并产物。全新 clone 的机器只有本机 dev 密钥对,tracked
56
+ // 的 prod ENC(jwt_secret)按设计解不开——直接喂 tracked 会让密封值预检误杀(dev 能跑、
57
+ // build 必挂)。合并产物里 jwt 已被本机 dev 密文覆盖,而编译本身不需要密文明文,产物也不
58
+ // 嵌 config(部署侧自带)。产物与 config.yaml 同目录,secrets.private_key_path 等相对路径
59
+ // 解析不变;非 jwt 的 ENC 解不开仍由链路内红线抛错(防护不降级)。
60
+ let effectiveConfigPath: string | undefined;
61
+ if (fs.existsSync(configPath))
62
+ effectiveConfigPath = mergeConfigForDev(projectRoot, configPath);
54
63
  // --no-minify:oj 的 swc minify 当前会把产物压坏,必须绕开。实测两种坏法:
55
64
  // 1) `const rows = await db.query(...); rows[0]` → 压成 `await db.query(...)[0]`,
56
65
  // 下标先于 await 求值,Promise 上取 [0] 得 undefined(登录恒 401);
@@ -61,11 +70,11 @@ export async function buildBackend(projectRoot: string): Promise<boolean> {
61
70
  // oj 在子树内递归找 manifest.yaml(命中 src/<name>/api/manifest.yaml 即停)。
62
71
  const sourceArgs = layout?.moduleRoot ? ["--module-root", layout.moduleRoot] : ["-d", apiSrc];
63
72
  // 出包前先自己确认 config 能读:读不动时 oj build 会静默关掉 sql_guard(见函数注释)
64
- if (configArgs.length)
65
- assertSealedValuesOpenable(oj, configPath);
73
+ if (effectiveConfigPath)
74
+ assertSealedValuesOpenable(oj, effectiveConfigPath);
66
75
  execFileSync(oj, [
67
76
  "build",
68
- ...configArgs,
77
+ ...(effectiveConfigPath ? ["-c", effectiveConfigPath] : []),
69
78
  ...sourceArgs,
70
79
  "-o",
71
80
  apiDist,
@@ -91,6 +100,10 @@ export async function buildBackend(projectRoot: string): Promise<boolean> {
91
100
  * 然后照常出包。那两行夹在模块清单里极易被忽略,而关掉的 `sql_guard` 正是 tenant 声明校验
92
101
  * (跨模块 SQL 的唯一守卫)——包看起来完好,检查其实没跑。
93
102
  *
103
+ * 对象是**合并产物**(`mergeConfigForDev` 出的 .ojm-tmp-config.yaml):tracked 的 prod jwt
104
+ * ENC 在合并时已被本机 dev 密文覆盖,能走到这里的密文本机都该解得开——解不开只可能是本机
105
+ * dev jwt 坏了或 local 覆盖里带了别的密文,属兜底双保险(第一道闸是链路内红线)。
106
+ *
94
107
  * 探法就是 oj 自己的排障命令 `oj secret open`:它和 `oj serve` / `oj build` 用同一套密钥解析
95
108
  * (`OJ_SECRET_KEY` → `OJ_SECRET_KEY_FILE` → config 的 `secrets.private_key_path`),
96
109
  * 解不开就非零退出。明文只可能出现在它的 stdout,这里直接丢弃,不落终端也不落日志。
@@ -122,6 +135,9 @@ function assertSealedValuesOpenable(oj: string, configPath: string): void {
122
135
  + " config.yaml,没带 config/secrets-private.pem;\n"
123
136
  + " 2) 密文被改过——手工编辑、复制被截断、编辑器把长行折了。\n"
124
137
  + "\n"
138
+ + "dev 机提示:jwt_secret 由 config.local.yaml 供给(本机密钥可轮换);若解不开的就是它,\n"
139
+ + " 删掉 config.local.yaml 里的 auth.jwt_secret 行,重跑一次 ojm dev 会重新密封。\n"
140
+ + "\n"
125
141
  + "单独验一条:oj secret open -c <config> '<那个 ENC[...] 值>'(成功=这条没问题,失败=就是它)\n"
126
142
  + "修法:把配对私钥带到本机(OJ_SECRET_KEY / OJ_SECRET_KEY_FILE / config 的 secrets.private_key_path\n"
127
143
  + " 三选一);密钥确实换了,就在本机重新 oj secret seal 覆写该值(明文走 stdin,别上命令行)。",
@@ -8,9 +8,9 @@
8
8
  "react-router": "7.18.2",
9
9
  "react-router/dom": "7.18.2",
10
10
  "@tanstack/react-query": "5.102.8",
11
- "@oj-module/runtime": "0.2.62",
12
- "@oj-module/runtime/contract": "0.2.62",
13
- "@oj-module/runtime/contract/errors": "0.2.62",
11
+ "@oj-module/runtime": "0.2.63",
12
+ "@oj-module/runtime/contract": "0.2.63",
13
+ "@oj-module/runtime/contract/errors": "0.2.63",
14
14
  "@plane/kit": "0.1.20",
15
15
  "@plane/editor": "0.1.20",
16
16
  "antd": "6.6.1",