@oj-module/cli 0.2.57 → 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.
@@ -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,别上命令行)。",
@@ -297,7 +297,20 @@ function makeBareImportStubPlugin(namesBySpec: Map<string, string[]>): EsbuildPl
297
297
  return {
298
298
  name: "ojm-bare-import-stub",
299
299
  setup(b) {
300
- b.onResolve({ filter: /^[^./#]/ }, args => ({ path: args.path, namespace: "ojm-bare-stub" }));
300
+ b.onResolve({ filter: /^[^./#]/ }, (args) => {
301
+ // 排除入口自身与绝对路径:entryPoints 在 Windows 上以盘符绝对路径(d:/...)
302
+ // 传入,会命中上面的裸说明符过滤被桩化成「可 thenable 的代理」——
303
+ // 整个入口模块变成 new Proxy(function(){},{get:()=>value,apply:()=>value}),
304
+ // mod.default 因而是一个 thenable,async readModuleDefinition 执行 return
305
+ // 时被当 thenable 接管、其 .then 永远不回调 resolve → Promise 永 pending →
306
+ // 事件循环排空 → Node 以退出码 0 静默退出(pnpm dev 起不来)。
307
+ // macOS/Linux 绝对路径以 / 开头已被过滤排除,故这是 Windows-only bug。
308
+ if (args.kind === "entry-point")
309
+ return null;
310
+ if (/^[a-z]:/i.test(args.path))
311
+ return null;
312
+ return { path: args.path, namespace: "ojm-bare-stub" };
313
+ });
301
314
  b.onLoad({ filter: /.*/, namespace: "ojm-bare-stub" }, (args) => {
302
315
  const names = namesBySpec.get(args.path) ?? [];
303
316
  return {
@@ -348,6 +361,10 @@ export async function readModuleDefinition(entryFile: string, projectRoot: strin
348
361
  // 会让非 entry.ts 文件名(如测试夹具)的产物对不上后续 import 路径
349
362
  entryNames: "entry",
350
363
  bundle: true,
364
+ // ESM 输出 + import() 加载:入口经下方 onResolve 修正后不会被裸导入桩误伤为
365
+ // thenable 代理,Windows/macOS 行为一致(此前 Windows 卡死的根因是入口被桩化成
366
+ // 代理导致 async return 被 thenable 接管而永不 settle)。元数据读取只需取
367
+ // `.default`,无需 CJS 语义。
351
368
  format: "esm",
352
369
  platform: "node",
353
370
  packages: "external",
@@ -360,6 +377,8 @@ export async function readModuleDefinition(entryFile: string, projectRoot: strin
360
377
  logLevel: "silent",
361
378
  });
362
379
  const bundled = path.join(outDir, "entry.js");
380
+ // 加载桩化产物(ESM):入口不会被裸导入桩误伤为 thenable 代理(见 onResolve 修正),
381
+ // 故 mod.default 是真实定义,import() 正常 settle。
363
382
  const mod = await import(pathToFileURL(bundled).href);
364
383
  const definition = mod.default;
365
384
  if (!definition?.name || !definition?.version) {
package/src/cli.ts CHANGED
@@ -121,6 +121,13 @@ function makeAction(def: HelpEntry, projectRoot: string, lang: Lang, exit: (code
121
121
  const portArg = Number(port);
122
122
  await devServer(projectRoot, { port: Number.isFinite(portArg) && portArg > 0 ? portArg : 5174 });
123
123
  };
124
+ case "rekey":
125
+ return async () => {
126
+ const { rekeyCommand } = await import("./rekey");
127
+ const result = rekeyCommand(projectRoot);
128
+ console.log(`[ojm] 已重新生成开发态本机密钥与 dev jwt_secret:${path.relative(projectRoot, result.localPath) || result.localPath}`);
129
+ console.log("[ojm] 仅影响 gitignored 的 config.local.yaml 与 config/secrets-*.pem;tracked config.yaml 未改动。下次 ojm dev 会用新密钥自动重合并重起。");
130
+ };
124
131
  case "build":
125
132
  return async (opts: { bundle?: unknown, out?: string } = {}) => {
126
133
  // mri 可选值:裸 --bundle 得到 "",--bundle name / --bundle=name 得到字符串
@@ -2,7 +2,7 @@ import type { OjSchemaDiag } from "./emit-oj-schema";
2
2
  import type { IrEndpoint } from "./ir";
3
3
  import { createHash } from "node:crypto";
4
4
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
- import { dirname, join } from "node:path";
5
+ import { dirname, posix, sep } from "node:path";
6
6
  import { emitOjSchemaLine } from "./emit-oj-schema";
7
7
  import { splitRoute, wirePrefixOf } from "./ir";
8
8
  import { jsonSchemaToSample } from "./json-schema-sample";
@@ -147,8 +147,12 @@ export async function planStubWrites(ir: IrEndpoint[], opts: PlanStubOptions): P
147
147
 
148
148
  const writes: StubWrite[] = [];
149
149
  for (const [, { module, dir, endpoints }] of [...groups.entries()].sort()) {
150
- const apiRoot = opts.apiRootOf ? opts.apiRootOf(module) : join(opts.apiSrcDir, module);
151
- const filePath = join(apiRoot, dir, "api.ts");
150
+ // filePath 统一 POSIX 分隔符:契约/manifest/指纹键都按 `/` 比对,Windows 上
151
+ // path.join 会产出 `\`,导致落盘路径失配(stub 找不到、快照漂移)。
152
+ // fs 侧 Windows 同样接受 `/`,无需回转分隔符。
153
+ const apiRootRaw = opts.apiRootOf ? opts.apiRootOf(module) : posix.join(opts.apiSrcDir, module);
154
+ const apiRoot = apiRootRaw.split(sep).join("/");
155
+ const filePath = posix.join(apiRoot, dir, "api.ts");
152
156
  // 模板天然 lint-clean(F6)→ 直接哈希发射内容;注入缝仅供测试/特殊工程对齐
153
157
  const body = await fix(emitStubBody(endpoints, opts.diag), filePath);
154
158
  const fingerprint = `// ojm-api:stub ${endpoints.map(e => e.name).sort().join(",")} sha256:${hashContent(body)}\n`;
@@ -0,0 +1,315 @@
1
+ /**
2
+ * 开发态本地密钥/凭据自动供给(方案 A',计划 docs/plans/20261103-1700)。
3
+ *
4
+ * 问题:tracked `config.yaml` 是共享模板,但其引用的机器本地密钥
5
+ * (`config/secrets-*.pem` 密封对 + 全部 `ENC[...]` 解密链)不随 git 分发——多名开发者
6
+ * clone 后解不开 → `oj serve` 起不来,且失败信息被吞。
7
+ *
8
+ * 设计(经架构师 + 工程师评审修正):
9
+ * - dev 密钥与 tracked prod ENC **解耦**:tracked 里的 `ENC[...]` 仅作 prod 资产,dev 完全忽略。
10
+ * - 本机密钥 + dev `jwt_secret` 只活在 gitignored 的 `config.local.yaml`,由本模块自动供给。
11
+ * - `mergeConfigForDev` 把 tracked + local 深合并成 `.ojm-tmp-config.yaml`(gitignored,与
12
+ * config.yaml 同目录)交给 `oj serve -c`,**绝不写回 tracked**。
13
+ * - **非 jwt 的 `ENC[...]` 红线**:若 tracked 出现其他解不开的 `ENC[...]`(如 `wecom_enc_key`,
14
+ * 落库加密键),触发清晰报错要求提供真实私钥,**不**自动用 dev 随机密钥重封(防破坏已加密数据)。
15
+ * - 模板 drift:合并产物每次 `ojm dev` 现合现用,tracked 非密改动自动生效;本机密钥持久于
16
+ * `config.local.yaml` 重合并时始终套用,模板怎么改都不丢。
17
+ */
18
+ import { execFileSync } from "node:child_process";
19
+ import crypto from "node:crypto";
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+
23
+ import { parse, stringify } from "yaml";
24
+ import { resolveCertDir } from "./layout";
25
+ import { resolveOjBin } from "./vendor";
26
+
27
+ /** `config.local.yaml` 落点(与 config.yaml 同目录,gitignored、可手编辑) */
28
+ export function resolveLocalConfigPath(configPath: string): string {
29
+ return path.join(path.dirname(configPath), "config.local.yaml");
30
+ }
31
+
32
+ /** 合并产物落点(与 config.yaml 同目录,`.ojm-tmp-*` 已被模板 .gitignore 忽略) */
33
+ export function resolveMergedConfigPath(configPath: string): string {
34
+ return path.join(path.dirname(configPath), ".ojm-tmp-config.yaml");
35
+ }
36
+
37
+ /**
38
+ * 写回护栏(A-4):合并产物绝不允许写回 tracked config、且必须位于 gitignore 内。
39
+ * 否则哪天密钥被误写回跟踪文件就泄密了。
40
+ */
41
+ export function assertOutputNotTracked(trackedConfigPath: string, mergedConfigPath: string): void {
42
+ if (path.resolve(mergedConfigPath) === path.resolve(trackedConfigPath)) {
43
+ throw new Error(
44
+ `[ojm] 内部错误:合并产物路径与 tracked config 相同(${mergedConfigPath}),拒绝写回以免覆盖共享配置。`,
45
+ );
46
+ }
47
+ // `.ojm-tmp-` 前缀已被模板 .gitignore 覆盖(无斜杠 → 任意层级匹配),双重保险
48
+ if (!path.basename(mergedConfigPath).startsWith(".ojm-tmp-")) {
49
+ throw new Error(
50
+ `[ojm] 内部错误:合并产物 ${mergedConfigPath} 未被 .gitignore 的 .ojm-tmp-* 覆盖,拒绝写入以免密钥泄漏。`,
51
+ );
52
+ }
53
+ }
54
+
55
+ /** 用本机私钥试解 `ENC[...]`;能解开返回 true(即使不使用明文也只在内存里) */
56
+ function canOpenWith(enc: string, keyPath: string, projectRoot: string): boolean {
57
+ const ojBin = resolveOjBin(projectRoot);
58
+ if (!fs.existsSync(ojBin) || !fs.existsSync(keyPath))
59
+ return false;
60
+ try {
61
+ execFileSync(ojBin, ["secret", "open", "-k", keyPath, enc], { stdio: "pipe" });
62
+ return true;
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * 本机密钥对是否可用:公私钥文件都在,且 seal→open 往返自洽(公钥能封、私钥能解)。
71
+ * 用于自愈(E-3 + A' 自动 rekey):若上次的密钥文件残缺/被改坏(如半截 pem),
72
+ * 直接删了重 keygen,而不是拿着坏文件去 seal 失败。返回 false 时调用方应删除后重生成。
73
+ */
74
+ function keyPairUsable(keysDir: string, projectRoot: string): boolean {
75
+ const privateKey = path.join(keysDir, "secrets-private.pem");
76
+ const publicKey = path.join(keysDir, "secrets-public.pem");
77
+ const ojBin = resolveOjBin(projectRoot);
78
+ if (!fs.existsSync(ojBin) || !fs.existsSync(privateKey) || !fs.existsSync(publicKey))
79
+ return false;
80
+ try {
81
+ const probe = execFileSync(ojBin, ["secret", "seal", "-k", publicKey], {
82
+ input: "__ojm_probe__",
83
+ encoding: "utf-8",
84
+ stdio: ["pipe", "pipe", "pipe"],
85
+ }).trim();
86
+ if (!probe.startsWith("ENC["))
87
+ return false;
88
+ execFileSync(ojBin, ["secret", "open", "-k", privateKey, probe], { stdio: "pipe" });
89
+ return true;
90
+ }
91
+ catch {
92
+ return false;
93
+ }
94
+ }
95
+
96
+ /** 递归扫出配置树里所有 `ENC[...]` 字符串值,返回其 JSON pointer 路径集合 */
97
+ function findEncValues(node: unknown, pointer = "", out: Map<string, string> = new Map()): Map<string, string> {
98
+ if (typeof node === "string") {
99
+ if (/^ENC\[/.test(node))
100
+ out.set(pointer, node);
101
+ }
102
+ else if (Array.isArray(node)) {
103
+ node.forEach((item, i) => findEncValues(item, `${pointer}/${i}`, out));
104
+ }
105
+ else if (node && typeof node === "object") {
106
+ for (const [k, v] of Object.entries(node))
107
+ findEncValues(v, pointer ? `${pointer}/${k}` : `/${k}`, out);
108
+ }
109
+ return out;
110
+ }
111
+
112
+ export interface DevSecretsDeps {
113
+ /** 注入 oj 二进制路径(测试) */
114
+ ojBin?: string
115
+ /** 注入随机 jwt_secret 生成器(测试可固定) */
116
+ randomHex?: (bytes: number) => string
117
+ /** 注入日志(测试) */
118
+ log?: (msg: string) => void
119
+ }
120
+
121
+ /**
122
+ * 供给本机开发密钥与 dev `jwt_secret`(A-1):
123
+ * - `config/secrets-{private,public}.pem` 缺失 → `oj secret keygen`(幂等:存在即跳过)。
124
+ * - `config.local.yaml` 的 `auth.jwt_secret` 缺失/解不开 → 生成随机 32 字节 hex →
125
+ * `oj secret seal` 成 `ENC[...]`,连同 `secrets:` 段写入 `config.local.yaml`。
126
+ * 不写 tracked config。返回最终 local config 对象。
127
+ */
128
+ export function provisionDevSecrets(
129
+ projectRoot: string,
130
+ configPath: string,
131
+ deps: DevSecretsDeps = {},
132
+ ): Record<string, unknown> {
133
+ const log = deps.log ?? ((msg: string) => console.log(msg));
134
+ const ojBin = deps.ojBin ?? resolveOjBin(projectRoot);
135
+ const randomHex = deps.randomHex ?? ((bytes: number) => crypto.randomBytes(bytes).toString("hex"));
136
+ const keysDir = resolveCertDir(projectRoot);
137
+ const privateKey = path.join(keysDir, "secrets-private.pem");
138
+ const publicKey = path.join(keysDir, "secrets-public.pem");
139
+
140
+ // 1) 密封密钥对(幂等:存在且可用即跳过,避免每次 dev 重启换新密钥致已签发会话失效,E-3)。
141
+ // 若文件存在但残缺/被改坏(seal→open 往返不过)→ 视为不可用,删了重 keygen(A' 自动 rekey)。
142
+ let provisionedKey = false;
143
+ const keyUsable = fs.existsSync(privateKey) && fs.existsSync(publicKey) && keyPairUsable(keysDir, projectRoot);
144
+ if (!keyUsable) {
145
+ if (!fs.existsSync(ojBin))
146
+ throw new Error(`[ojm] 未找到 oj 二进制(${ojBin}),无法生成开发密钥。请先运行 ojm vendor。`);
147
+ for (const f of [privateKey, publicKey]) {
148
+ if (fs.existsSync(f))
149
+ fs.rmSync(f);
150
+ }
151
+ fs.mkdirSync(keysDir, { recursive: true });
152
+ execFileSync(ojBin, ["secret", "keygen", "--out-dir", keysDir], { stdio: "pipe" });
153
+ provisionedKey = true;
154
+ }
155
+
156
+ // 2) 读/建 config.local.yaml
157
+ const localPath = resolveLocalConfigPath(configPath);
158
+ let local: Record<string, unknown> = {};
159
+ if (fs.existsSync(localPath)) {
160
+ try {
161
+ local = (parse(fs.readFileSync(localPath, "utf-8")) as Record<string, unknown>) ?? {};
162
+ }
163
+ catch {
164
+ local = {};
165
+ }
166
+ }
167
+ const auth = (local.auth as Record<string, unknown> | undefined) ?? {};
168
+ const existingJwt = typeof auth.jwt_secret === "string" ? auth.jwt_secret : "";
169
+
170
+ // 3) dev jwt_secret:缺失或本机密钥解不开 → 重新生成并 seal(仅 jwt,可随意轮换)
171
+ const jwtOk = existingJwt.startsWith("ENC[") && canOpenWith(existingJwt, privateKey, projectRoot);
172
+ let jwtSecretEnc = existingJwt;
173
+ if (!jwtOk) {
174
+ if (!fs.existsSync(ojBin))
175
+ throw new Error(`[ojm] 未找到 oj 二进制(${ojBin}),无法密封 jwt_secret。请先运行 ojm vendor。`);
176
+ const plain = randomHex(32);
177
+ const sealed = execFileSync(ojBin, ["secret", "seal", "-k", publicKey], {
178
+ input: plain,
179
+ encoding: "utf-8",
180
+ stdio: ["pipe", "pipe", "pipe"],
181
+ }).trim();
182
+ if (!sealed.startsWith("ENC["))
183
+ throw new Error("[ojm] oj secret seal 未返回 ENC[...],开发密钥供给失败。");
184
+ jwtSecretEnc = sealed;
185
+ }
186
+
187
+ // 4) 写回 config.local.yaml(仅 dev 覆盖 + dev 密钥;不碰 tracked)
188
+ const nextLocal: Record<string, unknown> = { ...local };
189
+ nextLocal.auth = { ...auth, jwt_secret: jwtSecretEnc };
190
+ nextLocal.secrets = {
191
+ private_key_path: "./config/secrets-private.pem",
192
+ public_key_path: "./config/secrets-public.pem",
193
+ };
194
+ fs.writeFileSync(localPath, stringify(nextLocal));
195
+
196
+ if (provisionedKey)
197
+ log("[ojm] 已生成本机开发密钥:config/secrets-{private,public}.pem");
198
+ if (!jwtOk)
199
+ log("[ojm] 已生成本机开发 jwt_secret(密封于 config.local.yaml,git 不跟踪)。");
200
+
201
+ return nextLocal;
202
+ }
203
+
204
+ /**
205
+ * 红线(A-2):tracked 里出现**非 jwt** 的 `ENC[...]` 且本机密钥解不开 → 清晰报错,
206
+ * 要求提供真实私钥;绝不自动用 dev 随机密钥重封(会破坏已加密落库数据)。
207
+ */
208
+ export function checkNoUnopenableSharedEnc(
209
+ projectRoot: string,
210
+ configPath: string,
211
+ _deps: DevSecretsDeps = {},
212
+ ): void {
213
+ const keysDir = resolveCertDir(projectRoot);
214
+ const privateKey = path.join(keysDir, "secrets-private.pem");
215
+ if (!fs.existsSync(configPath) || !fs.existsSync(privateKey))
216
+ return; // 无 tracked 或无本机密钥时不查(密钥缺失由 provision/red-line 别处兜底)
217
+ let doc: unknown;
218
+ try {
219
+ doc = parse(fs.readFileSync(configPath, "utf-8"));
220
+ }
221
+ catch {
222
+ return;
223
+ }
224
+ const encValues = findEncValues(doc);
225
+ for (const [pointer, enc] of encValues) {
226
+ if (pointer === "/auth/jwt_secret")
227
+ continue; // jwt 由 dev 自行供给,忽略 tracked 的 prod ENC
228
+ if (!canOpenWith(enc, privateKey, projectRoot)) {
229
+ throw new Error(
230
+ `[ojm] 配置中 ${pointer} 是 ENC[...] 密文,但本机没有能解开的私钥(config/secrets-private.pem)。\n`
231
+ + " 该密文通常由团队用真实密钥密封(如落库加密键 wecom_enc_key),dev 不会用随机密钥重封——否则会破坏已加密数据。\n"
232
+ + " 请向团队获取对应私钥放到 config/secrets-private.pem,或在 config.local.yaml 里覆盖该字段为可解密的明文/ENC 后重试。",
233
+ );
234
+ }
235
+ }
236
+ }
237
+
238
+ /** 纯函数深合并(local 覆盖 tracked;数组整体替换,不 concat) */
239
+ export function deepMerge(tracked: Record<string, unknown>, local: Record<string, unknown>): Record<string, unknown> {
240
+ const out: Record<string, unknown> = Array.isArray(tracked) ? [...(tracked as unknown[])] as unknown as Record<string, unknown> : { ...tracked };
241
+ for (const [k, v] of Object.entries(local)) {
242
+ const base = (out as Record<string, unknown>)[k];
243
+ if (
244
+ v && typeof v === "object" && !Array.isArray(v)
245
+ && base && typeof base === "object" && !Array.isArray(base)
246
+ ) {
247
+ (out as Record<string, unknown>)[k] = deepMerge(base as Record<string, unknown>, v as Record<string, unknown>);
248
+ }
249
+ else {
250
+ (out as Record<string, unknown>)[k] = v;
251
+ }
252
+ }
253
+ return out;
254
+ }
255
+
256
+ export interface MergeDeps extends DevSecretsDeps {
257
+ /** 注入供给逻辑(测试可替换为无 oj 的桩) */
258
+ provision?: (projectRoot: string, configPath: string, d: DevSecretsDeps) => Record<string, unknown>
259
+ /** 注入红线检查(测试可替换为 no-op) */
260
+ redLine?: (projectRoot: string, configPath: string, d: DevSecretsDeps) => void
261
+ /** 注入合并落点(测试可改) */
262
+ mergedPath?: (configPath: string) => string
263
+ }
264
+
265
+ /**
266
+ * dev 配置就绪 + 合并:供给本机密钥、红线校验、深合并 tracked + local → `.ojm-tmp-config.yaml`。
267
+ * 返回合并产物路径,供 `oj serve -c` 使用。幂等(密钥/secret 存在即跳过)。
268
+ */
269
+ export function mergeConfigForDev(
270
+ projectRoot: string,
271
+ configPath: string,
272
+ deps: MergeDeps = {},
273
+ ): string {
274
+ const provision = deps.provision ?? provisionDevSecrets;
275
+ const redLine = deps.redLine ?? checkNoUnopenableSharedEnc;
276
+ const mergedPath = deps.mergedPath ?? ((cp: string) => resolveMergedConfigPath(cp));
277
+
278
+ // 必须先 provision 再 red-line:首次 clone 本机密钥尚不存在,provision 会先生成
279
+ // 本地密钥对;之后 red-line 才能拿本机私钥去校验 tracked 里的非 jwt ENC[...],
280
+ // 否则红线会在「无密钥可校验」时直接 early-return,把打不开的共享密文漏过去
281
+ // (正是方案 A' 要解决的「启动失败被吞」根因之一)。
282
+ provision(projectRoot, configPath, deps);
283
+ redLine(projectRoot, configPath, deps);
284
+
285
+ let trackedDoc: Record<string, unknown> = {};
286
+ if (fs.existsSync(configPath)) {
287
+ try {
288
+ trackedDoc = (parse(fs.readFileSync(configPath, "utf-8")) as Record<string, unknown>) ?? {};
289
+ }
290
+ catch (error) {
291
+ throw new Error(`[ojm] ${configPath} 解析失败(不是合法 YAML):${(error as Error).message}`);
292
+ }
293
+ }
294
+ // provision 已把本机密钥/jwt 写入 config.local.yaml;此处读回参与合并
295
+ const localDoc = readLocal(projectRoot, configPath);
296
+ const merged = deepMerge(trackedDoc, localDoc);
297
+
298
+ const outPath = mergedPath(configPath);
299
+ assertOutputNotTracked(configPath, outPath);
300
+ fs.writeFileSync(outPath, stringify(merged));
301
+ return outPath;
302
+ }
303
+
304
+ /** 读 config.local.yaml(不存在/解析失败 → 空对象) */
305
+ function readLocal(projectRoot: string, configPath: string): Record<string, unknown> {
306
+ const localPath = resolveLocalConfigPath(configPath);
307
+ if (!fs.existsSync(localPath))
308
+ return {};
309
+ try {
310
+ return (parse(fs.readFileSync(localPath, "utf-8")) as Record<string, unknown>) ?? {};
311
+ }
312
+ catch {
313
+ return {};
314
+ }
315
+ }
package/src/dev.ts CHANGED
@@ -33,13 +33,15 @@ import { buildModules } from "./build";
33
33
  import { loadContractMocks, resolveMock } from "./contract/mock";
34
34
  import { loadProjectMocks, mockStatusCode } from "./dev-mock";
35
35
  import { createReloadHub, proxyApi, proxyWsUpgrade, sseScript } from "./dev-proxy";
36
+ import { mergeConfigForDev } from "./dev-secrets";
36
37
  import { loadHtmlTransform } from "./html-transform";
37
38
  import { resolveConfigPath, resolveLayout } from "./layout";
38
39
  import { resolveCdnLayout } from "./market/cdn-layout";
39
40
  import { startOj } from "./oj";
40
41
  import { requireModuleRootCapability } from "./oj-caps";
41
42
  import { ensureDevCert } from "./oj-cert";
42
- import { readOjApiPrefix } from "./oj-config";
43
+ import { readOjApiPrefix, readOjPort, readOjServerField } from "./oj-config";
44
+ import { isPortOccupied } from "./port-check";
43
45
  import { createStaticHandler, decodeReqPath, listenOnFreePort } from "./static-handler";
44
46
  import { resolveShellDist } from "./versions";
45
47
 
@@ -107,10 +109,31 @@ export async function devServer(projectRoot: string, opts: DevOptions = {}): Pro
107
109
  ? ["--module-root", layout.moduleRoot]
108
110
  : resolve(projectRoot, "api/src");
109
111
  apiBase = readOjApiPrefix(configPath);
110
- await ensureDevCert(configPath);
112
+ // 后端端口预检:被占用(通常上次 ojm dev 未正常退出、遗留 oj 孤儿进程仍在跑)
113
+ // 直接禁止启动并给清晰指引,避免 oj 健康检查超时后才以晦涩信息失败。
114
+ const backendPort = readOjPort(configPath);
115
+ const backendHost = readOjServerField(configPath, "host") || "127.0.0.1";
116
+ if (await isPortOccupied(backendPort, backendHost)) {
117
+ throw new Error(
118
+ `[ojm] 后端 API 端口 ${backendPort} 已被占用,已禁止启动 ojm dev。\n`
119
+ + " 原因通常是:上一次 ojm dev 未正常退出,遗留的 oj 进程仍在占用该端口。\n"
120
+ + " (oj 后端默认端口 9778;若 config.yaml 的 server.port 改过则为对应值。)\n\n"
121
+ + " 请先结束占用该端口的进程,再重新运行 ojm dev:\n"
122
+ + ` macOS / Linux: lsof -ti tcp:${backendPort} | xargs kill -9\n`
123
+ + ` Windows: netstat -ano | findstr :${backendPort} 找到 PID 后 taskkill /PID <PID> /F\n\n`
124
+ + " 已停止启动,未留下新的 oj 孤儿进程。",
125
+ );
126
+ }
127
+ // 开发态本地密钥/凭据自动供给(方案 A'):供给本机 secrets 密钥 + dev jwt_secret,
128
+ // 深合并 tracked config.yaml 与 gitignored config.local.yaml → .ojm-tmp-config.yaml。
129
+ // tracked 里的 ENC[...] 仅作 prod 资产,dev 完全忽略;非 jwt 的 ENC 解不开则清晰报错
130
+ // (红线,防用 dev 随机密钥重封破坏已加密数据)。合并产物与 config.yaml 同目录,oj
131
+ // 相对路径解析不变。
132
+ const effectiveConfigPath = mergeConfigForDev(projectRoot, configPath);
133
+ await ensureDevCert(effectiveConfigPath);
111
134
  const fallbackStarter = (cfg: string, b: string, apiSrc: string | string[]) => startOj(cfg, b, apiSrc);
112
135
  const starter: NonNullable<DevOptions["ojStarter"]> = opts.ojStarter ?? fallbackStarter;
113
- oj = starter(configPath, apiBase, apiSource);
136
+ oj = starter(effectiveConfigPath, apiBase, apiSource);
114
137
  try {
115
138
  await oj.ready; // 失败人话报错(stderr 尾部),fail-fast
116
139
  }
package/src/init.ts CHANGED
@@ -154,8 +154,10 @@ export async function initProject(destDir: string, opts: InitOptions = {}): Prom
154
154
  // 3) oj 二进制:从最新 release 联网下载(sha256 校验 → tar 解包 → chmod),
155
155
  // 无内置兜底;bin/oj 已在则幂等跳过,缺 .oj-version 标记视为未装
156
156
  const binDir = path.join(destDir, "bin");
157
- if (fs.existsSync(path.join(binDir, "oj"))) {
158
- report.skipped.push("bin/oj");
157
+ // Windows 上二进制名是 oj.exe,不能硬编码 `oj`(预置 bin/oj.exe 会被误判为未装而触发联网下载)。
158
+ const ojBin = resolveOjBin(destDir);
159
+ if (fs.existsSync(ojBin)) {
160
+ report.skipped.push(`bin/${path.basename(ojBin)}`);
159
161
  }
160
162
  else {
161
163
  const installer = opts.ojInstaller ?? ((dir: string) => vendorCommand(path.dirname(dir), { force: false }));
@@ -328,7 +330,10 @@ function copyTemplates(
328
330
  for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
329
331
  const src = path.join(srcDir, entry.name);
330
332
  const dest = path.join(destDir, entry.name);
331
- const srcRel = path.relative(rootDir, src);
333
+ // 跨平台:path.relative 在 Windows 返回反斜杠,而 include 集合与
334
+ // hasIncludedDescendant 的前缀对比都用正斜杠,必须归一后再比,
335
+ // 否则嵌套目录(src/app/api/<mod> 等)被误判为「未收录」整棵 skip。
336
+ const srcRel = path.relative(rootDir, src).split(path.sep).join("/");
332
337
  const listed = include.has(srcRel);
333
338
  if (!underIncluded && !listed && !hasIncludedDescendant(include, srcRel))
334
339
  continue;
@@ -390,7 +395,11 @@ function readInitInclude(templatesDir: string): Set<string> {
390
395
  const doc = yaml.parse(fs.readFileSync(yamlPath, "utf-8")) as { include?: string[] } | null;
391
396
  const out = new Set<string>();
392
397
  for (const raw of doc?.include ?? []) {
393
- const p = path.normalize(raw).replace(/^(\.\/)+/, "");
398
+ // 跨平台:include 清单用正斜杠书写,但 path.normalize 在 Windows 会把
399
+ // 正斜杠折成反斜杠,导致与 srcRel(path.relative 在 Windows 同样出反斜杠)
400
+ // 的对比永远落空、整棵 src 子树被 skip。这里只把反斜杠归正、剥 ./ 前缀,
401
+ // 不引入平台分隔符(修复:Windows 下 init 不拷贝 src 树)。
402
+ const p = raw.replace(/\\/g, "/").replace(/^(\.\/)+/, "");
394
403
  out.add(p);
395
404
  if (!fs.existsSync(path.join(templatesDir, p)))
396
405
  throw new Error(`init.include.yaml 收录了不存在的路径:${p}(拼写错误或模板缺文件?)`);
@@ -496,6 +505,8 @@ logs/
496
505
  # 现场签发的开发证书与凭据私钥(ojm dev 缺证时自动签发,见 oj-cert.ts);私钥绝不入仓
497
506
  config/*.pem
498
507
  config/cert.jws
508
+ # 开发态本地覆盖层(方案 A'):dev 自动供给的本机密钥 + dev jwt_secret;git 不跟踪
509
+ config.local.yaml
499
510
  # 构建产物(ojm build:站点 dist/web + 后端 release dist/api)
500
511
  dist/
501
512
  `;
@@ -15,6 +15,21 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
15
15
  import { parse, stringify } from "yaml";
16
16
  import { buildBundle } from "../../bundle";
17
17
 
18
+ // GNU tar 把 Windows 盘符绝对路径里的 `C:` 当「远程主机:路径」(Cannot connect to C: resolve failed),
19
+ // 需 --force-local;但 Windows 自带 bsdtar 不支持该选项(报 Option --force-local is not supported),
20
+ // 且 bsdtar 本就不做远程解析。按实际解析到的 tar 版本决定:仅 win32 + GNU tar 才加。POSIX 恒不加。
21
+ const TAR_LOCAL: string[] = (() => {
22
+ if (process.platform !== "win32")
23
+ return [];
24
+ try {
25
+ const version = execFileSync("tar", ["--version"], { encoding: "utf-8", shell: true });
26
+ return /GNU tar/i.test(version) ? ["--force-local"] : [];
27
+ }
28
+ catch {
29
+ return [];
30
+ }
31
+ })();
32
+
18
33
  const FIXTURE_VALID = join(dirname(fileURLToPath(import.meta.url)), "fixtures/fixture-valid");
19
34
 
20
35
  let project: string;
@@ -326,8 +341,10 @@ function writeManifest(m: unknown): void {
326
341
  }
327
342
 
328
343
  function tarList(tgz: string): string[] {
329
- return execFileSync("tar", ["-tvzf", tgz], { encoding: "utf8" })
344
+ return execFileSync("tar", [...TAR_LOCAL, "-tvzf", tgz], { encoding: "utf8", shell: true })
330
345
  // 铁律②(P1-6):tar 输出恒带尾随换行,trim 后再 split
346
+ // bsdtar on Windows emits CRLF; strip \r before splitting.
347
+ .replace(/\r/g, "")
331
348
  .trim()
332
349
  .split("\n")
333
350
  .map(line => line.split(" ").pop()!);
@@ -10,6 +10,21 @@ import { join } from "node:path";
10
10
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
11
11
  import { createDeterministicTgz } from "../../deterministic-tar";
12
12
 
13
+ // GNU tar 把 Windows 盘符绝对路径里的 `C:` 当「远程主机:路径」(Cannot connect to C: resolve failed),
14
+ // 需 --force-local;但 Windows 自带 bsdtar 不支持该选项(报 Option --force-local is not supported),
15
+ // 且 bsdtar 本就不做远程解析。按实际解析到的 tar 版本决定:仅 win32 + GNU tar 才加。POSIX 恒不加。
16
+ const TAR_LOCAL: string[] = (() => {
17
+ if (process.platform !== "win32")
18
+ return [];
19
+ try {
20
+ const version = execFileSync("tar", ["--version"], { encoding: "utf-8", shell: true });
21
+ return /GNU tar/i.test(version) ? ["--force-local"] : [];
22
+ }
23
+ catch {
24
+ return [];
25
+ }
26
+ })();
27
+
13
28
  let dir: string;
14
29
  let outDir: string;
15
30
 
@@ -97,7 +112,7 @@ describe("createDeterministicTgz · 产物可被系统 tar 消费(extract 段
97
112
  const { names } = await packAndList("a.tgz");
98
113
  const extractDir = join(outDir, "unpacked");
99
114
  mkdirSync(extractDir);
100
- execFileSync("tar", ["-xzf", join(outDir, "a.tgz"), "-C", extractDir]);
115
+ execFileSync("tar", [...TAR_LOCAL, "-xzf", join(outDir, "a.tgz"), "-C", extractDir], { shell: true });
101
116
  const root = join(extractDir, names[0]!.slice(0, -1));
102
117
  expect(readFileSync(join(root, "b-dir/zh.txt"), "utf8")).toBe("中文内容\n");
103
118
  expect(existsSync(join(root, "b-dir/empty-dir"))).toBe(true);
@@ -130,8 +145,10 @@ describe("createDeterministicTgz · 打包自检(C2-30 构建列)", () => {
130
145
  // ── 助手 ──
131
146
 
132
147
  function tarList(tgz: string): string[] {
133
- return execFileSync("tar", ["-tvzf", tgz], { encoding: "utf8" })
148
+ return execFileSync("tar", [...TAR_LOCAL, "-tvzf", tgz], { encoding: "utf8", shell: true })
134
149
  // 铁律②(P1-6):tar 输出恒带尾随换行,trim 后再 split
150
+ // bsdtar on Windows emits CRLF; strip \r before splitting.
151
+ .replace(/\r/g, "")
135
152
  .trim()
136
153
  .split("\n");
137
154
  }
@@ -118,9 +118,12 @@ export async function bootstrapIsolatedSite(opts: BootstrapOptions = {}): Promis
118
118
  }
119
119
  writeFileSync(configPath, readFileSync(path.join(templateDir, "config.yaml"), "utf-8"), "utf-8");
120
120
 
121
- // 2) oj 二进制与插件(startOj 按 <root>/bin/oj 解析;plugins 让 auth 插件可用)
121
+ // 2) oj 二进制与插件(startOj 按 <root>/bin/oj[.exe] 解析;plugins 让 auth 插件可用)。
122
+ // 跨平台:ojBin 在 Windows 为 bin/oj.exe、Linux/macOS 为 bin/oj——拷贝目标文件名必须
123
+ // 与源一致(path.basename),否则 Windows 下落盘成无扩展名的 `oj`,后续 resolveOjBin
124
+ // 按 bin/oj.exe 解析会找不到、后端构建检查也报「缺少 bin/oj.exe」(trial/walkthrough 隔离站点挂死)。
122
125
  mkdirSync(path.join(root, "bin"), { recursive: true });
123
- cpSync(ojBin, path.join(root, "bin", "oj"), { recursive: true });
126
+ cpSync(ojBin, path.join(root, "bin", path.basename(ojBin)), { recursive: true });
124
127
  const versionMarker = path.join(projectRoot, "bin", ".oj-version");
125
128
  if (existsSync(versionMarker)) {
126
129
  cpSync(versionMarker, path.join(root, "bin", ".oj-version"));