@oj-module/cli 0.2.57 → 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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@oj-module/cli",
3
3
  "type": "module",
4
- "version": "0.2.57",
4
+ "version": "0.2.62",
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.57",
52
+ "@oj-module/runtime": "0.2.62",
53
53
  "@redocly/cli": "^2.51.0",
54
54
  "@tailwindcss/vite": "^4.3.0",
55
55
  "cac": "^6.7.14",
@@ -1,6 +1,6 @@
1
1
  import { StyleProvider } from "@ant-design/cssinjs";
2
2
  import { AntdApp, ErrorBoundary, LayoutEffects, PageError, getHostRoutes, getRoutes, loadAll, resolveApiBase, setupI18n, useAccessStore, useAuthStore, usePreferences, useUserStore } from "@oj-module/runtime";
3
- import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3
+ import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
4
  import { ConfigProvider, theme } from "antd";
5
5
  import { useEffect, useState } from "react";
6
6
  import { createRoot } from "react-dom/client";
@@ -195,7 +195,37 @@ ${offenders.join("\n")}\n修复建议:该来源不在宿主内置白名单中
195
195
  }
196
196
  //#endregion
197
197
  //#region shell/src/host.tsx
198
- var queryClient = new QueryClient();
198
+ /**
199
+ * 全局失败收敛(框架层唯一构造点)。
200
+ *
201
+ * 宿主是唯一构造 QueryClient 的地方:模块页面的 useQuery/useQueryClient 上下文
202
+ * 都来自此处,`ojm build` 对 shell-dist 全量合并,工程侧没有注入 defaults 的缝隙。
203
+ * 原先裸 `new QueryClient()` 走 TanStack 默认 `retry: 3`,导致「必然失败也白等 3 次
204
+ * 重试 + 请求层逐次弹 toast 刷屏」。
205
+ *
206
+ * 这里两处收敛:
207
+ * - `retry: 1`:保留一次瞬时抖动重试,去掉默认 3 次的无效等待(mutations 默认不重试);
208
+ * - `QueryCache/MutationCache` onError:请求层 {@link handleErrorResponse} 已对
209
+ * HTTP 错误体弹 toast,此处只兜底「请求层未触达」的失败(网络中断 / 解析异常 /
210
+ * 非响应错误),避免与其重复弹窗。
211
+ */
212
+ function isHttpError(error) {
213
+ const maybe = error;
214
+ return Boolean(maybe?.response && typeof maybe.response.status === "number");
215
+ }
216
+ function surfaceQueryError(error) {
217
+ if (isHttpError(error)) return;
218
+ const message = error instanceof Error && /fetch|network|timeout/i.test(error.message) ? "网络异常,请检查网络连接后重试" : error instanceof Error ? error.message : "请求失败";
219
+ window.$message?.error(message);
220
+ }
221
+ var queryClient = new QueryClient({
222
+ defaultOptions: {
223
+ queries: { retry: 1 },
224
+ mutations: { retry: 0 }
225
+ },
226
+ queryCache: new QueryCache({ onError: surfaceQueryError }),
227
+ mutationCache: new MutationCache({ onError: surfaceQueryError })
228
+ });
199
229
  var DEMO_USER = {
200
230
  id: "1",
201
231
  avatar: "https://avatars.githubusercontent.com/u/47056890",
@@ -304,10 +304,10 @@ function getAppInfo() {
304
304
  return {
305
305
  "pkg": {
306
306
  "name": "@oj-module/runtime",
307
- "version": "0.2.57",
307
+ "version": "0.2.62",
308
308
  "license": "MIT"
309
309
  },
310
- "lastBuildTime": "2026-11-03 11:30:53"
310
+ "lastBuildTime": "2026-11-04 08:31:17"
311
311
  };
312
312
  }
313
313
  var init_get_app_info = __esmMin((() => {}));
@@ -7160,7 +7160,7 @@ var init_parent_layout = __esmMin((() => {}));
7160
7160
  */
7161
7161
  function resolveLayoutComponent(handle) {
7162
7162
  const name = handle?.layout;
7163
- if (!name) return Outlet;
7163
+ if (!name || name === "none") return Outlet;
7164
7164
  const registered = getRegisteredLayout(name);
7165
7165
  if (registered) return registered;
7166
7166
  if (layoutRegistry[name]) return layoutRegistry[name];
@@ -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-p8IkoQDMiluaMnE2ANd5og=='; 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="p8IkoQDMiluaMnE2ANd5og==">{
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==">{
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="p8IkoQDMiluaMnE2ANd5og==" data-ojm-entry="/assets/index-CImwskAz.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="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>
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.57",
11
- "@oj-module/runtime/contract": "0.2.57",
12
- "@oj-module/runtime/contract/errors": "0.2.57",
10
+ "@oj-module/runtime": "0.2.62",
11
+ "@oj-module/runtime/contract": "0.2.62",
12
+ "@oj-module/runtime/contract/errors": "0.2.62",
13
13
  "@plane/kit": "0.1.20",
14
14
  "@plane/editor": "0.1.20",
15
15
  "antd": "6.6.1",
@@ -0,0 +1,40 @@
1
+ import net from "node:net";
2
+ import { afterAll, describe, expect, it } from "vitest";
3
+ import { isPortOccupied } from "../port-check";
4
+
5
+ describe("isPortOccupied", () => {
6
+ it("端口有监听者时返回 true", async () => {
7
+ const server = net.createServer();
8
+ await new Promise<void>(resolve => server.listen(0, "127.0.0.1", () => resolve()));
9
+ const port = (server.address() as net.AddressInfo).port;
10
+ expect(await isPortOccupied(port)).toBe(true);
11
+ await new Promise<void>(resolve => server.close(() => resolve()));
12
+ });
13
+
14
+ it("端口空闲时返回 false", async () => {
15
+ // 探测一批非临时端口区间的端口:localhost 上这些端口几乎不可能被占用,
16
+ // 命中一个即证明「空闲端口返回 false」。避开 macOS 临时端口区间(49152+,
17
+ // 连接会被静默丢弃而非返回 ECONNREFUSED,导致保守判为占用)。
18
+ let foundFree = false;
19
+ for (const p of [23000, 23001, 23002, 23003, 23004]) {
20
+ if (!(await isPortOccupied(p))) {
21
+ foundFree = true;
22
+ break;
23
+ }
24
+ }
25
+ expect(foundFree).toBe(true);
26
+ });
27
+
28
+ it("默认 host 为 127.0.0.1", async () => {
29
+ const server = net.createServer();
30
+ await new Promise<void>(resolve => server.listen(0, "127.0.0.1", () => resolve()));
31
+ const port = (server.address() as net.AddressInfo).port;
32
+ expect(await isPortOccupied(port, "127.0.0.1")).toBe(true);
33
+ await new Promise<void>(resolve => server.close(() => resolve()));
34
+ });
35
+ });
36
+
37
+ // 防止未关闭句柄拖慢套件退出
38
+ afterAll(() => {
39
+ // 无持久资源;占位以明确意图
40
+ });
@@ -0,0 +1,39 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { defaultRun, npmExecSpec } from "../vendor";
4
+
5
+ vi.mock("node:child_process", () => ({
6
+ execFileSync: vi.fn(() => ""),
7
+ }));
8
+
9
+ const mockedExec = vi.mocked(execFileSync);
10
+
11
+ describe("npmExecSpec / defaultRun(Windows npm.cmd 兼容)", () => {
12
+ it("非 Windows:直接以 npm 执行,不经 shell", () => {
13
+ expect(npmExecSpec("linux", ["view", "x", "version"])).toEqual({
14
+ cmd: "npm",
15
+ execArgs: ["view", "x", "version"],
16
+ });
17
+ expect(npmExecSpec("darwin", ["install", "y"])).toEqual({
18
+ cmd: "npm",
19
+ execArgs: ["install", "y"],
20
+ });
21
+ });
22
+
23
+ it("windows:以 cmd.exe /c npm.cmd 包裹(修复 spawnSync npm.cmd EINVAL)", () => {
24
+ expect(npmExecSpec("win32", ["install", "@oj-bin/oj@0.1.86"])).toEqual({
25
+ cmd: "cmd.exe",
26
+ execArgs: ["/c", "npm.cmd", "install", "@oj-bin/oj@0.1.86"],
27
+ });
28
+ });
29
+
30
+ it("defaultRun 按当前平台选择执行方式", () => {
31
+ defaultRun(["view", "x", "version"], "/tmp/x");
32
+ const platform = process.platform;
33
+ const expectedCmd = platform === "win32" ? "cmd.exe" : "npm";
34
+ const expectedArgs = platform === "win32"
35
+ ? ["/c", "npm.cmd", "view", "x", "version"]
36
+ : ["view", "x", "version"];
37
+ expect(mockedExec).toHaveBeenCalledWith(expectedCmd, expectedArgs, expect.objectContaining({ cwd: "/tmp/x" }));
38
+ });
39
+ });
@@ -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"));
package/src/new.ts CHANGED
@@ -26,6 +26,26 @@ import { readBuiltinModules } from "./builtin";
26
26
  import { tryResolveLayout } from "./layout";
27
27
  import { assertValidSolutionName, RESERVED_NAMES } from "./manifest-contract";
28
28
 
29
+ /**
30
+ * 内建聚合模块 app 的 web 路由根(src/app/web 下各 routes.ts 的顶层 path):
31
+ * 这些路径由框架内置页面独占。ojm new 生成的默认路由为 /name,
32
+ * 若 name 命中其中之一,会与内置页面撞 route id,导致运行时白屏。
33
+ * 这里在脚手架阶段即拒绝,避免生成即炸。
34
+ *
35
+ * 与 readBuiltinModules(内建模块名)互补:内建模块名只覆盖 home /
36
+ * personal-center / system,而 login / change-password / notification-center 是
37
+ * app 聚合内的路由根,并非独立内建模块名,故单列此表。
38
+ * app 路由根增删时此处须同步。
39
+ */
40
+ const BUILTIN_ROUTE_ROOTS = new Set([
41
+ "home",
42
+ "login",
43
+ "change-password",
44
+ "notification-center",
45
+ "personal-center",
46
+ "system",
47
+ ]);
48
+
29
49
  export interface NewSolutionOptions {
30
50
  /** 示例路由布局(默认 container;none = 无外壳裸页面) */
31
51
  layout?: "container" | "none"
@@ -76,6 +96,13 @@ export function createSolution(projectRoot: string, name: string, opts: NewSolut
76
96
  + "想改造自带模块:`ojm new <新名>` 生成骨架后把代码复制过去(自带身份按目录名判定,复制即脱离)。",
77
97
  );
78
98
  }
99
+ if (BUILTIN_ROUTE_ROOTS.has(name)) {
100
+ throw new Error(
101
+ `[ojm] 「${name}」与框架内置页面路由根(${[...BUILTIN_ROUTE_ROOTS].join("/")})撞车——`
102
+ + `ojm new 生成的默认路由 /${name} 会与内置 ${name} 页面冲突(route id 碰撞白屏)。`
103
+ + "请换一个不与内置页面撞名的方案名。",
104
+ );
105
+ }
79
106
  const dir = path.join(projectRoot, "src", name);
80
107
  if (fs.existsSync(dir))
81
108
  throw new Error(`[ojm] src/${name}/ 已存在。换个名字,或删掉该目录后重试。`);
package/src/oj.ts CHANGED
@@ -9,7 +9,10 @@
9
9
  * 由 `ojm vendor` 的最低版本门禁挡住(见 src/vendor.ts 的 MIN_OJ_VERSION)。
10
10
  *
11
11
  * 健康判据:GET {base}/health 状态码 <400(oj 内置匿名健康端点;2xx/3xx 均视为就绪)。
12
- * 秒退:ready 拒绝,错误信息带 stderr 尾部(证书缺失等常见错因可直接读出)。
12
+ * 秒退:ready 拒绝,错误信息优先带 stderr 尾部(证书缺失等常见错因可直接读出);
13
+ * 若 oj 把 FATAL 落盘到 logs/ 而未进 stderr(手册说 `--console-log` 会镜像到终端,但
14
+ * 部分环境仍可能只落盘),再读 `<configDir>/logs/server-*.log` 最新文件兜底,绝不输出
15
+ * 无意义的 "(无输出)"(方案 A' 失败提示不吞,评审 E-1)。
13
16
  */
14
17
 
15
18
  import type { Buffer } from "node:buffer";
@@ -21,6 +24,43 @@ import process from "node:process";
21
24
  import { readOjPluginsDir, readOjPort } from "./oj-config";
22
25
  import { resolveOjBin } from "./vendor";
23
26
 
27
+ /**
28
+ * logs/ 兜底(评审 E-1):oj 启动期 FATAL 有时只落盘不进 stderr。
29
+ * 给定 oj 的日志目录(通常 `<configDir>/logs`),挑最新的 `server-*.log`,
30
+ * 抽出 ERROR/FATAL/panic 等关键行(没有则取末 30 行),供失败信息兜底展示。
31
+ * 找不到日志目录 / 文件即返回空串(调用方维持 "(无输出)")。
32
+ */
33
+ function readLatestLogError(logsDir: string): string {
34
+ if (!logsDir || !fs.existsSync(logsDir))
35
+ return "";
36
+ let entries: fs.Dirent[];
37
+ try {
38
+ entries = fs.readdirSync(logsDir, { withFileTypes: true });
39
+ }
40
+ catch {
41
+ return "";
42
+ }
43
+ const logs = entries
44
+ .filter(e => e.isFile() && /^server-.*\.log$/.test(e.name))
45
+ .map((e) => {
46
+ try {
47
+ return { name: e.name, mtime: fs.statSync(path.join(logsDir, e.name)).mtimeMs };
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ })
53
+ .filter((x): x is { name: string, mtime: number } => x !== null)
54
+ .sort((a, b) => b.mtime - a.mtime);
55
+ if (logs.length === 0)
56
+ return "";
57
+ const text = fs.readFileSync(path.join(logsDir, logs[0].name), "utf-8");
58
+ const lines = text.split(/\r?\n/);
59
+ const keyLines = lines.filter(l => /error|fatal|panic|denied|invalid|unable|cannot|fail|exception|refused/i.test(l));
60
+ const picked = keyLines.length > 0 ? keyLines.slice(-30) : lines.slice(-30);
61
+ return picked.join("\n").trim();
62
+ }
63
+
24
64
  export interface OjProcess {
25
65
  port: number
26
66
  /** 健康就绪(或失败拒绝:秒退带 stderr 尾部 / 超时) */
@@ -63,8 +103,12 @@ export function startOj(
63
103
  apiSource: string | string[],
64
104
  poll: StartOjPoll = {},
65
105
  extraArgs: string[] = [],
106
+ logsDir?: string,
66
107
  ): OjProcess {
67
108
  const port = readOjPort(configPath);
109
+ // logs/ 兜底目录(评审 E-1):oj FATAL 有时只落盘不进 stderr。缺省按 config 同级
110
+ // 的 logs/(与 oj 默认 logs_dir 一致);调用方可显式覆盖(preview 派生 config 等)。
111
+ const resolvedLogsDir = logsDir ?? path.join(path.dirname(configPath), "logs");
68
112
  // 约定:vendor 二进制在 <project>/bin/oj。工程根不能按「config 往上两级」算:
69
113
  // module-root 布局的 config 在仓库根(<project>/config.yaml),往上两级会越过工程,
70
114
  // 落到父目录找 bin/oj。改为「向上找最近的含 bin/ 的目录」,两种布局都成立。
@@ -84,10 +128,27 @@ export function startOj(
84
128
  // 注入条件:用户未在 toml 显式声明 plugins_dir(env 优先级更高,注入会覆盖),
85
129
  // 且 bin/plugins 确实存在(缺失时保持 oj 原行为,不生次生错误)。
86
130
  const pluginsDir = path.join(projectRoot, "bin", "plugins");
87
- const env: NodeJS.ProcessEnv | undefined
88
- = !readOjPluginsDir(configPath) && fs.existsSync(pluginsDir)
89
- ? { ...process.env, OJ_PLUGINS_DIR: pluginsDir }
90
- : undefined;
131
+ // win32:oj 是原生 Windows 二进制,从 Git Bash / MSYS 终端启动时继承 MSYS 风格 PATH
132
+ // (如 `/c/WINDOWS/system32`),Windows CreateProcess 无法按该形式解析可执行文件 → 内部
133
+ // `tar` 等系统工具 "command not found in PATH"(市场安装解包即挂)。追加原生 Windows 系统
134
+ // 目录(tar.exe 所在);cmd/PowerShell 用户 PATH 本就含这些,无副作用。
135
+ // win32: Git Bash/MSYS 把 PATH 写成 /usr/bin 这类 Unix 风格条目,oj 内部按 Windows 规则检索命令
136
+ // 时无法据此定位 tar.exe(系统 tar 在 C:\Windows\System32),市场安装解包会报 tar not found in PATH。
137
+ // 剔除无盘符的 Unix 风格条目、仅保留 Windows 绝对路径,并兜底补上系统目录与 Git bin。
138
+ // cmd/PowerShell 用户 PATH 本就全是 Windows 形态,过滤无副作用。
139
+ const env: NodeJS.ProcessEnv | undefined = (() => {
140
+ let base: NodeJS.ProcessEnv = process.env;
141
+ if (process.platform === "win32") {
142
+ const cur = process.env.PATH ?? process.env.Path ?? "";
143
+ const winEntries = cur.split(";").filter(e => /^[A-Z]:[\\/]/i.test(e.trim()));
144
+ const sysDirs = ["C:\\Windows\\System32", "C:\\Windows", "C:\\Program Files\\Git\\usr\\bin"];
145
+ const missing = sysDirs.filter(d => !winEntries.some(e => e.toLowerCase().startsWith(d.toLowerCase())));
146
+ base = { ...process.env, PATH: [...winEntries, ...missing].join(";") };
147
+ }
148
+ return !readOjPluginsDir(configPath) && fs.existsSync(pluginsDir)
149
+ ? { ...base, OJ_PLUGINS_DIR: pluginsDir }
150
+ : base;
151
+ })();
91
152
 
92
153
  let stderrTail = "";
93
154
  // --console-log:oj 新版终端默认静默(console_log 缺省 false,只落 logs/),
@@ -109,6 +170,13 @@ export function startOj(
109
170
 
110
171
  let settled = false;
111
172
  let pollTimer: NodeJS.Timeout | undefined;
173
+ // oj 端口被占用时 stderr 通常会带这些签名(Rust 绑定失败 / 系统错误码);命中即
174
+ // 补一条「先杀占用进程」的可操作提示,避免只甩晦涩的 oj 原生报错。
175
+ const portInUseHint = (text: string): string => {
176
+ if (/Address already in use|EADDRINUSE|os error 98|os error 10048|地址已被使用|端口已被占用/i.test(text))
177
+ return "\n可能原因:后端端口被占用(通常是上次 ojm dev 未正常退出留下的 oj 孤儿进程)。请先结束占用进程再重试。";
178
+ return "";
179
+ };
112
180
  const ready = new Promise<void>((resolve, reject) => {
113
181
  const intervalMs = poll.intervalMs ?? 500;
114
182
  const timeoutMs = poll.timeoutMs ?? 10_000;
@@ -129,9 +197,12 @@ export function startOj(
129
197
  }, intervalMs);
130
198
  pollTimer.unref();
131
199
  child.once("exit", (code) => {
200
+ const hint = portInUseHint(stderrTail);
201
+ // A' 失败提示不吞:stderr 空时读 logs/ 最新 server-*.log 兜底(评审 E-1)
202
+ const tail = stderrTail.trim() || readLatestLogError(resolvedLogsDir);
132
203
  finish(new Error(
133
- `[ojm] oj 进程异常退出(code ${code})。\n`
134
- + `stderr 尾部:\n${stderrTail.trim() || "(无输出)"}`,
204
+ `[ojm] oj 进程异常退出(code ${code})。${hint}\n`
205
+ + `stderr 尾部:\n${tail || "(无输出)"}`,
135
206
  ));
136
207
  });
137
208
  // spawn 本身失败(如 ENOENT)只发 error 不发 exit,必须单独兜住
@@ -141,9 +212,11 @@ export function startOj(
141
212
  finish(new Error(`[ojm] 无法启动 oj(${binPath}):${err.message}${hint}`));
142
213
  });
143
214
  const timeout = setTimeout(() => {
215
+ const hint = portInUseHint(stderrTail);
216
+ const tail = stderrTail.trim() || readLatestLogError(resolvedLogsDir);
144
217
  finish(new Error(
145
- `[ojm] oj 健康检查超时(${Math.round(timeoutMs / 1000)}s):${healthUrl} 一直不可达。\n`
146
- + `stderr 尾部:\n${stderrTail.trim() || "(无输出)"}`,
218
+ `[ojm] oj 健康检查超时(${Math.round(timeoutMs / 1000)}s):${healthUrl} 一直不可达。${hint}\n`
219
+ + `stderr 尾部:\n${tail || "(无输出)"}`,
147
220
  ));
148
221
  }, timeoutMs);
149
222
  timeout.unref();
@@ -0,0 +1,38 @@
1
+ import net from "node:net";
2
+
3
+ /**
4
+ * 探测某端口是否已被占用(即有进程在监听)。
5
+ *
6
+ * 用于 `ojm dev` 启动前的后端端口预检:若 oj 的目标端口已被(通常是上次未正常
7
+ * 退出、遗留的)oj 孤儿进程占据,提前给出清晰提示并禁止启动,避免 oj 健康检查
8
+ * 超时后才以晦涩信息失败。
9
+ *
10
+ * 探测方式:本进程尝试在该端口上 `listen`——
11
+ * - `EADDRINUSE` → 端口已被占用,返回 true;
12
+ * - 绑定成功 → 端口空闲,立即释放并返回 false;
13
+ * - 其它错误(如权限)→ 无法确认占用,保守返回 false(宁可放行,不让带病禁止)。
14
+ *
15
+ * 不用「TCP connect 探活」:某些环境下连向空闲端口的连接会被静默丢弃(既不 RST 也
16
+ * 不 SYN-ACK),导致探测挂起并以「保守判占用」告终,从而误杀每一次 ojm dev 启动。
17
+ * bind 探测同步返回,绝不挂起。
18
+ */
19
+ export function isPortOccupied(port: number, host = "127.0.0.1"): Promise<boolean> {
20
+ return new Promise((resolve) => {
21
+ const probe = net.createServer();
22
+ let settled = false;
23
+ const done = (occupied: boolean) => {
24
+ if (settled)
25
+ return;
26
+ settled = true;
27
+ resolve(occupied);
28
+ };
29
+ probe.once("error", (err: NodeJS.ErrnoException) => {
30
+ // EADDRINUSE = 端口确被占用;其余(EACCES 等)无法确认,保守放行
31
+ done(err.code === "EADDRINUSE");
32
+ });
33
+ probe.listen(port, host, () => {
34
+ // 能绑定说明空闲:立即释放后返回 false
35
+ probe.close(() => done(false));
36
+ });
37
+ });
38
+ }
package/src/rekey.ts ADDED
@@ -0,0 +1,51 @@
1
+ import type { DevSecretsDeps } from "./dev-secrets";
2
+ /**
3
+ * `ojm rekey` —— 重新生成开发态本机密钥并重封 dev jwt_secret(方案 A' 第 8 点)。
4
+ *
5
+ * 只动 gitignored 的 `config/secrets-*.pem` 与 `config.local.yaml` 里的 dev 覆盖,
6
+ * **绝不**触碰 tracked `config.yaml`(prod 路径零改动,A-3 不变量)。
7
+ *
8
+ * 典型场景:
9
+ * - 本机 dev 密钥对被人误删/改坏,`ojm dev` 因 jws/ENC 解不开起不来 → `ojm rekey` 重生。
10
+ * - 想让本机 jwt_secret 轮换(dev 可随意轮换,无外部密文依赖)。
11
+ * 之后 `ojm dev` 会用新密钥自动重合并重起;无需手改任何 tracked 文件。
12
+ */
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { provisionDevSecrets, resolveLocalConfigPath } from "./dev-secrets";
16
+ import { resolveCertDir, resolveConfigPath } from "./layout";
17
+
18
+ export interface RekeyResult {
19
+ /** 重生后的 `config.local.yaml` 路径 */
20
+ localPath: string
21
+ /** 是否真正重新生成了密钥对(已存在且可用则 rekey 只重封 jwt,不重生成) */
22
+ regeneratedKey: boolean
23
+ }
24
+
25
+ export interface RekeyDeps extends DevSecretsDeps {}
26
+
27
+ /**
28
+ * 重新供给开发态本机密钥:删除旧 `secrets-*.pem` → `provisionDevSecrets` 因缺失而重生成,
29
+ * 并跟着重封 jwt_secret 写入 `config.local.yaml`。返回落点信息。
30
+ */
31
+ export function rekeyCommand(projectRoot: string, deps: RekeyDeps = {}): RekeyResult {
32
+ const configPath = resolveConfigPath(projectRoot);
33
+ if (!fs.existsSync(configPath))
34
+ throw new Error(`[ojm] 未找到 config.yaml(${configPath}),无法 rekey。请确认当前目录是 ojm 工程。`);
35
+
36
+ const keysDir = resolveCertDir(projectRoot);
37
+ const privateKey = path.join(keysDir, "secrets-private.pem");
38
+ const publicKey = path.join(keysDir, "secrets-public.pem");
39
+ const hadKey = fs.existsSync(privateKey) || fs.existsSync(publicKey);
40
+ // 强制重生:先删旧的,provisionDevSecrets 见缺失即 keygen(A' 自动 rekey 的同一入口)
41
+ for (const f of [privateKey, publicKey]) {
42
+ if (fs.existsSync(f))
43
+ fs.rmSync(f);
44
+ }
45
+
46
+ provisionDevSecrets(projectRoot, configPath, deps);
47
+ return {
48
+ localPath: resolveLocalConfigPath(configPath),
49
+ regeneratedKey: hadKey,
50
+ };
51
+ }
@@ -15,7 +15,7 @@
15
15
 
16
16
  import type http from "node:http";
17
17
  import { existsSync, readFileSync, statSync } from "node:fs";
18
- import { extname, normalize, resolve } from "node:path";
18
+ import { extname, normalize, resolve, sep } from "node:path";
19
19
 
20
20
  const MIME: Record<string, string> = {
21
21
  ".html": "text/html; charset=utf-8",
@@ -185,7 +185,12 @@ export function createStaticHandler(opts: StaticHandlerOptions): (req: http.Inco
185
185
  res.end("400 Bad Request: malformed URL encoding");
186
186
  return;
187
187
  }
188
- const rel = normalize(urlPath).replace(/^(\.\.[/\\])+/, "");
188
+ // URL 路径是「类 Unix 斜杠语义」,不能用 path.normalize 的 OS 方言:
189
+ // 在 Windows 上 path.normalize("/assets/x.js") 会变成 "\\assets\\x.js",
190
+ // 既让下方 /__ojm_reload.js 等精确匹配全部落空(404),又让 resolveFile
191
+ // 把前导反斜杠当成「盘符根」解析到 C:\\assets\\...(错位 404)。统一转回
192
+ // 正斜杠——POSIX 上 path.sep 即 "/" 此换行为 no-op,跨平台安全。
193
+ const rel = normalize(urlPath).split(sep).join("/").replace(/^(\.\.[/\\])+/, "");
189
194
 
190
195
  if (opts.reload) {
191
196
  if (rel === "/__ojm_reload") {
package/src/usage.ts CHANGED
@@ -111,8 +111,18 @@ export const HELP_GROUPS: HelpGroup[] = [
111
111
  zh: "启动开发服务器(/api 反代 oj + 模块重建 + SSE 刷新)",
112
112
  en: "Start dev server (/api proxy to oj + module rebuild + SSE refresh)",
113
113
  detail: {
114
- zh: "ojm dev [port]\n 开发模式快捷键(仅 TTY 终端,类 Vite):\n r 强制重建并热更新 h 显示帮助 c 清屏 o 浏览器打开\n q 退出(后端 oj 一并终止)\n port 缺省 5174(oj 后端端口以 config.yaml 的 server.port 为准)",
115
- en: "ojm dev [port]\n Dev-mode shortcuts (TTY only, Vite-like):\n r force rebuild + hot reload h show help c clear screen o open browser\n q quit (terminates the oj backend as well)\n port defaults to 5174 (the oj backend port comes from config.yaml server.port)",
114
+ zh: "ojm dev [port]\n 开发模式快捷键(仅 TTY 终端,类 Vite):\n r 强制重建并热更新 h 显示帮助 c 清屏 o 浏览器打开\n q 退出(后端 oj 一并终止)\n port 缺省 5174(oj 后端端口以 config.yaml 的 server.port 为准)\n 首次 clone 无本机密钥时自动供给 dev 密钥 + 随机 jwt_secret(方案 A'),并合并 config.local.yaml 后启动",
115
+ en: "ojm dev [port]\n Dev-mode shortcuts (TTY only, Vite-like):\n r force rebuild + hot reload h show help c clear screen o open browser\n q quit (terminates the oj backend as well)\n port defaults to 5174 (the oj backend port comes from config.yaml server.port)\n On first clone without local keys, dev auto-provisions a dev keypair + random jwt_secret (plan A') and starts after merging config.local.yaml",
116
+ },
117
+ },
118
+ {
119
+ key: "rekey",
120
+ name: "rekey",
121
+ zh: "重生开发态本机密钥并重封 dev jwt_secret(不动 tracked config)",
122
+ en: "Regenerate the dev-local keypair and re-seal the dev jwt_secret (tracked config untouched)",
123
+ detail: {
124
+ zh: "ojm rekey\n 删除 config/secrets-*.pem → 重新 keygen → 重封 dev jwt_secret 写入 config.local.yaml\n 仅影响 gitignored 的本地文件,tracked config.yaml 零改动(方案 A' 第 8 点)\n 用于本机 dev 密钥对误删/损坏、或想轮换 dev jwt_secret;之后 ojm dev 自动用新密钥重合并重起",
125
+ en: "ojm rekey\n Deletes config/secrets-*.pem → keygen again → re-seal the dev jwt_secret into config.local.yaml\n Only affects gitignored local files; the tracked config.yaml is untouched (plan A' point 8)\n Use when the local dev keypair was deleted/corrupted, or to rotate the dev jwt_secret; ojm dev then re-merges and restarts with the new key",
116
126
  },
117
127
  },
118
128
  {
package/src/vendor.ts CHANGED
@@ -103,6 +103,20 @@ export function syncOjDevkit(projectRoot: string, binDir: string, log: (msg: str
103
103
  return { ok: true, files };
104
104
  }
105
105
 
106
+ /**
107
+ * bin/ 下实际存在的 oj 文件名:Windows 原生二进制是 oj.exe,但也兼容无扩展名的
108
+ * `oj`(手动放置 / 脚本桩)。仅 win32 做回落,POSIX 恒为 `oj` 不受影响。
109
+ */
110
+ function ojBinIn(binDir: string, platform: NodeJS.Platform = process.platform): string {
111
+ const primary = path.join(binDir, ojBinName(platform));
112
+ if (platform === "win32" && !fs.existsSync(primary)) {
113
+ const bare = path.join(binDir, "oj");
114
+ if (fs.existsSync(bare))
115
+ return bare;
116
+ }
117
+ return primary;
118
+ }
119
+
106
120
  /**
107
121
  * 工程内 oj 二进制路径的**唯一口径**(<projectRoot>/bin/oj)。
108
122
  *
@@ -111,12 +125,12 @@ export function syncOjDevkit(projectRoot: string, binDir: string, log: (msg: str
111
125
  * 整套同版本,路径算错会拿到半新半旧的组合。统一到这里。
112
126
  */
113
127
  export function resolveOjBin(projectRoot: string): string {
114
- return path.join(projectRoot, "bin", ojBinName());
128
+ return ojBinIn(path.join(projectRoot, "bin"));
115
129
  }
116
130
 
117
131
  /** 本地版本只认 .oj-version 标记;bin/oj 缺失或标记缺失都视为未安装(N6,格式 vX.Y.Z) */
118
132
  export function readLocalVersion(binDir: string, platform: NodeJS.Platform = process.platform): string | null {
119
- if (!fs.existsSync(path.join(binDir, ojBinName(platform))))
133
+ if (!fs.existsSync(ojBinIn(binDir, platform)))
120
134
  return null;
121
135
  const marker = path.join(binDir, ".oj-version");
122
136
  if (!fs.existsSync(marker))
@@ -180,10 +194,23 @@ function reportProbeFailure(log: (msg: string) => void, detail: string): void {
180
194
  /** npm 执行器(测试注入);返回 stdout */
181
195
  export type NpmRun = (args: string[], cwd: string) => string;
182
196
 
183
- const NPM_CMD = process.platform === "win32" ? "npm.cmd" : "npm";
197
+ /**
198
+ * npm 执行规格:按平台决定 execFileSync 的命令行。
199
+ *
200
+ * Windows 上 npm 实为 `npm.cmd` 批处理文件——`CreateProcess` 无法直接执行 `.cmd`
201
+ * (否则 `spawnSync npm.cmd EINVAL`,连 `npm view` 这步都过不去),必须由 `cmd.exe /c`
202
+ * 包裹运行。其余平台直接以 `npm` 执行即可。
203
+ */
204
+ export function npmExecSpec(platform: string, args: string[]): { cmd: string, execArgs: string[] } {
205
+ if (platform === "win32")
206
+ return { cmd: "cmd.exe", execArgs: ["/c", "npm.cmd", ...args] };
207
+ return { cmd: "npm", execArgs: args };
208
+ }
184
209
 
185
- const defaultRun: NpmRun = (args, cwd) =>
186
- execFileSync(NPM_CMD, args, { cwd, encoding: "utf-8", maxBuffer: 16 * 1024 * 1024 });
210
+ export const defaultRun: NpmRun = (args, cwd) => {
211
+ const { cmd, execArgs } = npmExecSpec(process.platform, args);
212
+ return execFileSync(cmd, execArgs, { cwd, encoding: "utf-8", maxBuffer: 16 * 1024 * 1024 });
213
+ };
187
214
 
188
215
  export interface VendorDeps {
189
216
  run?: NpmRun
@@ -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.57",
12
- "@oj-module/runtime/contract": "0.2.57",
13
- "@oj-module/runtime/contract/errors": "0.2.57",
11
+ "@oj-module/runtime": "0.2.62",
12
+ "@oj-module/runtime/contract": "0.2.62",
13
+ "@oj-module/runtime/contract/errors": "0.2.62",
14
14
  "@plane/kit": "0.1.20",
15
15
  "@plane/editor": "0.1.20",
16
16
  "antd": "6.6.1",