@oj-module/cli 0.2.56 → 0.2.62

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 (50) hide show
  1. package/devkit/README.md +7 -4
  2. package/devkit/SKILL.md +4 -1
  3. package/devkit/manual.md +4 -2
  4. package/devkit/module-dev-and-cdn-playground.md +7 -0
  5. package/devkit/publish-walkthrough.md +199 -0
  6. package/package.json +2 -2
  7. package/shell-dist/assets/{index-CImwskAz.js → index-BKtXxIXz.js} +32 -2
  8. package/shell-dist/assets/runtime.js +6 -6
  9. package/shell-dist/index.html +3 -3
  10. package/shell-dist/versions.json +3 -3
  11. package/src/__tests__/port-check.test.ts +40 -0
  12. package/src/__tests__/vendor-devkit.test.ts +87 -0
  13. package/src/__tests__/vendor.test.ts +39 -0
  14. package/src/build/backend.ts +131 -0
  15. package/src/build/module-build.ts +364 -0
  16. package/src/build/module-def.ts +400 -0
  17. package/src/build/site-merge.ts +33 -0
  18. package/src/build.ts +16 -885
  19. package/src/bundle.ts +61 -0
  20. package/src/cli.ts +11 -1
  21. package/src/contract/emit-stub.ts +7 -3
  22. package/src/dev-secrets.ts +315 -0
  23. package/src/dev.ts +26 -3
  24. package/src/init.ts +27 -39
  25. package/src/lifecycle/__tests__/bundle-summary.test.ts +106 -0
  26. package/src/lifecycle/__tests__/bundle.test.ts +24 -1
  27. package/src/lifecycle/__tests__/deterministic-tar.test.ts +19 -2
  28. package/src/market/bootstrap.ts +5 -2
  29. package/src/new.ts +27 -0
  30. package/src/oj.ts +82 -9
  31. package/src/port-check.ts +38 -0
  32. package/src/rekey.ts +51 -0
  33. package/src/static-handler.ts +7 -2
  34. package/src/usage.ts +12 -2
  35. package/src/vendor.ts +66 -5
  36. package/templates/openapi.json +7 -0
  37. package/templates/src/app/api/_platform/_lifecycle/installer.ts +8 -3
  38. package/templates/src/app/api/_platform/_lifecycle/steps/register.ts +53 -0
  39. package/templates/src/app/api/_platform/_shared/service/domain/roles.ts +4 -1
  40. package/templates/src/app/api/_platform/_shared/service/domain/solution-perms.ts +7 -3
  41. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.mysql.sql +3 -0
  42. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.pg.sql +3 -0
  43. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.sql +8 -0
  44. package/templates/src/app/api/_platform/schema.yaml +2 -0
  45. package/templates/src/app/api/system/roles/api.ts +2 -1
  46. package/templates/src/app/web/system/__client/api.schemas.ts +1 -0
  47. package/templates/src/app/web/system/pages/roles/index.tsx +7 -1
  48. package/templates/tests/lifecycle/role-title-translate.spec.ts +171 -0
  49. package/templates/tests/rbac-roles.spec.ts +9 -0
  50. package/vendor/host-versions.json +3 -3
package/src/bundle.ts CHANGED
@@ -308,6 +308,16 @@ async function assembleBundle(projectRoot: string, layout: ProjectLayout, plan:
308
308
  }
309
309
  fs.mkdirSync(path.join(root, "web", "modules"), { recursive: true });
310
310
  fs.cpSync(ownWeb, path.join(root, "web", "modules", name), { recursive: true });
311
+ // R1 数据源随包(计划 docs/plans/20261103-0948):安装期角色 title 解析读
312
+ // <包>/web/modules/<name>/locales/<lang>.json——构建产物不含 locales(源码 JSON
313
+ // 被 vite 内联进 JS chunk),故从源码树复制双语;目录不存在(纯后端段缺 locales)静默跳过。
314
+ for (const lang of ["zh-CN", "en-US"]) {
315
+ const src = path.join(webSrcOf(layout, name), "locales", `${lang}.json`);
316
+ if (fs.existsSync(src)) {
317
+ fs.mkdirSync(path.join(root, "web", "modules", name, "locales"), { recursive: true });
318
+ fs.copyFileSync(src, path.join(root, "web", "modules", name, "locales", `${lang}.json`));
319
+ }
320
+ }
311
321
  const web = m.web as Record<string, unknown>;
