@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.
- package/devkit/README.md +7 -4
- package/devkit/SKILL.md +4 -1
- package/devkit/manual.md +4 -2
- package/devkit/module-dev-and-cdn-playground.md +7 -0
- package/devkit/publish-walkthrough.md +199 -0
- package/package.json +2 -2
- package/shell-dist/assets/{index-CImwskAz.js → index-BKtXxIXz.js} +32 -2
- package/shell-dist/assets/runtime.js +6 -6
- package/shell-dist/index.html +3 -3
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/port-check.test.ts +40 -0
- package/src/__tests__/vendor-devkit.test.ts +87 -0
- package/src/__tests__/vendor.test.ts +39 -0
- package/src/build/backend.ts +131 -0
- package/src/build/module-build.ts +364 -0
- package/src/build/module-def.ts +400 -0
- package/src/build/site-merge.ts +33 -0
- package/src/build.ts +16 -885
- package/src/bundle.ts +61 -0
- package/src/cli.ts +11 -1
- package/src/contract/emit-stub.ts +7 -3
- package/src/dev-secrets.ts +315 -0
- package/src/dev.ts +26 -3
- package/src/init.ts +27 -39
- package/src/lifecycle/__tests__/bundle-summary.test.ts +106 -0
- package/src/lifecycle/__tests__/bundle.test.ts +24 -1
- package/src/lifecycle/__tests__/deterministic-tar.test.ts +19 -2
- package/src/market/bootstrap.ts +5 -2
- package/src/new.ts +27 -0
- package/src/oj.ts +82 -9
- package/src/port-check.ts +38 -0
- package/src/rekey.ts +51 -0
- package/src/static-handler.ts +7 -2
- package/src/usage.ts +12 -2
- package/src/vendor.ts +66 -5
- package/templates/openapi.json +7 -0
- package/templates/src/app/api/_platform/_lifecycle/installer.ts +8 -3
- package/templates/src/app/api/_platform/_lifecycle/steps/register.ts +53 -0
- package/templates/src/app/api/_platform/_shared/service/domain/roles.ts +4 -1
- package/templates/src/app/api/_platform/_shared/service/domain/solution-perms.ts +7 -3
- package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.mysql.sql +3 -0
- package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.pg.sql +3 -0
- package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.sql +8 -0
- package/templates/src/app/api/_platform/schema.yaml +2 -0
- package/templates/src/app/api/system/roles/api.ts +2 -1
- package/templates/src/app/web/system/__client/api.schemas.ts +1 -0
- package/templates/src/app/web/system/pages/roles/index.tsx +7 -1
- package/templates/tests/lifecycle/role-title-translate.spec.ts +171 -0
- package/templates/tests/rbac-roles.spec.ts +9 -0
- package/vendor/host-versions.json +3 -3
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// BDD:`ojm build` 末尾用户方案摘要(计划 docs/plans/20261103-0925-ojm-build-solution-summary.md)。
|
|
2
|
+
// 摘要只报告不裁决:判定口径与 --bundle 打包对象同源(模块级契约 ∪ 根契约,减内置名单);
|
|
3
|
+
// log 注入断言,夹具只摆契约/名单/构建结果三样,不需要真实工程结构。
|
|
4
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
9
|
+
import { summarizeBuiltSolutions } from "../../bundle";
|
|
10
|
+
|
|
11
|
+
let project: string;
|
|
12
|
+
let lines: string[];
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
project = mkdtempSync(join(tmpdir(), "ojm-build-summary-"));
|
|
16
|
+
lines = [];
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
rmSync(project, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** 模块级契约:src/<name>/solution.yaml(+ web/entry.ts 保持真实模块目录形态) */
|
|
24
|
+
function moduleContract(name: string): void {
|
|
25
|
+
const dir = join(project, "src", name);
|
|
26
|
+
mkdirSync(join(dir, "web"), { recursive: true });
|
|
27
|
+
writeFileSync(join(dir, "web", "entry.ts"), "export default {};\n");
|
|
28
|
+
writeFileSync(join(dir, "solution.yaml"), `name: ${name}\nversion: 1.0.0\nsummary: ${name}\n`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function builtinList(names: string[]): void {
|
|
32
|
+
mkdirSync(join(project, "src"), { recursive: true });
|
|
33
|
+
writeFileSync(join(project, "src", ".ojm-builtin.json"), JSON.stringify({ modules: names }));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function summaryOf(built: { name: string, version: string }[]): string[] {
|
|
37
|
+
summarizeBuiltSolutions(project, built, l => lines.push(l));
|
|
38
|
+
return lines;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("summarizeBuiltSolutions", () => {
|
|
42
|
+
it("场景:全部构建成功 → 一行列出可打包方案 + 一行列出内置模块", () => {
|
|
43
|
+
moduleContract("crm");
|
|
44
|
+
moduleContract("erp");
|
|
45
|
+
builtinList(["home", "login"]);
|
|
46
|
+
const out = summaryOf([
|
|
47
|
+
{ name: "crm", version: "1.0.0" },
|
|
48
|
+
{ name: "erp", version: "0.2.0" },
|
|
49
|
+
{ name: "home", version: "9.9.9" },
|
|
50
|
+
{ name: "login", version: "9.9.9" },
|
|
51
|
+
]);
|
|
52
|
+
expect(out.some(l => l.includes("用户方案 2 份已构建") && l.includes("crm@1.0.0") && l.includes("erp@0.2.0"))).toBe(true);
|
|
53
|
+
expect(out.some(l => l.includes("框架自带模块 2 份已构建"))).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("场景:方案没进构建 → 点名未构建方案与排查方向", () => {
|
|
57
|
+
moduleContract("crm");
|
|
58
|
+
moduleContract("erp");
|
|
59
|
+
builtinList(["home"]);
|
|
60
|
+
const out = summaryOf([
|
|
61
|
+
{ name: "crm", version: "1.0.0" },
|
|
62
|
+
{ name: "home", version: "9.9.9" },
|
|
63
|
+
]);
|
|
64
|
+
expect(out.some(l => l.includes("crm@1.0.0"))).toBe(true);
|
|
65
|
+
expect(out.filter(l => l.includes("erp")).join("\n")).toContain("未构建");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("场景:零用户方案 → 提示 ojm new", () => {
|
|
69
|
+
moduleContract("home");
|
|
70
|
+
builtinList(["home"]);
|
|
71
|
+
const out = summaryOf([{ name: "home", version: "9.9.9" }]);
|
|
72
|
+
expect(out.some(l => l.includes("用户方案 0 份") && l.includes("ojm new"))).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("场景:非内置模块缺契约 → 警告不会被打包", () => {
|
|
76
|
+
builtinList(["home"]);
|
|
77
|
+
const out = summaryOf([
|
|
78
|
+
{ name: "foo", version: "0.1.0" },
|
|
79
|
+
{ name: "home", version: "9.9.9" },
|
|
80
|
+
]);
|
|
81
|
+
expect(out.some(l => l.includes("缺方案契约") && l.includes("foo@0.1.0"))).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("场景:旧单方案工程(根契约、无 src/ 树)→ 列出根契约方案,不误报缺契约", () => {
|
|
85
|
+
writeFileSync(join(project, "manifest.yaml"), "name: demo-solution\nversion: 0.1.0\nsummary: 旧写法\n");
|
|
86
|
+
const out = summaryOf([{ name: "demo-solution", version: "0.1.0" }]);
|
|
87
|
+
expect(out.some(l => l.includes("用户方案 1 份已构建") && l.includes("demo-solution@0.1.0"))).toBe(true);
|
|
88
|
+
expect(out.every(l => !l.includes("缺方案契约"))).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("场景:新脚手架形态(内置集中 src/app,无用户方案)→ 0 份提示,src/app 不被误判", () => {
|
|
92
|
+
// 脚手架 init 后:src/ 下只有 app(平台聚合树,无契约)与 oj-market,均在内置名单
|
|
93
|
+
mkdirSync(join(project, "src", "app", "web"), { recursive: true });
|
|
94
|
+
writeFileSync(join(project, "src", "app", "web", "entry.ts"), "export default {};\n");
|
|
95
|
+
mkdirSync(join(project, "src", "oj-market", "web"), { recursive: true });
|
|
96
|
+
writeFileSync(join(project, "src", "oj-market", "web", "entry.ts"), "export default {};\n");
|
|
97
|
+
builtinList(["app", "oj-market", "_platform", "auth", "notification", "personal-center", "system", "web"]);
|
|
98
|
+
const out = summaryOf([
|
|
99
|
+
{ name: "app", version: "0.2.56" },
|
|
100
|
+
{ name: "oj-market", version: "0.2.56" },
|
|
101
|
+
]);
|
|
102
|
+
expect(out.some(l => l.includes("用户方案 0 份") && l.includes("ojm new"))).toBe(true);
|
|
103
|
+
expect(out.some(l => l.includes("框架自带模块 2 份已构建"))).toBe(true);
|
|
104
|
+
expect(out.every(l => !l.includes("缺方案契约"))).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -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;
|
|
@@ -104,6 +119,12 @@ describe("buildBundle · 正路径", () => {
|
|
|
104
119
|
expect(tarList(r.tgz).some(n => n.endsWith("/modules.json"))).toBe(false);
|
|
105
120
|
});
|
|
106
121
|
|
|
122
|
+
it("r1 前提:模块源码 locales 双语随包(安装期角色 title 解析数据源,计划 20261103-0948)", async () => {
|
|
123
|
+
const names = tarList((await pack()).tgz);
|
|
124
|
+
expect(names).toContain("demo-solution-0.1.0/web/modules/demo-solution/locales/zh-CN.json");
|
|
125
|
+
expect(names).toContain("demo-solution-0.1.0/web/modules/demo-solution/locales/en-US.json");
|
|
126
|
+
});
|
|
127
|
+
|
|
107
128
|
it("solution.yaml 作为 bundle 契约(优先于 manifest.yaml,避开 oj 按 manifest.yaml 枚举 api 后端的冲突)", async () => {
|
|
108
129
|
// 根目录若用 manifest.yaml 当 bundle 契约,会被 oj 的 api 模块搜索枚举误判为 api 模块;
|
|
109
130
|
// 故解决方案改用 solution.yaml,bundle 工具优先认它(manifest.yaml 仅旧方案回退)。
|
|
@@ -320,8 +341,10 @@ function writeManifest(m: unknown): void {
|
|
|
320
341
|
}
|
|
321
342
|
|
|
322
343
|
function tarList(tgz: string): string[] {
|
|
323
|
-
return execFileSync("tar", ["-tvzf", tgz], { encoding: "utf8" })
|
|
344
|
+
return execFileSync("tar", [...TAR_LOCAL, "-tvzf", tgz], { encoding: "utf8", shell: true })
|
|
324
345
|
// 铁律②(P1-6):tar 输出恒带尾随换行,trim 后再 split
|
|
346
|
+
// bsdtar on Windows emits CRLF; strip \r before splitting.
|
|
347
|
+
.replace(/\r/g, "")
|
|
325
348
|
.trim()
|
|
326
349
|
.split("\n")
|
|
327
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
|
}
|
package/src/market/bootstrap.ts
CHANGED
|
@@ -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",
|
|
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
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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}
|
|
134
|
-
+ `stderr 尾部:\n${
|
|
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}
|
|
146
|
-
+ `stderr 尾部:\n${
|
|
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
|
+
}
|
package/src/static-handler.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
@@ -79,6 +79,44 @@ export function ojBinName(platform: string = process.platform): string {
|
|
|
79
79
|
return platform === "win32" ? "oj.exe" : "oj";
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* oj devkit 文档同步(计划 docs/plans/20261103-1020-vendor-devkit-sync):bin/devkit
|
|
84
|
+
* (@oj-bin/oj postinstall 落盘,与 bin/oj 同版本)→ 工程 `.claude/skills/oj-api-dev/`,
|
|
85
|
+
* 全部 `.md` **覆盖**拷入——skill 文档始终与已装 oj 版本一致(vendor/init 共用出口)。
|
|
86
|
+
* bin/devkit 缺失(旧版 oj/异常安装)→ ok:false,调用方记跳过、不失败。
|
|
87
|
+
* global.d.ts 不在此同步:工程根那份是模板自有变体、用户可编辑(init 首装 copyIfAbsent)。
|
|
88
|
+
*/
|
|
89
|
+
export function syncOjDevkit(projectRoot: string, binDir: string, log: (msg: string) => void = () => {}): { ok: boolean, files: string[] } {
|
|
90
|
+
const devkitDir = path.join(binDir, "devkit");
|
|
91
|
+
if (!fs.existsSync(path.join(devkitDir, "SKILL.md")))
|
|
92
|
+
return { ok: false, files: [] };
|
|
93
|
+
const skillDir = path.join(projectRoot, ".claude", "skills", "oj-api-dev");
|
|
94
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
95
|
+
const files: string[] = [];
|
|
96
|
+
for (const name of fs.readdirSync(devkitDir)) {
|
|
97
|
+
if (!name.endsWith(".md"))
|
|
98
|
+
continue;
|
|
99
|
+
fs.copyFileSync(path.join(devkitDir, name), path.join(skillDir, name));
|
|
100
|
+
files.push(name);
|
|
101
|
+
}
|
|
102
|
+
log(`[ojm] oj devkit 文档已同步 → .claude/skills/oj-api-dev/(${files.length} 个文件,与已装 oj 同版本)`);
|
|
103
|
+
return { ok: true, files };
|
|
104
|
+
}
|
|
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
|
+
|
|
82
120
|
/**
|
|
83
121
|
* 工程内 oj 二进制路径的**唯一口径**(<projectRoot>/bin/oj)。
|
|
84
122
|
*
|
|
@@ -87,12 +125,12 @@ export function ojBinName(platform: string = process.platform): string {
|
|
|
87
125
|
* 整套同版本,路径算错会拿到半新半旧的组合。统一到这里。
|
|
88
126
|
*/
|
|
89
127
|
export function resolveOjBin(projectRoot: string): string {
|
|
90
|
-
return path.join(projectRoot, "bin"
|
|
128
|
+
return ojBinIn(path.join(projectRoot, "bin"));
|
|
91
129
|
}
|
|
92
130
|
|
|
93
131
|
/** 本地版本只认 .oj-version 标记;bin/oj 缺失或标记缺失都视为未安装(N6,格式 vX.Y.Z) */
|
|
94
132
|
export function readLocalVersion(binDir: string, platform: NodeJS.Platform = process.platform): string | null {
|
|
95
|
-
if (!fs.existsSync(
|
|
133
|
+
if (!fs.existsSync(ojBinIn(binDir, platform)))
|
|
96
134
|
return null;
|
|
97
135
|
const marker = path.join(binDir, ".oj-version");
|
|
98
136
|
if (!fs.existsSync(marker))
|
|
@@ -156,10 +194,23 @@ function reportProbeFailure(log: (msg: string) => void, detail: string): void {
|
|
|
156
194
|
/** npm 执行器(测试注入);返回 stdout */
|
|
157
195
|
export type NpmRun = (args: string[], cwd: string) => string;
|
|
158
196
|
|
|
159
|
-
|
|
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
|
+
}
|
|
160
209
|
|
|
161
|
-
const defaultRun: NpmRun = (args, cwd) =>
|
|
162
|
-
|
|
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
|
+
};
|
|
163
214
|
|
|
164
215
|
export interface VendorDeps {
|
|
165
216
|
run?: NpmRun
|
|
@@ -222,6 +273,12 @@ function installFromNpm(version: string, binDir: string, run: NpmRun, log: (msg:
|
|
|
222
273
|
}
|
|
223
274
|
}
|
|
224
275
|
|
|
276
|
+
/** devkit 同步 + 缺失时的人话跳过行(vendorCommand 两个调用点共用) */
|
|
277
|
+
function syncDevkitOrSkip(projectRoot: string, binDir: string, log: (msg: string) => void): void {
|
|
278
|
+
if (!syncOjDevkit(projectRoot, binDir, log).ok)
|
|
279
|
+
log("[ojm] bin/devkit 缺失,跳过 oj devkit 文档同步(旧版 oj 或异常安装;skill 文档不更新)");
|
|
280
|
+
}
|
|
281
|
+
|
|
225
282
|
/**
|
|
226
283
|
* US-1..US-5 编排:解析目标版本(tag 缺省经 npm view 追 latest,显式 tag 去 v 前缀)
|
|
227
284
|
* → 本地已是该版本则跳过(--force 除外)→ 临时目录安装 + 拷贝 → 报告版本 → 探针冒烟。
|
|
@@ -243,6 +300,9 @@ export async function vendorCommand(
|
|
|
243
300
|
const binDir = path.join(projectRoot, "bin");
|
|
244
301
|
const local = readLocalVersion(binDir);
|
|
245
302
|
if (!opts.force && local === `v${version}`) {
|
|
303
|
+
// 文档同步不随版本跳过:用户删过 skill 目录后重跑 vendor 即补齐(R4)。
|
|
304
|
+
// 先同步再打跳过行——「已是 vX」保持命令收尾行(既有门禁断言末行)。
|
|
305
|
+
syncDevkitOrSkip(projectRoot, binDir, log);
|
|
246
306
|
log(`[ojm] oj 已是 v${version},跳过。`);
|
|
247
307
|
const result = probe(binDir);
|
|
248
308
|
if (!result.ok)
|
|
@@ -255,6 +315,7 @@ export async function vendorCommand(
|
|
|
255
315
|
log(`[ojm] 本地 ${local ?? "未安装"} → v${version}`);
|
|
256
316
|
installFromNpm(version, binDir, run, log);
|
|
257
317
|
log(`[ojm] oj ${local ? `${local} → ` : ""}v${version} 安装完成:${path.join(binDir, ojBinName(process.platform))}`);
|
|
318
|
+
syncDevkitOrSkip(projectRoot, binDir, log);
|
|
258
319
|
// 安装后冒烟:发布二进制可能因构建机路径被烤进而不可用(见 probeOjRuntime)
|
|
259
320
|
const result = probe(binDir);
|
|
260
321
|
if (!result.ok)
|