@oj-module/cli 0.2.20 → 0.2.25
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/manual.md +100 -12
- package/devkit/module-dev-and-cdn-playground.md +10 -10
- package/package.json +4 -4
- package/shell-dist/assets/runtime.js +61 -35
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/build-backend.test.ts +13 -13
- package/src/__tests__/cli.test.ts +225 -0
- package/src/__tests__/init-packagejson.test.ts +29 -0
- package/src/__tests__/layout.test.ts +8 -5
- package/src/__tests__/new-scaffold.test.ts +59 -0
- package/src/__tests__/usage.test.ts +45 -47
- package/src/build.ts +4 -4
- package/src/builtin.ts +93 -0
- package/src/bundle.ts +237 -33
- package/src/cli.ts +265 -0
- package/src/contract/__tests__/market-client.integration.test.ts +12 -12
- package/src/contract/check.ts +4 -4
- package/src/dev.ts +75 -2
- package/src/index.ts +31 -191
- package/src/info.ts +49 -7
- package/src/init.ts +25 -11
- package/src/keygen.ts +0 -19
- package/src/layout.ts +11 -11
- package/src/lifecycle/__tests__/authoring.test.ts +3 -14
- package/src/lifecycle/__tests__/builtin.test.ts +97 -0
- package/src/lifecycle/__tests__/bundle.test.ts +188 -25
- package/src/lifecycle/__tests__/install.test.ts +0 -23
- package/src/lifecycle/__tests__/new.test.ts +129 -0
- package/src/lifecycle/__tests__/smoke.test.ts +4 -3
- package/src/manifest-contract.ts +16 -3
- package/src/market/__tests__/bootstrap.test.ts +24 -0
- package/src/market/__tests__/setup.test.ts +8 -1
- package/src/market/bootstrap.ts +10 -8
- package/src/market/cdn-layout.ts +3 -3
- package/src/market/command.ts +0 -23
- package/src/market/publish.ts +10 -2
- package/src/market/setup.ts +14 -2
- package/src/market/trial.ts +3 -3
- package/src/market/walkthrough.ts +7 -6
- package/src/new.ts +226 -0
- package/src/preview-config.ts +2 -2
- package/src/preview.ts +5 -4
- package/src/publish.ts +0 -22
- package/src/sign.ts +0 -22
- package/src/usage.ts +482 -198
- package/templates/README.md +3 -3
- package/templates/cdn/README.md +6 -6
- package/templates/config.yaml +5 -4
- package/templates/init.include.yaml +1 -1
- package/templates/package.json +3 -1
- package/templates/seed.mysql.sql +2 -1
- package/templates/seed.pg.sql +2 -1
- package/templates/src/_platform/api/fixtures/l1-test-user.sql +4 -5
- package/templates/src/_platform/api/market/api.ts +11 -8
- package/templates/src/_platform/api/seed.mysql.sql +11 -3
- package/templates/src/_platform/api/seed.pg.sql +11 -3
- package/templates/src/_platform/api/seed.sql +18 -6
- package/templates/src/_platform/api/solutions/api.ts +2 -2
- package/templates/src/oj-market/web/api/context.ts +2 -2
- package/templates/src/oj-market/web/components/InstallDialog.tsx +13 -6
- package/templates/src/oj-market/web/components/RollbackDialog.tsx +7 -3
- package/templates/src/oj-market/web/components/UninstallConfirm.tsx +18 -7
- package/templates/src/oj-market/web/hooks/useMarket.ts +30 -18
- package/templates/src/system/web/locales/en-US.json +1 -1
- package/templates/src/system/web/locales/zh-CN.json +1 -1
- package/templates/src/system/web/pages/users/roles.tsx +3 -1
- package/templates/tests/l1-builder.test.ts +2 -2
- package/templates/tests/lifecycle/market-api.spec.ts +35 -5
- package/templates/tests/lifecycle/verify-checks.spec.ts +1 -1
- package/templates/tests/lifecycle/verify-report.spec.ts +1 -1
- package/templates/tests/rbac-seed.spec.ts +12 -10
- package/templates/tests/seed-role-accounts.spec.ts +87 -0
- package/vendor/host-versions.json +3 -3
- package/src/args.ts +0 -122
package/src/builtin.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 脚手架自带模块身份:`src/.ojm-builtin.json`。
|
|
3
|
+
*
|
|
4
|
+
* 名单派生自 init 白名单(`init.include.yaml` 的 `src/<name>/(api|web)` 条目)——
|
|
5
|
+
* 单一真源是白名单,本模块只是它的读取/派生面,不在别处另立名单常量(防双写漂移)。
|
|
6
|
+
*
|
|
7
|
+
* 语义边界(评审钉死):
|
|
8
|
+
* - 只是打包排除 / UI 折叠提示,**不是安全边界**——文件只活在源工程、永不入包,
|
|
9
|
+
* 安装侧信任链(签名 + 信任库 + TOFU,fail-closed)与它无关;
|
|
10
|
+
* - 文件缺失 → 空名单(旧工程/框架仓没有内置概念,行为退化为现状);
|
|
11
|
+
* - 坏 JSON / modules 非字符串数组 → fail-fast(与「坏清单出不了包」同哲学,
|
|
12
|
+
* 缺失是故意的,坏文件是事故);
|
|
13
|
+
* - 用户对该文件有完全控制权:删名 = 该模块恢复参与打包/显示,删文件 = 全部退化。
|
|
14
|
+
*
|
|
15
|
+
* 消费方:`ojm build --bundle`(排除)、`ojm info`(折叠)、`ojm new`(拒名)、
|
|
16
|
+
* `ojm init`(生成/回填)。历史布局(new/flat)工程不适用(无 src/ 模块树概念),
|
|
17
|
+
* 调用方自行判布局,本模块不感知 layout。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
|
|
23
|
+
export const BUILTIN_LIST_FILE = ".ojm-builtin.json";
|
|
24
|
+
|
|
25
|
+
export function builtinListPath(projectRoot: string): string {
|
|
26
|
+
return path.join(projectRoot, "src", BUILTIN_LIST_FILE);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 读取自带模块名单。
|
|
31
|
+
* 文件缺失 → 空集;坏文件 → fail-fast(报路径与错因)。
|
|
32
|
+
*/
|
|
33
|
+
export function readBuiltinModules(projectRoot: string): Set<string> {
|
|
34
|
+
const p = builtinListPath(projectRoot);
|
|
35
|
+
if (!fs.existsSync(p))
|
|
36
|
+
return new Set();
|
|
37
|
+
let doc: unknown;
|
|
38
|
+
try {
|
|
39
|
+
doc = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`[ojm] ${p} 解析失败(${error instanceof Error ? error.message : String(error)})。`
|
|
44
|
+
+ "该文件由 ojm init 生成、可手改/删除,但必须是合法 JSON"
|
|
45
|
+
+ "({ \"modules\": [\"<模块名>\", …] })。",
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const modules = (doc as { modules?: unknown })?.modules;
|
|
49
|
+
if (!Array.isArray(modules) || modules.some(m => typeof m !== "string")) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`[ojm] ${p} 的 modules 必须是字符串数组,`
|
|
52
|
+
+ `实际为 ${Array.isArray(modules) ? `含非字符串项的数组(${JSON.stringify(modules)})` : typeof modules
|
|
53
|
+
}。修复或删除该文件后重试。`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return new Set(modules);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 从 init.include.yaml 的 include 集合派生自带模块名:`src/<name>/(api|web)` 条目的
|
|
61
|
+
* `<name>` 段(整目录收录 `src/<name>/api` 与逐文件收录 `src/<name>/web/...` 都命中)。
|
|
62
|
+
* 与 `pruneUnincludedApiPaths` 的派生同族,扩 web 侧覆盖 web-only 模块(login/oj-market)。
|
|
63
|
+
*/
|
|
64
|
+
export function deriveBuiltinModules(include: Iterable<string>): Set<string> {
|
|
65
|
+
const mods = new Set<string>();
|
|
66
|
+
for (const p of include) {
|
|
67
|
+
const m = /^src\/([^/]+)\/(?:api|web)(?:\/|$)/.exec(p)?.[1];
|
|
68
|
+
if (m)
|
|
69
|
+
mods.add(m);
|
|
70
|
+
}
|
|
71
|
+
return mods;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* init 接线:生成/回填 `src/.ojm-builtin.json`(copyIfAbsent 语义)。
|
|
76
|
+
* 已存在 → 原样不动(用户手改权);派生集为空 → 不写。
|
|
77
|
+
* @returns "created" | "skipped" | "none"
|
|
78
|
+
*/
|
|
79
|
+
export function writeBuiltinList(projectRoot: string, include: Iterable<string>): "created" | "skipped" | "none" {
|
|
80
|
+
const target = builtinListPath(projectRoot);
|
|
81
|
+
if (fs.existsSync(target))
|
|
82
|
+
return "skipped";
|
|
83
|
+
const modules = [...deriveBuiltinModules(include)].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
84
|
+
if (modules.length === 0)
|
|
85
|
+
return "none";
|
|
86
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
87
|
+
const doc = {
|
|
88
|
+
"//": "由 ojm init 生成(名单派生自模板白名单)。可手改/删除:删名 = 该模块恢复参与打包与 ojm info 显示;删整个文件 = 退化为无内置概念。",
|
|
89
|
+
modules,
|
|
90
|
+
};
|
|
91
|
+
fs.writeFileSync(target, `${JSON.stringify(doc, null, "\t")}\n`);
|
|
92
|
+
return "created";
|
|
93
|
+
}
|
package/src/bundle.ts
CHANGED
|
@@ -1,9 +1,30 @@
|
|
|
1
|
-
// 方案包构建(P1-11,`ojm build --bundle`):
|
|
1
|
+
// 方案包构建(P1-11,`ojm build --bundle`):bundle 级契约校验(fail-fast 先于打包)
|
|
2
2
|
// → 组装 tgz 内布局(契约 §2.9)→ 确定性打包(C2-32)+ sha256。
|
|
3
|
+
// bundle 级契约文件名:解决方案优先 `solution.yaml`;旧方案回退 `manifest.yaml`。
|
|
4
|
+
// 之所以用 solution.yaml 而非根目录 manifest.yaml:oj 按 `manifest.yaml` 枚举发现 api 后端模块
|
|
5
|
+
// (模块级 `src/<name>/api/manifest.yaml`),根目录再放一份 bundle 契约会污染后端的「搜索枚举」,
|
|
6
|
+
// 让 oj 误把 bundle 契约当 api 模块——故用独立文件名隔开两条枚举(避免后端搜索枚举与 bundle 冲突)。
|
|
7
|
+
//
|
|
8
|
+
// 多方案(2026-10-22,docs/plans/2026-10-22-1640-multi-solution-bundle-design.md):
|
|
9
|
+
// 一个脚手架可有多份用户方案,各自一份模块级契约。契约解析优先级矩阵:
|
|
10
|
+
// --bundle <name> → src/<name>/solution.yaml(无则 manifest.yaml 回退);
|
|
11
|
+
// 未命中再比对工程根契约的 name === <name>;都不中 → 报错。
|
|
12
|
+
// 内置名拒绝(第一方请在框架仓模板工程打包——那里无 builtin 清单)。
|
|
13
|
+
// 无参 + 工程根契约 → 只打根契约,src 契约被跳过打 hint。根 solution.yaml 场景与旧行为
|
|
14
|
+
// 逐字节一致;根 manifest.yaml 与 src 模块契约并存时旧版取 src 契约
|
|
15
|
+
// (旧优先级 ②>③)、现取根契约——多方案矩阵定版「根压 src」(B11)。
|
|
16
|
+
// 无参 + 无根契约 → 收集全部非内置模块级契约逐个打包;
|
|
17
|
+
// 零份用户契约 → 友好报错指向 `ojm new`
|
|
18
|
+
// 模块级契约强校验 name === 目录名(B8;跨契约查重因 B8 不可达已不实现,见计划文档 B7 决策)。
|
|
19
|
+
// 两段式:全部校验(纯读零写盘)先于全部组装——校验失败 dist/ 零增量,
|
|
20
|
+
// 发布者不会把半截批次扫进 CDN 索引(build-index 全量收目录)。
|
|
21
|
+
//
|
|
3
22
|
// 组装映射(对齐 P1-9 staging copy 源一层定版,unpack 布局即安装器 extract 段消费面):
|
|
4
|
-
// <project>/
|
|
5
|
-
// <project>/
|
|
6
|
-
//
|
|
23
|
+
// <project>/solution.yaml | manifest.yaml → 工程根契约(旧单方案写法)
|
|
24
|
+
// <project>/src/<模块>/solution.yaml | manifest.yaml → 模块级契约(绝不进 <模块>/api/,§1.2 红线)
|
|
25
|
+
// 任一处均 → <name>-<version>/manifest.yaml(Form A;tgz 内名固定为 manifest.yaml)
|
|
26
|
+
// <project>/dist/api/<name>-<version>/ → <name>-<version>/api/<name>-<version>/(oj build 产物原样,C2-31 分层核对)
|
|
27
|
+
// <project>/dist/web/modules/<name>/ → <name>-<version>/web/modules/<name>/(只入包方案自己的模块;
|
|
7
28
|
// web.entry/css 存在性复核)
|
|
8
29
|
// 红线:modules.json 禁入包(C2-31——该清单由安装器在目标站点 upsert,防覆盖宿主清单);
|
|
9
30
|
// 文件面契约(tables↔schema.yaml、locales 双语齐备)构建期 fail-fast(契约 §4.1 构建列)。
|
|
@@ -13,9 +34,10 @@ import path from "node:path";
|
|
|
13
34
|
|
|
14
35
|
import { parse } from "yaml";
|
|
15
36
|
import { readModuleDefinition } from "./build";
|
|
37
|
+
import { readBuiltinModules } from "./builtin";
|
|
16
38
|
import { createDeterministicTgz } from "./deterministic-tar";
|
|
17
39
|
import { apiSrcOf, resolveLayout, webSrcOf } from "./layout";
|
|
18
|
-
import { checkLocalesComplete, checkTablesAgainstSchema, parseManifest, referencedI18nKeys, validateContract } from "./manifest-contract";
|
|
40
|
+
import { assertValidSolutionName, checkLocalesComplete, checkTablesAgainstSchema, NAME_RE, parseManifest, referencedI18nKeys, validateContract } from "./manifest-contract";
|
|
19
41
|
import { deriveManifestRoutes, reconcileManifestRoutes } from "./manifest-routes-derive";
|
|
20
42
|
|
|
21
43
|
export interface BundleBuildResult {
|
|
@@ -26,29 +48,168 @@ export interface BundleBuildResult {
|
|
|
26
48
|
}
|
|
27
49
|
|
|
28
50
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
51
|
+
* 方案名正则(`--bundle <name>` / `ojm new <name>` 入口校验共用,DRY):
|
|
52
|
+
* 与契约 C2-01 同一条(小写字母开头 kebab-case,≤64)——入口先校验,
|
|
53
|
+
* 契约校验再做正式判罚;挡住 ../ 路径穿越与引号/换行注入。
|
|
54
|
+
* 校验函数本体在 manifest-contract.ts(new 入口不拖入打包模块图),此处 re-export 兼容既有引用。
|
|
33
55
|
*/
|
|
34
|
-
export
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
56
|
+
export const SOLUTION_NAME_RE = NAME_RE;
|
|
57
|
+
export { assertValidSolutionName } from "./manifest-contract";
|
|
58
|
+
|
|
59
|
+
/** 打包目标:一份契约 = 一个方案包 */
|
|
60
|
+
interface BundleTarget {
|
|
61
|
+
/** 契约文件路径(solution.yaml 或 manifest.yaml) */
|
|
62
|
+
contractPath: string
|
|
63
|
+
/** 模块级契约所在 src/<name> 目录名(工程根契约为 undefined) */
|
|
64
|
+
moduleDir?: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 收集模块级契约(`src/<name>/` 根下,solution.yaml 优先于 manifest.yaml 回退;
|
|
69
|
+
* 绝不进 `<模块>/api/`——与 oj 的 api 枚举撞车红线,§1.2)。按目录名排序保证确定性。
|
|
70
|
+
*/
|
|
71
|
+
function collectModuleContracts(projectRoot: string): BundleTarget[] {
|
|
72
|
+
const srcDir = path.join(projectRoot, "src");
|
|
73
|
+
if (!fs.existsSync(srcDir))
|
|
74
|
+
return [];
|
|
75
|
+
const out: BundleTarget[] = [];
|
|
76
|
+
for (const e of fs.readdirSync(srcDir, { withFileTypes: true })) {
|
|
77
|
+
if (!e.isDirectory() || e.name.startsWith("."))
|
|
78
|
+
continue;
|
|
79
|
+
const dir = path.join(srcDir, e.name);
|
|
80
|
+
const sol = path.join(dir, "solution.yaml");
|
|
81
|
+
const man = path.join(dir, "manifest.yaml");
|
|
82
|
+
if (fs.existsSync(sol))
|
|
83
|
+
out.push({ contractPath: sol, moduleDir: e.name });
|
|
84
|
+
else if (fs.existsSync(man))
|
|
85
|
+
out.push({ contractPath: man, moduleDir: e.name });
|
|
86
|
+
}
|
|
87
|
+
out.sort((a, b) => (a.moduleDir! < b.moduleDir! ? -1 : a.moduleDir! > b.moduleDir! ? 1 : 0));
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 读契约 name 用于优先级矩阵的快速比对(坏契约不在此处报错——planBundle 会给正式错误) */
|
|
92
|
+
function peekContractName(contractPath: string): string | undefined {
|
|
93
|
+
try {
|
|
94
|
+
const parsed = parseManifest(fs.readFileSync(contractPath, "utf8"));
|
|
95
|
+
return parsed.ok ? (parsed.manifest.name as string) : undefined;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 契约解析复数化(优先级矩阵见文件头注释)。
|
|
104
|
+
* 内置排除只是便利机制不是安全边界:伪造/删除名单的最坏后果在打包侧,
|
|
105
|
+
* 安装侧信任链(签名 + 信任库,fail-closed)与它无关。
|
|
106
|
+
*/
|
|
107
|
+
function resolveContractTargets(projectRoot: string, name?: string): BundleTarget[] {
|
|
108
|
+
const builtin = readBuiltinModules(projectRoot);
|
|
109
|
+
const moduleContracts = collectModuleContracts(projectRoot);
|
|
110
|
+
const rootSolution = path.join(projectRoot, "solution.yaml");
|
|
111
|
+
const rootManifest = path.join(projectRoot, "manifest.yaml");
|
|
112
|
+
const rootContract = fs.existsSync(rootSolution) ? rootSolution : fs.existsSync(rootManifest) ? rootManifest : undefined;
|
|
113
|
+
|
|
114
|
+
// --bundle <name>:先按目录名选模块级契约,再比对根契约 name
|
|
115
|
+
if (name !== undefined) {
|
|
116
|
+
assertValidSolutionName(name, "--bundle <name>");
|
|
117
|
+
const hit = moduleContracts.find(c => c.moduleDir === name);
|
|
118
|
+
if (hit) {
|
|
119
|
+
if (builtin.has(name)) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`[ojm] 「${name}」是框架自带方案,不参与用户打包。`
|
|
122
|
+
+ "第一方发布请在框架仓模板工程打包(模板工程无内置名单,走全量打包路径)。",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
return [hit];
|
|
126
|
+
}
|
|
127
|
+
if (rootContract) {
|
|
128
|
+
const rootName = peekContractName(rootContract);
|
|
129
|
+
if (rootName === undefined) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`[ojm] 方案包构建失败:工程根契约 ${path.basename(rootContract)} 存在但无法解析`
|
|
132
|
+
+ "(name 缺失或文件损坏)——请先修复它,或显式给出 src/<name>/solution.yaml。",
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
if (rootName === name) {
|
|
136
|
+
if (builtin.has(name)) {
|
|
137
|
+
console.warn(
|
|
138
|
+
`[ojm] ⚠️ 工程根契约 name「${name}」与框架自带方案同名——`
|
|
139
|
+
+ "确认你要分发的是自有方案而非自带模块的构建产物。",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return [{ contractPath: rootContract }];
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
throw new Error(
|
|
146
|
+
`[ojm] 方案包构建失败:未找到「${name}」的契约`
|
|
147
|
+
+ `(期望 src/${name}/solution.yaml,或工程根契约 name === "${name}")。`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 无参 + 工程根契约:旧单方案行为零变化(根优先于一切模块级契约)
|
|
152
|
+
if (rootContract) {
|
|
153
|
+
if (moduleContracts.length > 0) {
|
|
154
|
+
console.log(
|
|
155
|
+
`[ojm] 工程根契约优先:跳过 src/ 下 ${moduleContracts.length} 份模块级契约`
|
|
156
|
+
+ "(用 ojm build --bundle <name> 单独打包其中一份)",
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
const rootName = peekContractName(rootContract);
|
|
160
|
+
if (rootName !== undefined && builtin.has(rootName)) {
|
|
161
|
+
console.warn(
|
|
162
|
+
`[ojm] ⚠️ 工程根契约 name「${rootName}」与框架自带方案同名——`
|
|
163
|
+
+ "确认你要分发的是自有方案而非自带模块的构建产物。",
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return [{ contractPath: rootContract }];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 无参 + 无根契约:全部非内置模块级契约
|
|
170
|
+
for (const c of moduleContracts.filter(c => builtin.has(c.moduleDir!)))
|
|
171
|
+
console.log(`[ojm] 跳过框架自带方案:${c.moduleDir}(不参与用户打包)`);
|
|
172
|
+
const userContracts = moduleContracts.filter(c => !builtin.has(c.moduleDir!));
|
|
173
|
+
if (userContracts.length === 0) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
"[ojm] 方案包构建失败:没有可打包的用户方案(脚手架自带方案不参与打包)。"
|
|
176
|
+
+ "用 ojm new <name> 创建方案后再打包。",
|
|
177
|
+
);
|
|
43
178
|
}
|
|
179
|
+
return userContracts;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 校验段产物:一份通过全部构建期校验、待组装的方案 */
|
|
183
|
+
interface BundlePlan {
|
|
184
|
+
manifest: Record<string, unknown>
|
|
185
|
+
contractPath: string
|
|
186
|
+
moduleDir?: string
|
|
187
|
+
name: string
|
|
188
|
+
version: string
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 校验段(纯读、零写盘):契约解析 → routes 派生 → 契约校验 → 文件面校验。
|
|
193
|
+
* 模块级契约强校验 name === 目录名(--bundle <name> 按目录名选契约,
|
|
194
|
+
* 产物定位/入口派生全用契约 name,两者不一致会打出名字与目录对不上的包)。
|
|
195
|
+
*/
|
|
196
|
+
async function planBundle(projectRoot: string, layout: ProjectLayout, target: BundleTarget): Promise<BundlePlan> {
|
|
197
|
+
const parsed = parseManifest(fs.readFileSync(target.contractPath, "utf8"));
|
|
198
|
+
if (!parsed.ok)
|
|
199
|
+
throw new Error(`[ojm] 方案包构建失败:${parsed.error}`);
|
|
44
200
|
const m = parsed.manifest;
|
|
45
201
|
const name = m.name as string;
|
|
46
202
|
|
|
203
|
+
if (target.moduleDir !== undefined && name !== target.moduleDir) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`[ojm] 方案包构建失败:${target.contractPath} 的 name「${name}」与目录名「${target.moduleDir}」不一致`
|
|
206
|
+
+ "(module-root 下契约与模块同位同名)。",
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
47
210
|
// R5:manifest.routes 由 entry.ts 路由 handle 自动派生(单一真源),覆盖手工书写。
|
|
48
211
|
// 仅当 entry.ts 声明了 category 路由才覆盖;否则保留 manifest 既有 routes(未迁移方案兼容)。
|
|
49
212
|
// entry 读不出来(如第三依赖未装)时软降级:沿用既有 routes,不阻断打包。
|
|
50
|
-
const layout = resolveLayout(projectRoot);
|
|
51
|
-
// 前端入口两侧布局都在 webSrcOf 里收口:module-root → src/<name>/web,历史 → web/src/<name>
|
|
52
213
|
const entryFile = path.join(webSrcOf(layout, name), "entry.ts");
|
|
53
214
|
if (fs.existsSync(entryFile)) {
|
|
54
215
|
try {
|
|
@@ -73,7 +234,7 @@ export async function buildBundle(projectRoot: string, opts: { out?: string } =
|
|
|
73
234
|
}
|
|
74
235
|
const version = m.version as string;
|
|
75
236
|
|
|
76
|
-
//
|
|
237
|
+
// 文件面契约(§4.1 构建列专属执行点;manifest 内在规则已在 validateContract 全量通过)
|
|
77
238
|
const fileViolations = [
|
|
78
239
|
...checkTables(m, layout, name),
|
|
79
240
|
...checkLocales(m, layout, name),
|
|
@@ -91,17 +252,29 @@ export async function buildBundle(projectRoot: string, opts: { out?: string } =
|
|
|
91
252
|
}
|
|
92
253
|
}
|
|
93
254
|
|
|
255
|
+
return { manifest: m, contractPath: target.contractPath, moduleDir: target.moduleDir, name, version };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* 组装段(写盘):staging 单根 <name>-<version>/ → 红线自检 → 确定性 tgz + sha256。
|
|
260
|
+
* 入包内容与 §11.1 一致:只组装本方案的模块产物(layout.apiDist/<name>-<version>/ 与
|
|
261
|
+
* layout.distDir/modules/<name>/),模板/宿主模块不随包。
|
|
262
|
+
*/
|
|
263
|
+
async function assembleBundle(projectRoot: string, layout: ProjectLayout, plan: BundlePlan, outDir: string): Promise<BundleBuildResult> {
|
|
264
|
+
const m = plan.manifest;
|
|
265
|
+
const name = plan.name;
|
|
266
|
+
const version = plan.version;
|
|
267
|
+
|
|
94
268
|
// 3) 组装 staging:<name>-<version>/ 单根
|
|
95
269
|
const staging = fs.mkdtempSync(path.join(projectRoot, ".ojm-bundle-"));
|
|
96
270
|
try {
|
|
97
271
|
const root = path.join(staging, `${name}-${version}`);
|
|
98
272
|
fs.mkdirSync(root);
|
|
99
|
-
fs.copyFileSync(
|
|
273
|
+
fs.copyFileSync(plan.contractPath, path.join(root, "manifest.yaml"));
|
|
100
274
|
|
|
101
|
-
// api:oj build 产物原样入包(编译编排不在此重跑——作者先 oj build
|
|
102
|
-
// 编排接线随 P1-15 方案脚手架定形)
|
|
275
|
+
// api:oj build 产物原样入包(编译编排不在此重跑——作者先 oj build,本命令只校验与组装)
|
|
103
276
|
if (m.api !== undefined) {
|
|
104
|
-
const built = path.join(
|
|
277
|
+
const built = path.join(layout.apiDist, `${name}-${version}`);
|
|
105
278
|
if (!fs.existsSync(built)) {
|
|
106
279
|
throw new Error(`[ojm] 方案包构建失败:缺少 oj build 产物 ${path.relative(projectRoot, built)}(manifest 声明了 api 段;先对 api/ 运行 oj build,产物原样入包)`);
|
|
107
280
|
}
|
|
@@ -113,18 +286,18 @@ export async function buildBundle(projectRoot: string, opts: { out?: string } =
|
|
|
113
286
|
}
|
|
114
287
|
|
|
115
288
|
// web:只入包方案自己的模块产物(web 段缺省 = 纯后端方案,整段跳过)。
|
|
116
|
-
// 为何不是整树:web/
|
|
289
|
+
// 为何不是整树:dist/web/modules 里除本方案模块外还有脚手架模板模块(home/login/
|
|
117
290
|
// personal-center/notification/system/oj-market…),而安装器 deployWeb 会把包内
|
|
118
291
|
// web/modules 整树覆盖进目标站 <dist>/modules——整树入包等于用开发机上的模板产物
|
|
119
292
|
// 覆盖目标站同名前端。方案包只该带自己那一份,模板模块属宿主面,不由方案分发。
|
|
120
293
|
if (m.web !== undefined) {
|
|
121
|
-
const webModules = path.join(
|
|
294
|
+
const webModules = path.join(layout.distDir, "modules");
|
|
122
295
|
if (!fs.existsSync(webModules)) {
|
|
123
|
-
throw new Error(
|
|
296
|
+
throw new Error(`[ojm] 方案包构建失败:缺少站点构建产物 ${path.relative(projectRoot, webModules)}(manifest 声明了 web 段;先运行 ojm build 生成模块产物)`);
|
|
124
297
|
}
|
|
125
298
|
const ownWeb = path.join(webModules, name);
|
|
126
299
|
if (!fs.existsSync(ownWeb)) {
|
|
127
|
-
throw new Error(`[ojm] 方案包构建失败:缺少本方案的站点产物
|
|
300
|
+
throw new Error(`[ojm] 方案包构建失败:缺少本方案的站点产物 ${path.relative(projectRoot, ownWeb)}/(方案包只入包方案自己的模块;请确认 web.config.ts 已登记 ${name} 且本次 ojm build 已产出)`);
|
|
128
301
|
}
|
|
129
302
|
fs.mkdirSync(path.join(root, "web", "modules"), { recursive: true });
|
|
130
303
|
fs.cpSync(ownWeb, path.join(root, "web", "modules", name), { recursive: true });
|
|
@@ -141,7 +314,6 @@ export async function buildBundle(projectRoot: string, opts: { out?: string } =
|
|
|
141
314
|
assertNoForbiddenMembers(root);
|
|
142
315
|
|
|
143
316
|
// 4) 确定性打包(C2-32;symlink 等成员守卫在打包原语内,C2-30)
|
|
144
|
-
const outDir = path.resolve(projectRoot, opts.out ?? "dist");
|
|
145
317
|
const tgzPath = path.join(outDir, `${name}-${version}.tgz`);
|
|
146
318
|
const sha256 = await createDeterministicTgz(staging, tgzPath);
|
|
147
319
|
const size = fs.statSync(tgzPath).size;
|
|
@@ -154,6 +326,38 @@ export async function buildBundle(projectRoot: string, opts: { out?: string } =
|
|
|
154
326
|
}
|
|
155
327
|
}
|
|
156
328
|
|
|
329
|
+
/**
|
|
330
|
+
* 从方案工程产出可安装 tgz + sha256(多方案:返回 N 份结果,与目标契约一一对应)。
|
|
331
|
+
* 两段式:planBundle 全部先跑完(纯读、零写盘),任一失败 dist/ 零增量;
|
|
332
|
+
* 组装段失败保留已完成产物并在报错中指名失败方案。
|
|
333
|
+
*/
|
|
334
|
+
export async function buildBundle(projectRoot: string, opts: { out?: string, name?: string } = {}): Promise<BundleBuildResult[]> {
|
|
335
|
+
const targets = resolveContractTargets(projectRoot, opts.name);
|
|
336
|
+
const layout = resolveLayout(projectRoot);
|
|
337
|
+
// 两段式:全部校验先跑完(纯读、零写盘),任一失败 dist/ 零增量——
|
|
338
|
+
// 发布者不会把半截批次扫进 CDN 索引(build-index 全量收目录)。
|
|
339
|
+
// name+version 撞车防护由 planBundle 的 name===目录名 强校验承担:
|
|
340
|
+
// 目录名在单一 src/ 层唯一 ⇒ 契约 name 唯一(评审 B7 与 B8 组合后撞车向量不可达)。
|
|
341
|
+
const plans = await Promise.all(targets.map(t => planBundle(projectRoot, layout, t)));
|
|
342
|
+
|
|
343
|
+
const outDir = path.resolve(projectRoot, opts.out ?? "dist");
|
|
344
|
+
const results: BundleBuildResult[] = [];
|
|
345
|
+
for (const plan of plans) {
|
|
346
|
+
try {
|
|
347
|
+
results.push(await assembleBundle(projectRoot, layout, plan, outDir));
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
const done = results.map(r => `${r.name}-${r.version}`).join(", ");
|
|
351
|
+
throw new Error(
|
|
352
|
+
`[ojm] 方案「${plan.name}」打包失败${done ? `(已完成:${done})` : ""}:${
|
|
353
|
+
error instanceof Error ? error.message : String(error)}`,
|
|
354
|
+
{ cause: error },
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return results;
|
|
359
|
+
}
|
|
360
|
+
|
|
157
361
|
/**
|
|
158
362
|
* C2-31 分层核对:模块级 manifest 仅 oj 原生键,且 name/version 与 bundle 级逐一相等
|
|
159
363
|
* (oj 启动强校验同源)。
|
|
@@ -214,8 +418,8 @@ function checkLocales(m: Record<string, unknown>, layout: ProjectLayout, name: s
|
|
|
214
418
|
return [...out, ...checkLocalesComplete(m, texts["zh-CN"] ?? "", texts["en-US"] ?? "")];
|
|
215
419
|
}
|
|
216
420
|
|
|
217
|
-
/** 私钥/凭证文件名红线(P2-4 入包自检):*.pem
|
|
218
|
-
const FORBIDDEN_MEMBER = /\.(?:pem|key)$|^\.env
|
|
421
|
+
/** 私钥/凭证文件名红线(P2-4 入包自检):*.pem/*.key/*.p12/*.pfx、.env 系(.env/.env.*)、id_ed25519|id_rsa */
|
|
422
|
+
const FORBIDDEN_MEMBER = /\.(?:pem|key|p12|pfx)$|^\.env(?:\.|$)|^id_(?:ed25519|rsa)$/;
|
|
219
423
|
|
|
220
424
|
/** staging 全树扫描:modules.json 禁入(C2-31 红线)+ 私钥/凭证禁入(P2-4 红线) */
|
|
221
425
|
function assertNoForbiddenMembers(root: string): void {
|
|
@@ -230,7 +434,7 @@ function assertNoForbiddenMembers(root: string): void {
|
|
|
230
434
|
throw new Error(`[ojm] 打包自检失败(C2-31):modules.json 禁入方案包(该清单由安装器在目标站点 upsert,防覆盖宿主清单)——位置:${p}`);
|
|
231
435
|
}
|
|
232
436
|
if (e.isFile() && FORBIDDEN_MEMBER.test(e.name)) {
|
|
233
|
-
throw new Error(`[ojm] 打包自检失败(P2-4 入包红线):私钥/凭证文件「${e.name}」禁入方案包(*.pem / *.key / .env / id_ed25519 / id_rsa)——签名私钥永不入包,签名经 detached .sig 侧车随包分发(包内只含产物与公钥)。位置:${p}`);
|
|
437
|
+
throw new Error(`[ojm] 打包自检失败(P2-4 入包红线):私钥/凭证文件「${e.name}」禁入方案包(*.pem / *.key / *.p12 / *.pfx / .env 系 / id_ed25519 / id_rsa)——签名私钥永不入包,签名经 detached .sig 侧车随包分发(包内只含产物与公钥)。位置:${p}`);
|
|
234
438
|
}
|
|
235
439
|
}
|
|
236
440
|
};
|