@oj-module/cli 0.2.67 → 0.2.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/devkit/SKILL.md +8 -3
- package/devkit/manual.md +109 -13
- package/devkit/menu-rules.md +19 -4
- package/devkit/module-dev-and-cdn-playground.md +4 -0
- package/devkit/publish-walkthrough.md +28 -6
- package/devkit/wecom-internal-app.md +1 -1
- package/package.json +2 -2
- package/shell-dist/assets/{runtime.2dgstpjsu0.js → runtime.1xzahckt10.js} +5036 -5020
- package/shell-dist/index.html +4 -4
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/cli.test.ts +31 -0
- package/src/__tests__/new-scaffold.test.ts +13 -0
- package/src/build/module-def.ts +1 -0
- package/src/builtin.ts +3 -2
- package/src/cli.ts +4 -0
- package/src/init.ts +47 -41
- package/src/market/publish.ts +3 -0
- package/src/new.ts +2 -0
- package/src/scaffold-guard.ts +227 -0
- package/src/usage.ts +5 -5
- package/templates/init.include.yaml +2 -0
- package/templates/src/README.md +37 -0
- package/templates/src/app/api/_platform/_lifecycle/installer.ts +7 -3
- package/templates/src/app/api/_platform/_shared/service/domain/solution-perms.ts +25 -10
- package/templates/tests/lifecycle/register.spec.ts +22 -0
- package/templates/tests/lifecycle/role-title-translate.spec.ts +2 -2
- package/templates/tests/rbac-solution-perms.spec.ts +48 -3
- package/templates/web.config.ts +3 -1
- package/vendor/host-versions.json +3 -3
package/shell-dist/index.html
CHANGED
|
@@ -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-
|
|
9
|
-
<script type="importmap" nonce="
|
|
8
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' https://modules.cdn.example.com 'wasm-unsafe-eval' 'nonce-36CAd5UpXCtQqxASbhUFfw=='; 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="36CAd5UpXCtQqxASbhUFfw==">{
|
|
10
10
|
"imports": {
|
|
11
11
|
"react": "/assets/react.1ew63mwts0.js",
|
|
12
12
|
"react/jsx-runtime": "/assets/jsx-runtime.h40xu4o5b0.js",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"react-router": "/assets/react-router.2h9rebcv50.js",
|
|
17
17
|
"react-router/dom": "/assets/react-router-dom.3c6zld8h40.js",
|
|
18
18
|
"@tanstack/react-query": "/assets/react-query.3rln45ow00.js",
|
|
19
|
-
"@oj-module/runtime": "/assets/runtime.
|
|
19
|
+
"@oj-module/runtime": "/assets/runtime.1xzahckt10.js",
|
|
20
20
|
"@oj-module/runtime/contract": "/assets/contract.3h327o3mj0.js",
|
|
21
21
|
"@oj-module/runtime/contract/errors": "/assets/contract-errors.2o17vz9u50.js",
|
|
22
22
|
"@plane/kit": "/assets/kit.37kr8ui390.js",
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
.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}
|
|
80
80
|
@keyframes bs-spin{to{transform:rotate(360deg)}}
|
|
81
81
|
</style>
|
|
82
|
-
<script nonce="
|
|
82
|
+
<script nonce="36CAd5UpXCtQqxASbhUFfw==" data-ojm-preload="/assets/cssinjs.atgmhan7m0.js,/assets/pro-components.2kyot1vcl0.js,/assets/runtime.1xzahckt10.js,/assets/react-query.3rln45ow00.js,/assets/antd.uw1coq15l0.js,/assets/react.1ew63mwts0.js,/assets/react-dom-client.ick0v05ob0.js,/assets/react-i18next.auhvjhqv00.js,/assets/react-router.2h9rebcv50.js,/assets/jsx-runtime.h40xu4o5b0.js,/assets/react-dom.2so9uxg3f0.js,/assets/clsx.jotilrt2e0.js,/assets/antd-es-locale-zh_CN.1gjjsvxbf0.js,/assets/dayjs.pa60l6wjo0.js,/assets/ojm-dayjs-locale-zh-cn.3vr3beiyp0.js,/assets/use-sync-external-store-shim.1nz1pi9bb0.js,/assets/icons.159wjwals0.js,/assets/dayjs-plugin-quarterOfYear.2oqo1lcx80.js,/assets/dayjs-plugin-customParseFormat.13xhghv1d0.js,/assets/dayjs-plugin-advancedFormat.3vsyd2eis0.js,/assets/dayjs-plugin-isoWeek.1fn1tw82p0.js,/assets/dayjs-plugin-localeData.2rwbjp9nk0.js,/assets/dayjs-plugin-weekday.2d56a4tml0.js,/assets/dayjs-plugin-weekOfYear.gvn9iph080.js,/assets/dayjs-plugin-relativeTime.2pqs3et4y0.js,/assets/dnd-kit-core.1xyreig6r0.js,/assets/dnd-kit-utilities.1umw4xphb0.js,/assets/dnd-kit-sortable.s94qciuft0.js,/assets/ky.1cgch7gu60.js,/assets/zustand.3gjgzlnqo0.js,/assets/zustand-middleware.1xaxjueme0.js,/assets/react-error-boundary.2dncdfj3b0.js,/assets/antd-locale-en_US.561tozx860.js,/assets/antd-locale-zh_CN.1cmpmgzh70.js,/assets/i18next.33zqofapj0.js,/assets/tailwind-merge.125ehiqjw0.js,/assets/nprogress.2nz76n9ny0.js,/assets/nprogress-css.1cq2pzkre0.js,/assets/react-jss.1ei6dr9e00.js,/assets/ahooks.2l850ymmh0.js,/assets/spin-delay.yjeyj6iwp0.js,/assets/simplebar-react.2g739uvai0.js,/assets/keepalive-for-react.xenu5vam70.js,/assets/zustand-shallow.6cz0yyv9x0.js,/assets/contract-errors.2o17vz9u50.js,/assets/antd-img-crop.196rkty480.js,/assets/icons-es-components-Context.365qmflwg0.js,/assets/dayjs-plugin-weekYear.2u78l3ex10.js,/assets/zustand-vanilla.1mo3ka3g30.js,/assets/zustand-react.9fsl9brxy0.js,/assets/zustand-vanilla-shallow.3424monv50.js,/assets/zustand-react-shallow.pybayv1hx0.js,/assets/ojm-antd-es-modal.bf3tgdh8v0.js,/assets/ojm-antd-es-upload.13gx45e0g0.js,/assets/ojm-antd-es-button.2uriphio50.js,/assets/ojm-antd-es-slider.xyvptuhid0.js" data-ojm-entry="/assets/index.6alqyx6ae0.js">(function(){var cs=document.currentScript;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);var q=new URLSearchParams(location.search),w=new URLSearchParams(),k=["corp","agent"];for(var i=0;i<k.length;i++){var v=(q.get(k[i])||"").trim();if(v)w.set(k[i],v)}var qs=w.toString();location.replace(a+"/auth/wecom/login"+(qs?"?"+qs:""));return}}catch(e){}var pl=cs.getAttribute("data-ojm-preload");if(pl){var ps=pl.split(",");for(var i=0;i<ps.length;i++){var l=document.createElement("link");l.rel="modulepreload";l.href=ps[i];document.head.appendChild(l);}}var s=document.createElement("script");s.type="module";s.src=cs.getAttribute("data-ojm-entry");document.head.appendChild(s);})();</script>
|
|
83
83
|
</head>
|
|
84
84
|
<body>
|
|
85
85
|
<div id="root"><div class="boot-skeleton">加载中…</div></div>
|
package/shell-dist/versions.json
CHANGED
|
@@ -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.
|
|
11
|
-
"@oj-module/runtime/contract": "0.2.
|
|
12
|
-
"@oj-module/runtime/contract/errors": "0.2.
|
|
10
|
+
"@oj-module/runtime": "0.2.68",
|
|
11
|
+
"@oj-module/runtime/contract": "0.2.68",
|
|
12
|
+
"@oj-module/runtime/contract/errors": "0.2.68",
|
|
13
13
|
"@plane/kit": "0.1.20",
|
|
14
14
|
"@plane/editor": "0.1.20",
|
|
15
15
|
"antd": "6.6.1",
|
|
@@ -21,6 +21,12 @@ let exitSpy: ReturnType<typeof vi.fn>;
|
|
|
21
21
|
// 提升式 mock:vitest 测试体内注册的 vi.mock 拦不到 cli.ts 的 dynamic import,须 hoisted 顶层注册
|
|
22
22
|
const buildBundleMock = vi.hoisted(() => vi.fn(async () => [{ file: "x.tgz" }]));
|
|
23
23
|
vi.mock("../bundle", () => ({ buildBundle: buildBundleMock }));
|
|
24
|
+
// 脚手架完整性门禁(scaffold-guard):门禁失败必须先于一切产物写入
|
|
25
|
+
const guardMock = vi.hoisted(() => vi.fn());
|
|
26
|
+
vi.mock("../scaffold-guard", () => ({ assertDefaultModulesUntouched: guardMock }));
|
|
27
|
+
const buildBackendMock = vi.hoisted(() => vi.fn(async () => {}));
|
|
28
|
+
const buildModulesMock = vi.hoisted(() => vi.fn(async () => []));
|
|
29
|
+
vi.mock("../build", () => ({ buildBackend: buildBackendMock, buildModules: buildModulesMock }));
|
|
24
30
|
const vendorCommandMock = vi.hoisted(() => vi.fn(async () => {}));
|
|
25
31
|
vi.mock("../vendor", () => ({ vendorCommand: vendorCommandMock }));
|
|
26
32
|
// REG-CS3 v2:sign 的 --registry-id 曾只被 signCommand 要求、却没在 CLI 上接线,命令成死路
|
|
@@ -39,6 +45,9 @@ afterEach(() => {
|
|
|
39
45
|
buildBundleMock.mockClear();
|
|
40
46
|
vendorCommandMock.mockClear();
|
|
41
47
|
signCommandMock.mockClear();
|
|
48
|
+
guardMock.mockClear();
|
|
49
|
+
buildBackendMock.mockClear();
|
|
50
|
+
buildModulesMock.mockClear();
|
|
42
51
|
});
|
|
43
52
|
|
|
44
53
|
interface RunResult { out: string, unknown: string[], exitCode: number | undefined }
|
|
@@ -215,6 +224,28 @@ describe("参数映射", () => {
|
|
|
215
224
|
});
|
|
216
225
|
});
|
|
217
226
|
|
|
227
|
+
describe("脚手架完整性门禁", () => {
|
|
228
|
+
it("门禁失败 → 普通 build 不执行(零产物)、exit 1", async () => {
|
|
229
|
+
guardMock.mockImplementation(() => {
|
|
230
|
+
throw new Error("[ojm] 框架默认模块与上游模板不一致,已拒绝构建");
|
|
231
|
+
});
|
|
232
|
+
const r = await run(["build"], "zh-CN", tmp);
|
|
233
|
+
expect(guardMock).toHaveBeenCalledWith(tmp);
|
|
234
|
+
expect(buildBackendMock).not.toHaveBeenCalled();
|
|
235
|
+
expect(buildModulesMock).not.toHaveBeenCalled();
|
|
236
|
+
expect(r.exitCode).toBe(1);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("门禁失败 → --bundle 同样不进 buildBundle", async () => {
|
|
240
|
+
guardMock.mockImplementation(() => {
|
|
241
|
+
throw new Error("[ojm] 框架默认模块与上游模板不一致,已拒绝构建");
|
|
242
|
+
});
|
|
243
|
+
const r = await run(["build", "--bundle", "demo"], "zh-CN", tmp);
|
|
244
|
+
expect(buildBundleMock).not.toHaveBeenCalled();
|
|
245
|
+
expect(r.exitCode).toBe(1);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
218
249
|
describe("help 词形", () => {
|
|
219
250
|
it("ojm help build → build --help", () => {
|
|
220
251
|
expect(rewriteHelpArgv(["help", "build"])).toEqual(["build", "--help"]);
|
|
@@ -56,4 +56,17 @@ describe("ojm new --api 骨架可路由(BDD)", () => {
|
|
|
56
56
|
expect(doc[key], `solution.yaml 缺 ${key}(market verify 必填)`).toBeDefined();
|
|
57
57
|
expect((doc.archive as Record<string, unknown>).file).toBe("crm-0.1.0.tgz");
|
|
58
58
|
});
|
|
59
|
+
|
|
60
|
+
it("给定 createSolution(api: true),api 段声明 ping 匿名(安装预检 anonymous_residual 防线,20261106 市场线实测)", async () => {
|
|
61
|
+
const root = projectRoot();
|
|
62
|
+
const result = createSolution(root, "crm", { api: true, layout: "none" });
|
|
63
|
+
const { parse: yamlParse } = await import("yaml");
|
|
64
|
+
const doc = yamlParse(fs.readFileSync(path.join(result.dir, "solution.yaml"), "utf8")) as Record<string, unknown>;
|
|
65
|
+
|
|
66
|
+
// Then:ping 匿名可达必须在契约里声明(fixture.ts 同形状);漏声明 = 真包装不进(preflight 拒)
|
|
67
|
+
const api = doc.api as Record<string, unknown>;
|
|
68
|
+
expect(api.module).toBe("crm");
|
|
69
|
+
expect(api.prefix).toBe("/api/crm");
|
|
70
|
+
expect(api.anonymous).toEqual(["crm/ping"]);
|
|
71
|
+
});
|
|
59
72
|
});
|
package/src/build/module-def.ts
CHANGED
|
@@ -157,6 +157,7 @@ export const getModules = () => [];
|
|
|
157
157
|
export const getModule = () => undefined;
|
|
158
158
|
export const getRoutes = () => [];
|
|
159
159
|
export const getHostRoutes = () => [];
|
|
160
|
+
export const resolveRoutePath = _fn;
|
|
160
161
|
// 模块生效 web 路由前缀段取值口(R1.3:写进 modules.json 的 routePrefix,未声明回落 name)
|
|
161
162
|
export const getModuleRoutePrefix = _fn;
|
|
162
163
|
export const getRegisteredStore = () => undefined;
|
package/src/builtin.ts
CHANGED
|
@@ -81,11 +81,12 @@ export function deriveBuiltinModules(include: Iterable<string>): Set<string> {
|
|
|
81
81
|
/**
|
|
82
82
|
* init 接线:生成/回填 `src/.ojm-builtin.json`(copyIfAbsent 语义)。
|
|
83
83
|
* 已存在 → 原样不动(用户手改权);派生集为空 → 不写。
|
|
84
|
+
* `overwrite`(ojm init --yes 恢复语义):已存在也重写为上游名单。
|
|
84
85
|
* @returns "created" | "skipped" | "none"
|
|
85
86
|
*/
|
|
86
|
-
export function writeBuiltinList(projectRoot: string, include: Iterable<string
|
|
87
|
+
export function writeBuiltinList(projectRoot: string, include: Iterable<string>, opts: { overwrite?: boolean } = {}): "created" | "skipped" | "none" {
|
|
87
88
|
const target = builtinListPath(projectRoot);
|
|
88
|
-
if (fs.existsSync(target))
|
|
89
|
+
if (fs.existsSync(target) && !opts.overwrite)
|
|
89
90
|
return "skipped";
|
|
90
91
|
const modules = [...deriveBuiltinModules(include)].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
91
92
|
if (modules.length === 0)
|
package/src/cli.ts
CHANGED
|
@@ -130,6 +130,10 @@ function makeAction(def: HelpEntry, projectRoot: string, lang: Lang, exit: (code
|
|
|
130
130
|
};
|
|
131
131
|
case "build":
|
|
132
132
|
return async (opts: { bundle?: unknown, out?: string } = {}) => {
|
|
133
|
+
// 脚手架完整性门禁(内置模块自治契约):普通与 --bundle 汇合前执行,
|
|
134
|
+
// 失败零产物——默认模块上游自治,本地修改不允许进包
|
|
135
|
+
const { assertDefaultModulesUntouched } = await import("./scaffold-guard");
|
|
136
|
+
assertDefaultModulesUntouched(projectRoot);
|
|
133
137
|
// mri 可选值:裸 --bundle 得到 "",--bundle name / --bundle=name 得到字符串
|
|
134
138
|
const bundle = opts.bundle !== undefined;
|
|
135
139
|
const bundleName = typeof opts.bundle === "string" && opts.bundle !== "" ? opts.bundle : undefined;
|
package/src/init.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
*
|
|
8
8
|
* 幂等补缺:对既有工程重跑只补缺失文件(bin/oj 已在则跳过下载,但先过 §7 能力闸门),
|
|
9
9
|
* config.yaml 与用户文件永不覆盖——「重新 init 覆盖脚手架」是审阅记录二明令禁止的行为。
|
|
10
|
+
* 唯一例外是 `--yes`:默认方案的显式恢复入口,仅覆盖/清理上游自治的保护根
|
|
11
|
+
* (src/app、src/oj-market,见 scaffold-guard.ts),自定义模块与工程配置仍不动。
|
|
10
12
|
*/
|
|
11
13
|
|
|
12
14
|
import { execFileSync } from "node:child_process";
|
|
@@ -14,18 +16,23 @@ import crypto from "node:crypto";
|
|
|
14
16
|
import fs from "node:fs";
|
|
15
17
|
import path from "node:path";
|
|
16
18
|
import process from "node:process";
|
|
17
|
-
import { fileURLToPath } from "node:url";
|
|
18
|
-
import yaml from "yaml";
|
|
19
19
|
import { ALIAS_PATHS_FILE, syncAliasPaths } from "./alias-paths";
|
|
20
20
|
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 {
|
|
25
|
+
isProtectedRel,
|
|
26
|
+
readInitInclude,
|
|
27
|
+
removeExtraProtectedFiles,
|
|
28
|
+
cliRoot as resolveCliRoot,
|
|
29
|
+
templatesRoot,
|
|
30
|
+
} from "./scaffold-guard";
|
|
24
31
|
import { isSupportedOjVersion, MIN_OJ_VERSION, resolveOjBin, syncOjDevkit, vendorCommand } from "./vendor";
|
|
25
32
|
import { readHostVersions } from "./versions";
|
|
26
33
|
|
|
27
34
|
export interface InitOptions {
|
|
28
|
-
/**
|
|
35
|
+
/** 跳过非空目录确认(自动化/测试用);对既有工程同时恢复上游自治的保护根 */
|
|
29
36
|
yes?: boolean
|
|
30
37
|
/** 注入桩 oj 安装器(测试);默认 vendorCommand 经 npm 包 @oj-bin/oj 联网安装 */
|
|
31
38
|
ojInstaller?: (binDir: string) => Promise<void>
|
|
@@ -109,26 +116,34 @@ export async function initProject(destDir: string, opts: InitOptions = {}): Prom
|
|
|
109
116
|
}
|
|
110
117
|
|
|
111
118
|
const report: InitReport = { created: [], skipped: [], warnings: [] };
|
|
112
|
-
const cliRoot =
|
|
119
|
+
const cliRoot = resolveCliRoot();
|
|
113
120
|
|
|
114
121
|
// 0) 已有 bin/oj 先过能力闸门(评审 BLOCKING #8):早前「bin/oj 存在即跳过」
|
|
115
122
|
// 会让从旧工程带来的 0.1.84 静默过关,等 `ojm build` 才死在 --module-root 不认。
|
|
116
123
|
assertExistingOjUsable(destDir, report);
|
|
117
124
|
|
|
118
|
-
// 1) 模板(占位符替换 +
|
|
119
|
-
const templatesDir =
|
|
125
|
+
// 1) 模板(占位符替换 + 幂等跳过;--yes 时保护根内改为覆盖恢复)
|
|
126
|
+
const templatesDir = templatesRoot();
|
|
120
127
|
const jwtSecret = crypto.randomBytes(32).toString("hex");
|
|
121
128
|
const projectName = path.basename(destDir);
|
|
122
129
|
const include = readInitInclude(templatesDir);
|
|
123
130
|
copyTemplates(templatesDir, destDir, {
|
|
124
131
|
__JWT_SECRET__: jwtSecret,
|
|
125
132
|
__PROJECT_NAME__: projectName,
|
|
126
|
-
}, report, include, templatesDir);
|
|
133
|
+
}, report, include, templatesDir, false, !!opts.yes);
|
|
127
134
|
pruneUnincludedApiPaths(destDir, include, report);
|
|
128
135
|
|
|
136
|
+
// 1a') --yes 恢复的清理面:删除保护根内模板没有的文件(留着会让 build 门禁
|
|
137
|
+
// 永久失败——extra 漂移不因 init 补缺而消失,必须显式清掉)
|
|
138
|
+
if (opts.yes) {
|
|
139
|
+
for (const rel of removeExtraProtectedFiles(destDir))
|
|
140
|
+
report.created.push(`恢复:删除多出文件 ${rel}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
129
143
|
// 1a) src/.ojm-builtin.json:脚手架自带模块身份(名单派生自上面的白名单,单一真源)。
|
|
130
|
-
// copyIfAbsent:旧工程重跑 init
|
|
131
|
-
|
|
144
|
+
// copyIfAbsent:旧工程重跑 init 时缺失才补写,已存在不覆盖用户手改;
|
|
145
|
+
// --yes 恢复语义下重写为上游名单。
|
|
146
|
+
const builtinStatus = writeBuiltinList(destDir, include, { overwrite: !!opts.yes });
|
|
132
147
|
if (builtinStatus === "created")
|
|
133
148
|
report.created.push(`src/${BUILTIN_LIST_FILE}`);
|
|
134
149
|
else if (builtinStatus === "skipped")
|
|
@@ -317,6 +332,9 @@ async function sealJwtSecret(destDir: string, report: InitReport, seal: Sealer =
|
|
|
317
332
|
* created/skipped(demo 模块/模板自运行产物/元数据的大头在清单外)。清单外的
|
|
318
333
|
* 目录仅在「有收录子孙」时才穿行(如 `api/`、`web/src/`、`web/src/demo/`——
|
|
319
334
|
* 只收录了后者底下的 entry/locales/pages),穿行时未收录的子项继续被挡下。
|
|
335
|
+
*
|
|
336
|
+
* `restore`(--yes):上游自治保护根(scaffold-guard.PROTECTED_ROOTS)内的
|
|
337
|
+
* 已存在文件改为覆盖写入——这是默认方案的显式恢复入口,其余文件仍不覆盖。
|
|
320
338
|
*/
|
|
321
339
|
function copyTemplates(
|
|
322
340
|
srcDir: string,
|
|
@@ -326,6 +344,7 @@ function copyTemplates(
|
|
|
326
344
|
include: Set<string>,
|
|
327
345
|
rootDir: string,
|
|
328
346
|
underIncluded = false,
|
|
347
|
+
restore = false,
|
|
329
348
|
): void {
|
|
330
349
|
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
|
|
331
350
|
const src = path.join(srcDir, entry.name);
|
|
@@ -343,24 +362,36 @@ function copyTemplates(
|
|
|
343
362
|
if (!dirHasFiles(src))
|
|
344
363
|
continue;
|
|
345
364
|
fs.mkdirSync(dest, { recursive: true });
|
|
346
|
-
copyTemplates(src, dest, placeholders, report, include, rootDir, underIncluded || listed);
|
|
365
|
+
copyTemplates(src, dest, placeholders, report, include, rootDir, underIncluded || listed, restore);
|
|
347
366
|
continue;
|
|
348
367
|
}
|
|
368
|
+
// 恢复判定用 srcRel(模板相对 == 工程相对,已归一正斜杠);destRel 是相对
|
|
369
|
+
// 当前递归层 destDir 的短路径,只配做报告标签,做不了保护根判定
|
|
349
370
|
const destRel = path.relative(destDir, dest);
|
|
350
371
|
if (fs.existsSync(dest)) {
|
|
372
|
+
if (restore && isProtectedRel(srcRel)) {
|
|
373
|
+
writeTemplateFile(src, dest, placeholders);
|
|
374
|
+
report.created.push(`恢复(--yes 覆盖)${srcRel}`);
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
351
377
|
report.skipped.push(destRel);
|
|
352
378
|
continue;
|
|
353
379
|
}
|
|
354
|
-
|
|
355
|
-
for (const [token, value] of Object.entries(placeholders))
|
|
356
|
-
content = content.replaceAll(token, value);
|
|
357
|
-
content = stripRegion(content, "demo-only");
|
|
358
|
-
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
359
|
-
fs.writeFileSync(dest, content);
|
|
380
|
+
writeTemplateFile(src, dest, placeholders);
|
|
360
381
|
report.created.push(destRel);
|
|
361
382
|
}
|
|
362
383
|
}
|
|
363
384
|
|
|
385
|
+
/** 读模板 → 占位符替换 → 剥 demo-only 区间 → 落盘(创建与 --yes 恢复共用一条变换链) */
|
|
386
|
+
function writeTemplateFile(src: string, dest: string, placeholders: Record<string, string>): void {
|
|
387
|
+
let content = fs.readFileSync(src, "utf-8");
|
|
388
|
+
for (const [token, value] of Object.entries(placeholders))
|
|
389
|
+
content = content.replaceAll(token, value);
|
|
390
|
+
content = stripRegion(content, "demo-only");
|
|
391
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
392
|
+
fs.writeFileSync(dest, content);
|
|
393
|
+
}
|
|
394
|
+
|
|
364
395
|
/** srcRel 是否为白名单里某条收录路径的祖先目录——决定清单外目录要不要穿行 */
|
|
365
396
|
function hasIncludedDescendant(include: Set<string>, srcRel: string): boolean {
|
|
366
397
|
const prefix = `${srcRel}/`;
|
|
@@ -382,31 +413,6 @@ function dirHasFiles(dir: string): boolean {
|
|
|
382
413
|
return false;
|
|
383
414
|
}
|
|
384
415
|
|
|
385
|
-
/**
|
|
386
|
-
* 读取模板根 `init.include.yaml` 的 `include` 白名单,归一为相对模板根的路径集合。
|
|
387
|
-
* 清单随模板一起发布,缺失即安装包损坏——直接 fail-fast,不留「静默少拷」的
|
|
388
|
-
* 退化路径。条目指向不存在的路径同样 fail-fast:拼错一个目录名不会当场报错,
|
|
389
|
-
* 只会在用户工程里留下难以追溯的缺文件(旧 exclude 清单没有这类校验面)。
|
|
390
|
-
*/
|
|
391
|
-
function readInitInclude(templatesDir: string): Set<string> {
|
|
392
|
-
const yamlPath = path.join(templatesDir, "init.include.yaml");
|
|
393
|
-
if (!fs.existsSync(yamlPath))
|
|
394
|
-
throw new Error(`模板缺少 init.include.yaml:${yamlPath}(安装包损坏?)`);
|
|
395
|
-
const doc = yaml.parse(fs.readFileSync(yamlPath, "utf-8")) as { include?: string[] } | null;
|
|
396
|
-
const out = new Set<string>();
|
|
397
|
-
for (const raw of doc?.include ?? []) {
|
|
398
|
-
// 跨平台:include 清单用正斜杠书写,但 path.normalize 在 Windows 会把
|
|
399
|
-
// 正斜杠折成反斜杠,导致与 srcRel(path.relative 在 Windows 同样出反斜杠)
|
|
400
|
-
// 的对比永远落空、整棵 src 子树被 skip。这里只把反斜杠归正、剥 ./ 前缀,
|
|
401
|
-
// 不引入平台分隔符(修复:Windows 下 init 不拷贝 src 树)。
|
|
402
|
-
const p = raw.replace(/\\/g, "/").replace(/^(\.\/)+/, "");
|
|
403
|
-
out.add(p);
|
|
404
|
-
if (!fs.existsSync(path.join(templatesDir, p)))
|
|
405
|
-
throw new Error(`init.include.yaml 收录了不存在的路径:${p}(拼写错误或模板缺文件?)`);
|
|
406
|
-
}
|
|
407
|
-
return out;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
416
|
/**
|
|
411
417
|
* 从脚手架产出的 openapi.json 里删掉未进白名单的 api 模块端点。
|
|
412
418
|
*
|
package/src/market/publish.ts
CHANGED
|
@@ -17,6 +17,7 @@ import process from "node:process";
|
|
|
17
17
|
|
|
18
18
|
import { buildBundle } from "../bundle";
|
|
19
19
|
import { createDeterministicTgz } from "../deterministic-tar";
|
|
20
|
+
import { assertDefaultModulesUntouched } from "../scaffold-guard";
|
|
20
21
|
import { signCommand } from "../sign";
|
|
21
22
|
import { resolveCdnLayout } from "./cdn-layout";
|
|
22
23
|
import { buildFixturePackage, parseFixtureSpec } from "./fixture";
|
|
@@ -126,6 +127,8 @@ async function buildFixture(args: { fixture?: string, staging: string, cdnDir: s
|
|
|
126
127
|
|
|
127
128
|
/** `--real`:走既有 ojm build --bundle,产物搬到 CDN 目录(一次只发一份:多方案工程请显式指定) */
|
|
128
129
|
async function buildRealPackage(solutionRoot: string, staging: string, cdnDir: string): Promise<{ name: string, version: string, tgzPath: string, sha256: string, preExisted: boolean }> {
|
|
130
|
+
// 与 ojm build 同一道完整性门禁:发布链路同样不许携带被改过的默认模块
|
|
131
|
+
assertDefaultModulesUntouched(solutionRoot);
|
|
129
132
|
const builtList = await buildBundle(solutionRoot, { out: staging });
|
|
130
133
|
if (builtList.length !== 1) {
|
|
131
134
|
throw new Error(
|
package/src/new.ts
CHANGED
|
@@ -140,6 +140,8 @@ function solutionYaml(name: string, withApi: boolean | undefined): string {
|
|
|
140
140
|
"api:",
|
|
141
141
|
` module: ${name}`,
|
|
142
142
|
` prefix: /api/${name}`,
|
|
143
|
+
// ping 匿名可达必须在契约声明(fixture.ts 同形状);漏声明安装预检 anonymous_residual 拒装
|
|
144
|
+
` anonymous: ["${name}/ping"]`,
|
|
143
145
|
"",
|
|
144
146
|
].join("\n")
|
|
145
147
|
: "";
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 脚手架完整性门禁——内置模块自治契约。
|
|
3
|
+
*
|
|
4
|
+
* 契约(下游反馈 2026-11-06):`src/app` 内置模块与 `src/oj-market` 属上游自治,
|
|
5
|
+
* 下游不得本地修改;`ojm build` 前逐文件与**当前 cli 包内 templates** 比对
|
|
6
|
+
* (上游模板即基线,不另立可被下游手改的哈希 lock)。CLI 升级后工程里的旧版
|
|
7
|
+
* 默认模块与新模板必不一致 → fail-fast,必须先 `ojm init --yes` 同步再构建
|
|
8
|
+
* (「必须先同步上游」策略,用户已确认)。
|
|
9
|
+
*
|
|
10
|
+
* 语义边界:
|
|
11
|
+
* - 保护集 = init.include.yaml 白名单中保护根前缀条目展开的模板文件清单;
|
|
12
|
+
* demo-only 文件本就不在白名单,不参与比较,无误报;
|
|
13
|
+
* - 三类漂移均拦:modified(内容不一致,CRLF 归一防 autocrlf 误报)、
|
|
14
|
+
* missing(白名单文件缺失)、extra(保护根内模板没有的文件);
|
|
15
|
+
* - 门禁开关 = `src/.ojm-builtin.json` 存在(新脚手架 init 必写;缺失 =
|
|
16
|
+
* 旧工程/框架仓,退化现状不拦——与 builtin.ts 同哲学);
|
|
17
|
+
* - **不是安全边界**:删开关文件即退回旧行为;错误信息如实告知处置路径;
|
|
18
|
+
* - 仅约束打包链路(ojm build / build --bundle / market publish --real);
|
|
19
|
+
* dev 不拦,允许本地试验。
|
|
20
|
+
*
|
|
21
|
+
* 消费方:`cli.ts` build action(分支前,失败零产物)、`market/publish.ts`、
|
|
22
|
+
* `init.ts`(--yes 恢复复用保护根/白名单/清理面)。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
import yaml from "yaml";
|
|
29
|
+
import { builtinListPath } from "./builtin";
|
|
30
|
+
|
|
31
|
+
/** 上游自治的保护根(相对工程根,正斜杠)。init --yes 恢复范围与此严格一致。 */
|
|
32
|
+
export const PROTECTED_ROOTS = ["src/app", "src/oj-market"] as const;
|
|
33
|
+
|
|
34
|
+
/** rel 是否落在保护根内(接受平台分隔符输入,归一后比较) */
|
|
35
|
+
export function isProtectedRel(rel: string): boolean {
|
|
36
|
+
const p = rel.split(path.sep).join("/");
|
|
37
|
+
return PROTECTED_ROOTS.some(root => p === root || p.startsWith(`${root}/`));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** cli 包根(src 运行态与 dist 发布态各上跳一层即包根) */
|
|
41
|
+
export function cliRoot(): string {
|
|
42
|
+
return path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function templatesRoot(): string {
|
|
46
|
+
return path.join(cliRoot(), "templates");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 读取模板根 `init.include.yaml` 的 `include` 白名单,归一为相对模板根的路径集合。
|
|
51
|
+
* 清单随模板一起发布,缺失即安装包损坏——直接 fail-fast,不留「静默少拷」的
|
|
52
|
+
* 退化路径。条目指向不存在的路径同样 fail-fast:拼错一个目录名不会当场报错,
|
|
53
|
+
* 只会在用户工程里留下难以追溯的缺文件(旧 exclude 清单没有这类校验面)。
|
|
54
|
+
*/
|
|
55
|
+
export function readInitInclude(templatesDir: string): Set<string> {
|
|
56
|
+
const yamlPath = path.join(templatesDir, "init.include.yaml");
|
|
57
|
+
if (!fs.existsSync(yamlPath))
|
|
58
|
+
throw new Error(`模板缺少 init.include.yaml:${yamlPath}(安装包损坏?)`);
|
|
59
|
+
const doc = yaml.parse(fs.readFileSync(yamlPath, "utf-8")) as { include?: string[] } | null;
|
|
60
|
+
const out = new Set<string>();
|
|
61
|
+
for (const raw of doc?.include ?? []) {
|
|
62
|
+
// 跨平台:include 清单用正斜杠书写,但 path.normalize 在 Windows 会把
|
|
63
|
+
// 正斜杠折成反斜杠,导致与 srcRel(path.relative 在 Windows 同样出反斜杠)
|
|
64
|
+
// 的对比永远落空、整棵 src 子树被 skip。这里只把反斜杠归正、剥 ./ 前缀,
|
|
65
|
+
// 不引入平台分隔符(修复:Windows 下 init 不拷贝 src 树)。
|
|
66
|
+
const p = raw.replace(/\\/g, "/").replace(/^(\.\/)+/, "");
|
|
67
|
+
out.add(p);
|
|
68
|
+
if (!fs.existsSync(path.join(templatesDir, p)))
|
|
69
|
+
throw new Error(`init.include.yaml 收录了不存在的路径:${p}(拼写错误或模板缺文件?)`);
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ScaffoldDrift {
|
|
75
|
+
modified: string[]
|
|
76
|
+
missing: string[]
|
|
77
|
+
extra: string[]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 门禁开关:工程声明了内置模块概念(`src/.ojm-builtin.json` 存在) */
|
|
81
|
+
export function guardEnabled(projectRoot: string): boolean {
|
|
82
|
+
return fs.existsSync(builtinListPath(projectRoot));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 白名单中保护根条目展开成模板文件相对路径清单(正斜杠、去重、排序) */
|
|
86
|
+
export function protectedTemplateFiles(templatesDir: string, include: Set<string>): string[] {
|
|
87
|
+
const files = new Set<string>();
|
|
88
|
+
const walk = (abs: string): void => {
|
|
89
|
+
for (const entry of fs.readdirSync(abs, { withFileTypes: true })) {
|
|
90
|
+
const p = path.join(abs, entry.name);
|
|
91
|
+
if (entry.isDirectory())
|
|
92
|
+
walk(p);
|
|
93
|
+
else
|
|
94
|
+
files.add(path.relative(templatesDir, p).split(path.sep).join("/"));
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
for (const rel of include) {
|
|
98
|
+
if (!isProtectedRel(rel))
|
|
99
|
+
continue;
|
|
100
|
+
const abs = path.join(templatesDir, rel);
|
|
101
|
+
if (fs.statSync(abs).isDirectory())
|
|
102
|
+
walk(abs);
|
|
103
|
+
else
|
|
104
|
+
files.add(rel);
|
|
105
|
+
}
|
|
106
|
+
return [...files].sort();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const normalizeText = (s: string): string => s.replaceAll("\r\n", "\n");
|
|
110
|
+
|
|
111
|
+
function walkProjectFiles(projectRoot: string, root: string, visit: (rel: string, abs: string) => void): void {
|
|
112
|
+
const absRoot = path.join(projectRoot, root);
|
|
113
|
+
if (!fs.existsSync(absRoot))
|
|
114
|
+
return;
|
|
115
|
+
const walk = (abs: string): void => {
|
|
116
|
+
for (const entry of fs.readdirSync(abs, { withFileTypes: true })) {
|
|
117
|
+
const p = path.join(abs, entry.name);
|
|
118
|
+
if (entry.isDirectory())
|
|
119
|
+
walk(p);
|
|
120
|
+
else
|
|
121
|
+
visit(path.relative(projectRoot, p).split(path.sep).join("/"), p);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
walk(absRoot);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 收集工程默认模块相对当前 cli 模板的漂移。
|
|
129
|
+
* 门禁未启用(无 `.ojm-builtin.json`)→ null;已启用 → 三类清单(可为空)。
|
|
130
|
+
*/
|
|
131
|
+
export function collectDefaultModuleDrift(projectRoot: string): ScaffoldDrift | null {
|
|
132
|
+
if (!guardEnabled(projectRoot))
|
|
133
|
+
return null;
|
|
134
|
+
const templatesDir = templatesRoot();
|
|
135
|
+
const expected = protectedTemplateFiles(templatesDir, readInitInclude(templatesDir));
|
|
136
|
+
const expectedSet = new Set(expected);
|
|
137
|
+
const drift: ScaffoldDrift = { modified: [], missing: [], extra: [] };
|
|
138
|
+
for (const rel of expected) {
|
|
139
|
+
const projectFile = path.join(projectRoot, rel);
|
|
140
|
+
if (!fs.existsSync(projectFile)) {
|
|
141
|
+
drift.missing.push(rel);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (normalizeText(fs.readFileSync(projectFile, "utf-8")) !== normalizeText(fs.readFileSync(path.join(templatesDir, rel), "utf-8")))
|
|
145
|
+
drift.modified.push(rel);
|
|
146
|
+
}
|
|
147
|
+
for (const root of PROTECTED_ROOTS) {
|
|
148
|
+
walkProjectFiles(projectRoot, root, (rel) => {
|
|
149
|
+
if (!expectedSet.has(rel))
|
|
150
|
+
drift.extra.push(rel);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
drift.extra.sort();
|
|
154
|
+
return drift;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 门禁入口:有漂移即抛(错误信息点名文件 + 上游自治说明 + `ojm init --yes` 处置)。
|
|
159
|
+
* 调用方保证在任何产物写入之前调用(失败零产物)。
|
|
160
|
+
*/
|
|
161
|
+
export function assertDefaultModulesUntouched(projectRoot: string): void {
|
|
162
|
+
const drift = collectDefaultModuleDrift(projectRoot);
|
|
163
|
+
if (!drift)
|
|
164
|
+
return;
|
|
165
|
+
const sections: string[] = [];
|
|
166
|
+
const section = (label: string, items: string[]): void => {
|
|
167
|
+
if (items.length > 0)
|
|
168
|
+
sections.push(`${label}(${items.length} 个文件):\n${items.map(f => ` - ${f}`).join("\n")}`);
|
|
169
|
+
};
|
|
170
|
+
section("内容被修改", drift.modified);
|
|
171
|
+
section("缺失", drift.missing);
|
|
172
|
+
section("多出(模板没有)", drift.extra);
|
|
173
|
+
if (sections.length === 0)
|
|
174
|
+
return;
|
|
175
|
+
throw new Error([
|
|
176
|
+
"[ojm] 框架默认模块与上游模板不一致,已拒绝构建(未写入任何产物)。",
|
|
177
|
+
"",
|
|
178
|
+
...sections,
|
|
179
|
+
"",
|
|
180
|
+
"框架默认模块(src/app 内置模块、src/oj-market)由上游自治:本地修改会在下次同步时丢失,",
|
|
181
|
+
"功能需求请向上游仓库提交 issue,不要在下游直接改这些目录。",
|
|
182
|
+
"处置:",
|
|
183
|
+
" - 确认放弃本地修改并恢复上游版本:ojm init --yes",
|
|
184
|
+
" (仅覆盖/清理 src/app 与 src/oj-market,会丢弃其中的本地改动,执行前请备份;",
|
|
185
|
+
" 下游自定义模块与工程配置不受影响)。",
|
|
186
|
+
].join("\n"));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* `ojm init --yes` 恢复的清理面:删除保护根内模板没有的文件(含遗留空目录)。
|
|
191
|
+
* 返回删除清单(正斜杠相对路径),供 init report 展示。
|
|
192
|
+
*/
|
|
193
|
+
export function removeExtraProtectedFiles(projectRoot: string): string[] {
|
|
194
|
+
const templatesDir = templatesRoot();
|
|
195
|
+
const expected = new Set(protectedTemplateFiles(templatesDir, readInitInclude(templatesDir)));
|
|
196
|
+
const removed: string[] = [];
|
|
197
|
+
for (const root of PROTECTED_ROOTS) {
|
|
198
|
+
walkProjectFiles(projectRoot, root, (rel, abs) => {
|
|
199
|
+
if (!expected.has(rel)) {
|
|
200
|
+
fs.rmSync(abs);
|
|
201
|
+
removed.push(rel);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
const absRoot = path.join(projectRoot, root);
|
|
205
|
+
if (fs.existsSync(absRoot))
|
|
206
|
+
pruneEmptyDirs(absRoot);
|
|
207
|
+
}
|
|
208
|
+
return removed.sort();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 递归删除空目录(自底向上);根自身保留,返回其是否为空 */
|
|
212
|
+
function pruneEmptyDirs(dir: string): boolean {
|
|
213
|
+
let empty = true;
|
|
214
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
215
|
+
const p = path.join(dir, entry.name);
|
|
216
|
+
if (entry.isDirectory()) {
|
|
217
|
+
if (pruneEmptyDirs(p))
|
|
218
|
+
fs.rmdirSync(p);
|
|
219
|
+
else
|
|
220
|
+
empty = false;
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
empty = false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return empty;
|
|
227
|
+
}
|
package/src/usage.ts
CHANGED
|
@@ -77,11 +77,11 @@ export const HELP_GROUPS: HelpGroup[] = [
|
|
|
77
77
|
zh: "前后端一体化工程脚手架(幂等补缺)",
|
|
78
78
|
en: "Full-stack project scaffolding (idempotent, fills gaps only)",
|
|
79
79
|
options: [
|
|
80
|
-
{ raw: "--yes", zh: "
|
|
80
|
+
{ raw: "--yes", zh: "非交互:跳过非空确认,并把框架自带模块(src/app、src/oj-market)恢复为当前版本(丢弃其中的本地修改)", en: "Non-interactive: skip the non-empty check and restore built-in modules (src/app, src/oj-market) to upstream (local edits there are discarded)" },
|
|
81
81
|
],
|
|
82
82
|
detail: {
|
|
83
|
-
zh: "ojm init [dir] [--yes]\n
|
|
84
|
-
en: "ojm init [dir] [--yes]\n Full-stack project scaffolding (
|
|
83
|
+
zh: "ojm init [dir] [--yes]\n 前后端一体化工程脚手架(重跑只补缺失文件;已有文件永不覆盖)\n --yes 恢复模式:覆盖 src/app 与 src/oj-market 为当前 CLI 携带的上游版本,\n 并删除这些目录内多出的文件(会丢弃其中的本地修改,先备份;\n 下游自定义模块与工程配置不受影响)。CLI 升级后构建前需先跑一次。",
|
|
84
|
+
en: "ojm init [dir] [--yes]\n Full-stack project scaffolding (re-runs only fill missing files; existing files are never overwritten)\n --yes restore mode: overwrites src/app & src/oj-market with the upstream version\n carried by this CLI and removes extra files there (local edits in those two\n directories are discarded — back up first; your own modules and project config\n are untouched). Run once after a CLI upgrade before building.",
|
|
85
85
|
},
|
|
86
86
|
},
|
|
87
87
|
{
|
|
@@ -135,8 +135,8 @@ export const HELP_GROUPS: HelpGroup[] = [
|
|
|
135
135
|
{ raw: "--out <dir>", zh: "--bundle 输出目录(缺省 dist/)", en: "Output directory for --bundle (default dist/)" },
|
|
136
136
|
],
|
|
137
137
|
detail: {
|
|
138
|
-
zh: "ojm build [--bundle [name]] [--out <dir>]\n 构建后端(oj build)与模块产物(含全站合并)\n --bundle 产出 dist/<name>-<version>.tgz + sha256
|
|
139
|
-
en: "ojm build [--bundle [name]] [--out <dir>]\n Builds the backend (oj build) and module artifacts (including the site-wide merge)\n --bundle produces dist/<name>-<version>.tgz + sha256 (upstream artifact for the marketplace installer)",
|
|
138
|
+
zh: "ojm build [--bundle [name]] [--out <dir>]\n 构建后端(oj build)与模块产物(含全站合并)\n --bundle 产出 dist/<name>-<version>.tgz + sha256(市场安装器的上游产物)\n 构建前校验框架自带模块(src/app、src/oj-market)未被本地修改——\n 与上游模板不一致即拒绝构建(内置模块自治契约,见 src/README.md)",
|
|
139
|
+
en: "ojm build [--bundle [name]] [--out <dir>]\n Builds the backend (oj build) and module artifacts (including the site-wide merge)\n --bundle produces dist/<name>-<version>.tgz + sha256 (upstream artifact for the marketplace installer)\n Before building, verifies the built-in modules (src/app, src/oj-market) are unmodified —\n any drift from the upstream templates rejects the build (built-in module autonomy, see src/README.md)",
|
|
140
140
|
},
|
|
141
141
|
},
|
|
142
142
|
{
|