312
322
  for (const rel of [web.entry, ...(Array.isArray(web.css) ? web.css : [])]) {
313
323
  if (typeof rel === "string" && !fs.existsSync(path.join(root, "web", rel))) {
@@ -365,6 +375,57 @@ export async function buildBundle(projectRoot: string, opts: { out?: string, nam
365
375
  return results;
366
376
  }
367
377
 
378
+ /**
379
+ * `ojm build` 末尾的用户方案摘要(2026-11-03,计划 docs/plans/20261103-0925):
380
+ * 明确回答「我的方案构建进了吗、能不能打包」。判定口径与 resolveContractTargets 同源
381
+ * (模块级契约 ∪ 根契约,减内置名单)——摘要里列出的就是 --bundle 将要打包的那批。
382
+ * 只报告不裁决:不调 resolveLayout(构建末尾不因摘要崩溃),log 可注入供测试断言。
383
+ */
384
+ export function summarizeBuiltSolutions(projectRoot: string, built: readonly { name: string, version: string }[], log: (line: string) => void = console.log): void {
385
+ const builtin = readBuiltinModules(projectRoot);
386
+ const rootSolution = path.join(projectRoot, "solution.yaml");
387
+ const rootManifest = path.join(projectRoot, "manifest.yaml");
388
+ const rootContract = fs.existsSync(rootSolution) ? rootSolution : fs.existsSync(rootManifest) ? rootManifest : undefined;
389
+ const rootName = rootContract ? peekContractName(rootContract) : undefined;
390
+
391
+ // 用户方案候选:模块级契约(非内置)∪ 根契约 name(按名去重——「根压 src」只是打包取哪份契约,摘要里是同一个方案)
392
+ const names = new Set<string>();
393
+ for (const c of collectModuleContracts(projectRoot)) {
394
+ if (!builtin.has(c.moduleDir!))
395
+ names.add(c.moduleDir!);
396
+ }
397
+ if (rootName !== undefined && !builtin.has(rootName))
398
+ names.add(rootName);
399
+
400
+ const builtByName = new Map(built.map(b => [b.name, b]));
401
+ if (names.size === 0) {
402
+ log("[ojm] 用户方案 0 份(ojm new <name> 创建后即可 ojm build --bundle 打包)");
403
+ }
404
+ else {
405
+ const all = [...names].sort();
406
+ const ok = all.filter(n => builtByName.has(n));
407
+ const missing = all.filter(n => !builtByName.has(n));
408
+ const builtList = ok.map(n => `${n}@${builtByName.get(n)!.version}`).join("、") || "无";
409
+ if (missing.length === 0)
410
+ log(`[ojm] 用户方案 ${all.length} 份已构建:${builtList}(ojm build --bundle 打包)`);
411
+ else
412
+ log(`[ojm] 用户方案 ${all.length} 份中 ${ok.length} 份已构建:${builtList};未构建:${missing.join("、")}(查 web.config.ts 登记/enabled)`);
413
+ }
414
+
415
+ const builtinBuilt = built.filter(b => builtin.has(b.name));
416
+ if (builtinBuilt.length > 0)
417
+ log(`[ojm] 框架自带模块 ${builtinBuilt.length} 份已构建(不参与用户打包)`);
418
+
419
+ // module-root 口径(src/ 树在即适用;flat/new 工程模块不在 src/):非内置且无契约的模块不会被打包——提前点名,别等 --bundle 才发现
420
+ if (fs.existsSync(path.join(projectRoot, "src"))) {
421
+ for (const b of built) {
422
+ if (builtin.has(b.name) || names.has(b.name))
423
+ continue;
424
+ log(`[ojm] ⚠️ 模块 ${b.name}@${b.version} 缺方案契约(src/${b.name}/solution.yaml),不会被打包`);
425
+ }
426
+ }
427
+ }
428
+
368
429
  /**
369
430
  * C2-31 分层核对:模块级 manifest 仅 oj 原生键,且 name/version 与 bundle 级逐一相等
370
431
  * (oj 启动强校验同源)。
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 得到字符串
@@ -135,7 +142,10 @@ function makeAction(def: HelpEntry, projectRoot: string, lang: Lang, exit: (code
135
142
  }
136
143
  const { buildBackend, buildModules } = await import("./build");
137
144
  await buildBackend(projectRoot);
138
- await buildModules(projectRoot, { mergeSite: true });
145
+ const built = await buildModules(projectRoot, { mergeSite: true });
146
+ // 用户方案摘要(计划 20261103-0925):构建末尾点名「我的方案」是否进构建、能否打包
147
+ const { summarizeBuiltSolutions } = await import("./bundle");
148
+ summarizeBuiltSolutions(projectRoot, built);
139
149
  };
140
150
  case "new":
141
151
  return async (name: string | undefined, extra: string[] = [], opts: { layout?: string, api?: boolean } = {}) => {
@@ -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
@@ -21,7 +21,7 @@ import { BUILTIN_LIST_FILE, writeBuiltinList } from "./builtin";
21
21
  import { resolveCertDir, resolveConfigPath } from "./layout";
22
22
  import { probeOjCaps } from "./oj-caps";
23
23
  import { mintDevCert } from "./oj-cert";
24
- import { isSupportedOjVersion, MIN_OJ_VERSION, resolveOjBin, vendorCommand } from "./vendor";
24
+ import { isSupportedOjVersion, MIN_OJ_VERSION, resolveOjBin, syncOjDevkit, vendorCommand } from "./vendor";
25
25
  import { readHostVersions } from "./versions";
26
26
 
27
27
  export interface InitOptions {
@@ -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 }));
@@ -167,25 +169,16 @@ export async function initProject(destDir: string, opts: InitOptions = {}): Prom
167
169
  // oj 不可用/不支持 secret → 保留明文并告警(init 不因加密失败而失败)
168
170
  await sealJwtSecret(destDir, report, opts.seal);
169
171
 
170
- // 4) devkit 分发:agent skill + 全局类型
171
- const devkit = path.join(binDir, "devkit");
172
- if (fs.existsSync(path.join(devkit, "SKILL.md"))) {
173
- const skillDir = path.join(destDir, ".claude/skills/oj-api-dev");
174
- fs.mkdirSync(skillDir, { recursive: true });
175
- for (const name of ["SKILL.md", "api-manual.md"])
176
- copyIfAbsent(path.join(devkit, name), path.join(skillDir, name), report);
177
- // v0.1.82 起 devkit 新增两份专题指南(api-manual/SKILL 均引用它们):
178
- // SQL 构造器能力矩阵/方言差异、oj test L1 测试指南。它们是**增量文档**、
179
- // 不影响 ojm 运行,故「有则拷、无则记跳过」——bin/devkit 来自用户本地
180
- // oj 版本(MIN_OJ_VERSION 只钉到 0.1.73),旧版 devkit 没有这两个文件,
181
- // 为两份文档把整个 init 打挂不值当(用户升 oj 后重跑 init 即补上)。
182
- for (const name of ["sql-builder-guide.md", "oj-test-guide.md"])
183
- copyIfPresent(path.join(devkit, name), path.join(skillDir, name), report);
184
- copyIfAbsent(path.join(devkit, "global.d.ts"), path.join(destDir, "global.d.ts"), report);
185
- }
186
- else {
172
+ // 4) devkit 分发:agent skill(与 vendor 同一同步出口 syncOjDevkit:bin/devkit 全量
173
+ // .md **覆盖**进 .claude/skills/oj-api-dev——文档随 oj 版本同步,见计划 20261103-1020)
174
+ // + 全局类型(模板已落则 copyIfAbsent 跳过,模板变体优先、用户可编辑)
175
+ const devkitStatus = syncOjDevkit(destDir, binDir);
176
+ if (devkitStatus.ok)
177
+ report.created.push(`.claude/skills/oj-api-dev(oj devkit 文档已同步 ${devkitStatus.files.length} 个文件)`);
178
+ else
187
179
  report.skipped.push(".claude/skills/oj-api-dev(bin/devkit 缺失,跳过)");
188
- }
180
+ if (fs.existsSync(path.join(binDir, "devkit", "global.d.ts")))
181
+ copyIfAbsent(path.join(binDir, "devkit", "global.d.ts"), path.join(destDir, "global.d.ts"), report);
189
182
 
190
183
  // 4b) ojm 模块开发 skill 分发:从 cli 包内置 devkit(prepack 同步的
191
184
  // docs/devkit)拷到 .claude/skills/ojm-module-dev/;已存在则幂等跳过。
@@ -197,7 +190,8 @@ export async function initProject(destDir: string, opts: InitOptions = {}): Prom
197
190
  // 业务工程拿到模板后自助完成对接要用,不能只留本仓:
198
191
  // · `wecom-daikaifa.md` 企业微信代开发(服务商模板):凭据清单 + 用户/开发/部署三视角;
199
192
  // · `wecom-internal-app.md` 企业内部应用(自建):五项 corp 凭据 + 验证并接入 + 共存。
200
- for (const name of ["SKILL.md", "manual.md", "README.md", "wecom-daikaifa.md", "wecom-internal-app.md"])
193
+ // · `publish-walkthrough.md` 方案包发布全流程(新手照做版):打包/签名/CDN/安装一条链。
194
+ for (const name of ["SKILL.md", "manual.md", "README.md", "wecom-daikaifa.md", "wecom-internal-app.md", "publish-walkthrough.md"])
201
195
  copyIfAbsent(path.join(cliDevkit, name), path.join(ojmSkillDir, name), report);
202
196
  }
203
197
  else {
@@ -336,7 +330,10 @@ function copyTemplates(
336
330
  for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
337
331
  const src = path.join(srcDir, entry.name);
338
332
  const dest = path.join(destDir, entry.name);
339
- 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("/");
340
337
  const listed = include.has(srcRel);
341
338
  if (!underIncluded && !listed && !hasIncludedDescendant(include, srcRel))
342
339
  continue;
@@ -398,7 +395,11 @@ function readInitInclude(templatesDir: string): Set<string> {
398
395
  const doc = yaml.parse(fs.readFileSync(yamlPath, "utf-8")) as { include?: string[] } | null;
399
396
  const out = new Set<string>();
400
397
  for (const raw of doc?.include ?? []) {
401
- 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(/^(\.\/)+/, "");
402
403
  out.add(p);
403
404
  if (!fs.existsSync(path.join(templatesDir, p)))
404
405
  throw new Error(`init.include.yaml 收录了不存在的路径:${p}(拼写错误或模板缺文件?)`);
@@ -483,21 +484,6 @@ function copyIfAbsent(src: string, dest: string, report: InitReport): void {
483
484
  report.created.push(path.relative(process.cwd(), dest) || path.basename(dest));
484
485
  }
485
486
 
486
- /**
487
- * 同 copyIfAbsent,但**源缺失**时记一条跳过而非抛错。
488
- *
489
- * 用于从 bin/devkit 拷第三方版本化内容(两份专题指南):devkit 随用户本地 oj
490
- * 版本而变,旧版没有新文件属正常版本 skew,不是安装包损坏——缺了只少份文档,
491
- * 不该让 init 失败。源在而目标已存在仍走 copyIfAbsent 的幂等跳过。
492
- */
493
- function copyIfPresent(src: string, dest: string, report: InitReport): void {
494
- if (!fs.existsSync(src)) {
495
- report.skipped.push(`${path.basename(src)}(bin/devkit 无此文件,跳过)`);
496
- return;
497
- }
498
- copyIfAbsent(src, dest, report);
499
- }
500
-
501
487
  /**
502
488
  * 新工程 .gitignore:不存在则生成;已存在则幂等跳过(用户规则永不覆盖)。
503
489
  *
@@ -519,6 +505,8 @@ logs/
519
505
  # 现场签发的开发证书与凭据私钥(ojm dev 缺证时自动签发,见 oj-cert.ts);私钥绝不入仓
520
506
  config/*.pem
521
507
  config/cert.jws
508
+ # 开发态本地覆盖层(方案 A'):dev 自动供给的本机密钥 + dev jwt_secret;git 不跟踪
509
+ config.local.yaml
522
510
  # 构建产物(ojm build:站点 dist/web + 后端 release dist/api)
523
511
  dist/
524
512
  `;