@oj-module/cli 0.2.20 → 0.2.23
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 +93 -7
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +59 -34
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/init-packagejson.test.ts +29 -0
- package/src/args.ts +54 -4
- package/src/builtin.ts +93 -0
- package/src/bundle.ts +230 -26
- package/src/contract/__tests__/market-client.integration.test.ts +12 -12
- package/src/dev.ts +58 -2
- package/src/index.ts +26 -6
- package/src/info.ts +49 -7
- package/src/init.ts +23 -7
- package/src/lifecycle/__tests__/args.test.ts +20 -0
- package/src/lifecycle/__tests__/builtin.test.ts +97 -0
- package/src/lifecycle/__tests__/bundle.test.ts +168 -5
- package/src/lifecycle/__tests__/new.test.ts +129 -0
- package/src/lifecycle/__tests__/smoke.test.ts +2 -1
- package/src/manifest-contract.ts +16 -3
- package/src/market/publish.ts +10 -2
- package/src/new.ts +208 -0
- package/src/usage.ts +18 -6
- package/templates/package.json +3 -1
- package/templates/src/_platform/api/fixtures/l1-test-user.sql +4 -5
- 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 +16 -5
- 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/tests/l1-builder.test.ts +2 -2
- 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/index.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
|
-
import { parseApiArgs, parseBuildArgs, parseInitArgs, parseLifecycleArgs, parseVendorArgs } from "./args";
|
|
4
|
+
import { parseApiArgs, parseBuildArgs, parseInitArgs, parseLifecycleArgs, parseNewArgs, parseVendorArgs } from "./args";
|
|
5
5
|
import { buildBackend, buildModules } from "./build";
|
|
6
6
|
import { buildBundle } from "./bundle";
|
|
7
7
|
import { devServer } from "./dev";
|
|
8
8
|
import { mergeManifests, printInfo } from "./info";
|
|
9
9
|
import { initProject } from "./init";
|
|
10
|
+
import { createSolution } from "./new";
|
|
10
11
|
import { runPreview } from "./preview";
|
|
11
12
|
import { getCommandHelp, resolveHelpRequest, usageText } from "./usage";
|
|
12
13
|
import { vendorCommand } from "./vendor";
|
|
@@ -50,10 +51,13 @@ async function main() {
|
|
|
50
51
|
|
|
51
52
|
switch (command) {
|
|
52
53
|
case "build": {
|
|
53
|
-
const { bundle, out } = parseBuildArgs(process.argv.slice(3));
|
|
54
|
+
const { bundle, bundleName, out } = parseBuildArgs(process.argv.slice(3));
|
|
54
55
|
if (bundle) {
|
|
55
56
|
// 方案包构建(P1-11):契约校验 fail-fast → 确定性 tgz + sha256(消费方:市场安装器)
|
|
56
|
-
|
|
57
|
+
// 多方案:无 bundleName 打全部用户方案(自带排除),有则只打 name 那一份
|
|
58
|
+
const results = await buildBundle(projectRoot, { out, name: bundleName });
|
|
59
|
+
if (results.length > 1)
|
|
60
|
+
console.log(`[ojm] 共打包 ${results.length} 份方案包(逐包签名:ojm sign dist/<name>-<version>.tgz --id <publisher>.<key> --check)`);
|
|
57
61
|
break;
|
|
58
62
|
}
|
|
59
63
|
// 设计 §5:后端 oj build(零 DB 副作用)→ 前端全站合并(仅 build 清场合并)
|
|
@@ -61,6 +65,19 @@ async function main() {
|
|
|
61
65
|
await buildModules(projectRoot, { mergeSite: true });
|
|
62
66
|
break;
|
|
63
67
|
}
|
|
68
|
+
case "new": {
|
|
69
|
+
const { name, layout, api } = parseNewArgs(process.argv.slice(3));
|
|
70
|
+
if (!name) {
|
|
71
|
+
console.error("[ojm] 用法:ojm new <name> [--layout container|none] [--api](非交互环境必须显式给 name)");
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
const created = createSolution(projectRoot, name, { layout, api });
|
|
75
|
+
console.log(`[ojm] 已生成方案骨架:${path.relative(process.cwd(), created.dir)}`);
|
|
76
|
+
for (const f of created.files)
|
|
77
|
+
console.log(`[ojm] + ${f}`);
|
|
78
|
+
console.log(`[ojm] 下一步:pnpm dev 看效果 → ojm build && ojm build --bundle ${name} 打包`);
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
64
81
|
case "init": {
|
|
65
82
|
const { dest, yes } = parseInitArgs(process.argv.slice(3));
|
|
66
83
|
await initProject(dest ? path.resolve(dest) : projectRoot, { yes });
|
|
@@ -79,7 +96,7 @@ async function main() {
|
|
|
79
96
|
break;
|
|
80
97
|
}
|
|
81
98
|
case "info":
|
|
82
|
-
await printInfo(projectRoot);
|
|
99
|
+
await printInfo(projectRoot, undefined, { all: process.argv.slice(3).includes("--all") });
|
|
83
100
|
break;
|
|
84
101
|
case "vendor": {
|
|
85
102
|
const { force, tag } = parseVendorArgs(process.argv.slice(3));
|
|
@@ -197,7 +214,10 @@ async function main() {
|
|
|
197
214
|
|
|
198
215
|
main().catch((error: unknown) => {
|
|
199
216
|
// Error 的 message 已是人话(含修复指引);堆栈默认是噪音,OJM_DEBUG=1 才输出
|
|
200
|
-
// (旧名 RAM_DEBUG
|
|
201
|
-
|
|
217
|
+
// (旧名 RAM_DEBUG 一并读,兼容既有排障习惯)。
|
|
218
|
+
// 文案常自带 [ojm] 前缀(bundle/publish/sign 等抛出的错误),顶层不再叠一个,否则变 [ojm] [ojm]。
|
|
219
|
+
const isDebug = !!(process.env.OJM_DEBUG ?? process.env.RAM_DEBUG);
|
|
220
|
+
const body = error instanceof Error ? (isDebug ? error.stack ?? error.message : error.message) : String(error);
|
|
221
|
+
console.error(/^\[ojm/.test(body) ? body : `[ojm] ${body}`);
|
|
202
222
|
process.exit(1);
|
|
203
223
|
});
|
package/src/info.ts
CHANGED
|
@@ -12,8 +12,9 @@ import fs from "node:fs";
|
|
|
12
12
|
|
|
13
13
|
import http from "node:http";
|
|
14
14
|
import path from "node:path";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
15
|
+
import { readBuiltinModules } from "./builtin";
|
|
16
|
+
import { loadModulesConfig, loadSiteConfig, mergeModuleList } from "./config";
|
|
17
|
+
import { resolveConfigPath, scanModules, tryResolveLayout } from "./layout";
|
|
17
18
|
import { mergeModuleManifests } from "./manifest";
|
|
18
19
|
import { probeOjCaps } from "./oj-caps";
|
|
19
20
|
import { readOjApiPrefix, readOjServerField } from "./oj-config";
|
|
@@ -91,7 +92,50 @@ export function realOjObservability(projectRoot: string): OjObservability {
|
|
|
91
92
|
};
|
|
92
93
|
}
|
|
93
94
|
|
|
94
|
-
export
|
|
95
|
+
export interface PrintInfoOptions {
|
|
96
|
+
/** 展开框架自带模块(默认折叠为一行计数) */
|
|
97
|
+
all?: boolean
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 模块清单条目解析:module-root 用「扫描 + 登记覆盖」并集(与 build 同源,
|
|
102
|
+
* 未登记的用户模块也可见——旧实现只读 web.config.ts 会漏掉它们);历史布局
|
|
103
|
+
* 维持登记清单(loadModulesConfig)。
|
|
104
|
+
*/
|
|
105
|
+
async function resolveModuleEntries(projectRoot: string): Promise<{ name: string, entry: string, enabled: boolean }[]> {
|
|
106
|
+
const layout = tryResolveLayout(projectRoot);
|
|
107
|
+
if (layout?.kind === "module-root") {
|
|
108
|
+
const config = await loadSiteConfig(projectRoot);
|
|
109
|
+
return mergeModuleList(scanModules(layout), config.modules, projectRoot);
|
|
110
|
+
}
|
|
111
|
+
const config = await loadModulesConfig(projectRoot);
|
|
112
|
+
return config.modules.map(m => ({ name: m.name, entry: m.entry, enabled: m.enabled !== false }));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 清单渲染:自带模块默认折叠(--all 展开);坏名单文件告警降级为平铺,info 不失败 */
|
|
116
|
+
function renderModuleLines(entries: { name: string, entry: string, enabled: boolean }[], projectRoot: string, all: boolean): string {
|
|
117
|
+
let builtin = new Set<string>();
|
|
118
|
+
try {
|
|
119
|
+
builtin = readBuiltinModules(projectRoot);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
console.warn(`[ojm] ⚠️ ${error instanceof Error ? error.message : String(error)}——本次按无内置名单渲染。`);
|
|
123
|
+
}
|
|
124
|
+
const fmt = (m: { name: string, entry: string, enabled: boolean }) => ` · ${m.name}(${m.entry})${m.enabled ? "" : " [disabled]"}`;
|
|
125
|
+
const own = entries.filter(m => builtin.has(m.name));
|
|
126
|
+
const user = entries.filter(m => !builtin.has(m.name));
|
|
127
|
+
const lines = user.map(fmt);
|
|
128
|
+
if (own.length > 0) {
|
|
129
|
+
if (all)
|
|
130
|
+
lines.push(...own.map(fmt));
|
|
131
|
+
else
|
|
132
|
+
lines.push(` 框架自带:${own.length} 个(ojm info --all 展开)`);
|
|
133
|
+
}
|
|
134
|
+
// 零模块时留占位行,避免清单段整段空白像渲染故障
|
|
135
|
+
return lines.length > 0 ? lines.join("\n") : "(无 web 模块)";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function printInfo(projectRoot: string, oj: OjObservability = realOjObservability(projectRoot), opts: PrintInfoOptions = {}): Promise<void> {
|
|
95
139
|
const cliVersion = readPkgVersion(path.join(projectRoot, "node_modules/@oj-module/cli/package.json"));
|
|
96
140
|
const runtimeVersion = readPkgVersion(path.join(projectRoot, "node_modules/@oj-module/runtime/package.json"));
|
|
97
141
|
|
|
@@ -107,10 +151,8 @@ export async function printInfo(projectRoot: string, oj: OjObservability = realO
|
|
|
107
151
|
|
|
108
152
|
let moduleLines = "(web.config.ts 加载失败或无模块)";
|
|
109
153
|
try {
|
|
110
|
-
const
|
|
111
|
-
moduleLines =
|
|
112
|
-
.map(m => ` · ${m.name}(${m.entry})${m.enabled === false ? " [disabled]" : ""}`)
|
|
113
|
-
.join("\n");
|
|
154
|
+
const entries = await resolveModuleEntries(projectRoot);
|
|
155
|
+
moduleLines = renderModuleLines(entries, projectRoot, opts.all ?? false);
|
|
114
156
|
}
|
|
115
157
|
catch (error) {
|
|
116
158
|
moduleLines = `(加载失败:${(error as Error).message})`;
|
package/src/init.ts
CHANGED
|
@@ -17,6 +17,7 @@ import process from "node:process";
|
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
import yaml from "yaml";
|
|
19
19
|
import { ALIAS_PATHS_FILE, syncAliasPaths } from "./alias-paths";
|
|
20
|
+
import { BUILTIN_LIST_FILE, writeBuiltinList } from "./builtin";
|
|
20
21
|
import { resolveCertDir, resolveConfigPath } from "./layout";
|
|
21
22
|
import { probeOjCaps } from "./oj-caps";
|
|
22
23
|
import { mintDevCert } from "./oj-cert";
|
|
@@ -125,6 +126,14 @@ export async function initProject(destDir: string, opts: InitOptions = {}): Prom
|
|
|
125
126
|
}, report, include, templatesDir);
|
|
126
127
|
pruneUnincludedApiPaths(destDir, include, report);
|
|
127
128
|
|
|
129
|
+
// 1a) src/.ojm-builtin.json:脚手架自带模块身份(名单派生自上面的白名单,单一真源)。
|
|
130
|
+
// copyIfAbsent:旧工程重跑 init 时缺失才补写,已存在不覆盖用户手改。
|
|
131
|
+
const builtinStatus = writeBuiltinList(destDir, include);
|
|
132
|
+
if (builtinStatus === "created")
|
|
133
|
+
report.created.push(`src/${BUILTIN_LIST_FILE}`);
|
|
134
|
+
else if (builtinStatus === "skipped")
|
|
135
|
+
report.skipped.push(`${BUILTIN_LIST_FILE}(已存在,不覆盖用户手改)`);
|
|
136
|
+
|
|
128
137
|
// 1b) tsconfig.paths.json:把 oj 别名 `#/<模块名>/…` 投影成 TS 的 paths。
|
|
129
138
|
// 必须**现场生成**而不是随模板拷贝——模板兼作 demo 工程(多出 demo/home 两个
|
|
130
139
|
// 模块),而用户工程只有白名单里的那些;照拷会让 paths 指向不存在的目录。
|
|
@@ -233,7 +242,7 @@ export async function initProject(destDir: string, opts: InitOptions = {}): Prom
|
|
|
233
242
|
console.log(`[ojm] · 跳过 ${item}`);
|
|
234
243
|
for (const item of report.warnings)
|
|
235
244
|
console.warn(`[ojm] ! ${item}`);
|
|
236
|
-
console.log("[ojm] 下一步:pnpm install && pnpm dev(登录
|
|
245
|
+
console.log("[ojm] 下一步:pnpm install && pnpm dev(登录 superadmin / 123456,角色账号见 devkit 手册 §1.1)");
|
|
237
246
|
}
|
|
238
247
|
|
|
239
248
|
/** 默认密封器:bin/oj secret keygen + seal(失败返回 null,不抛) */
|
|
@@ -576,7 +585,8 @@ function mergePackageJson(existing: Record<string, any>, generated: Record<strin
|
|
|
576
585
|
return merged;
|
|
577
586
|
}
|
|
578
587
|
|
|
579
|
-
|
|
588
|
+
/** 生成脚手架 package.json(导出供守卫测试:scripts ⊆ 模板 scripts,防双写漂移) */
|
|
589
|
+
export function generatePackageJson(cliRoot: string, projectName: string) {
|
|
580
590
|
const { matrix: hostVersions } = resolveVersionMatrix(cliRoot);
|
|
581
591
|
const cliPkg = JSON.parse(fs.readFileSync(path.join(cliRoot, "package.json"), "utf-8"));
|
|
582
592
|
|
|
@@ -626,11 +636,17 @@ function generatePackageJson(cliRoot: string, projectName: string) {
|
|
|
626
636
|
type: "module",
|
|
627
637
|
private: true,
|
|
628
638
|
scripts: {
|
|
629
|
-
dev: "ojm dev",
|
|
630
|
-
build: "ojm build",
|
|
631
|
-
preview: "ojm preview",
|
|
632
|
-
info: "ojm info",
|
|
633
|
-
typecheck: "tsc --noEmit -p tsconfig.json",
|
|
639
|
+
"dev": "ojm dev",
|
|
640
|
+
"build": "ojm build",
|
|
641
|
+
"preview": "ojm preview",
|
|
642
|
+
"info": "ojm info",
|
|
643
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
644
|
+
// typecheck:api 指向 src/tsconfig.json(后端工程面,白名单收录)——
|
|
645
|
+
// 早期生成器漏发这条(与模板双写漂移),补上并钉守卫测试
|
|
646
|
+
"typecheck:api": "tsc --noEmit -p src/tsconfig.json",
|
|
647
|
+
// 多方案(phase 2/3):打包全部用户方案(自带自动排除)/ 生成新方案骨架
|
|
648
|
+
"bundle": "ojm build --bundle",
|
|
649
|
+
"new": "ojm new",
|
|
634
650
|
},
|
|
635
651
|
devDependencies: devDeps,
|
|
636
652
|
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// BDD:ojm new 参数解析(统一审查 F8——多余位置参数须显式拒绝,不能静默丢弃)。
|
|
2
|
+
// 静默丢弃的恶果:ojm new crm erp(手滑/粘贴)只建 crm,用户以为两份都建了。
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { parseNewArgs } from "../../args";
|
|
5
|
+
|
|
6
|
+
describe("parseNewArgs", () => {
|
|
7
|
+
it("name + 旗标组合", () => {
|
|
8
|
+
expect(parseNewArgs(["crm"])).toEqual({ name: "crm", layout: undefined, api: false });
|
|
9
|
+
expect(parseNewArgs(["crm", "--layout", "none", "--api"])).toEqual({ name: "crm", layout: "none", api: true });
|
|
10
|
+
expect(parseNewArgs([])).toEqual({ name: undefined, layout: undefined, api: false });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("多余位置参数 → 报错而非静默丢弃", () => {
|
|
14
|
+
expect(() => parseNewArgs(["crm", "erp"])).toThrow(/只接受一个方案名/);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("非法 --layout 值 → 报错", () => {
|
|
18
|
+
expect(() => parseNewArgs(["crm", "--layout", "sidebar"])).toThrow(/container \| none/);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// BDD:脚手架自带模块身份(`src/.ojm-builtin.json`)。
|
|
2
|
+
// 消费契约:bundle 排除打包 / ojm info 折叠 / ojm new 拒名;文件缺失 → 空名单(旧工程兼容)。
|
|
3
|
+
// 用例对应设计 docs/plans/2026-10-22-1640-multi-solution-bundle-design.md:
|
|
4
|
+
// B12(坏文件 fail-fast)/ T1(名单 == 白名单派生集)/ T2(copyIfAbsent,不覆盖用户手改)
|
|
5
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
9
|
+
import { BUILTIN_LIST_FILE, deriveBuiltinModules, readBuiltinModules, writeBuiltinList } from "../../builtin";
|
|
10
|
+
|
|
11
|
+
let project: string;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
project = mkdtempSync(join(tmpdir(), "ojm-builtin-"));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
rmSync(project, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function writeList(content: string): void {
|
|
22
|
+
mkdirSync(join(project, "src"), { recursive: true });
|
|
23
|
+
writeFileSync(join(project, "src", BUILTIN_LIST_FILE), content);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("readBuiltinModules", () => {
|
|
27
|
+
it("文件缺失 → 空名单(旧工程/框架仓无内置概念,行为退化兼容)", () => {
|
|
28
|
+
expect(readBuiltinModules(project).size).toBe(0);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("合法文件 → 返回 modules 集合", () => {
|
|
32
|
+
writeList(JSON.stringify({ modules: ["auth", "home"] }));
|
|
33
|
+
expect([...readBuiltinModules(project)].sort()).toEqual(["auth", "home"]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("b12 坏 JSON → fail-fast,报错含文件路径与错因(不静默降级)", () => {
|
|
37
|
+
writeList("{ not json");
|
|
38
|
+
expect(() => readBuiltinModules(project)).toThrow(new RegExp(`${BUILTIN_LIST_FILE.replace(".", "\\.")}`));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("b12 modules 非数组 → fail-fast", () => {
|
|
42
|
+
writeList(JSON.stringify({ modules: "auth" }));
|
|
43
|
+
expect(() => readBuiltinModules(project)).toThrow(/modules/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("b12 modules 含非字符串项 → fail-fast", () => {
|
|
47
|
+
writeList(JSON.stringify({ modules: ["auth", 42] }));
|
|
48
|
+
expect(() => readBuiltinModules(project)).toThrow(/modules/);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("deriveBuiltinModules(名单派生自 init.include.yaml,单一真源)", () => {
|
|
53
|
+
it("t1 取 src/<name>/(api|web) 条目的模块名(含 web-only 模块),忽略无关路径", () => {
|
|
54
|
+
const include = new Set([
|
|
55
|
+
"src/auth/api",
|
|
56
|
+
"src/notification/api",
|
|
57
|
+
"src/login/web/entry.ts",
|
|
58
|
+
"src/oj-market/web/pages",
|
|
59
|
+
"src/styles.css",
|
|
60
|
+
"src/tsconfig.json",
|
|
61
|
+
"config.yaml",
|
|
62
|
+
"web.config.ts",
|
|
63
|
+
]);
|
|
64
|
+
expect([...deriveBuiltinModules(include)].sort())
|
|
65
|
+
.toEqual(["auth", "login", "notification", "oj-market"]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("白名单无模块条目 → 空集(不凭空造身份)", () => {
|
|
69
|
+
expect(deriveBuiltinModules(new Set(["config.yaml", "README.md"])).size).toBe(0);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("writeBuiltinList(init 接线:copyIfAbsent)", () => {
|
|
74
|
+
it("t1 写入派生名单(排序)+ 自注释头,返回 created", () => {
|
|
75
|
+
const include = new Set(["src/home/web/entry.ts", "src/auth/api", "src/login/web"]);
|
|
76
|
+
expect(writeBuiltinList(project, include)).toBe("created");
|
|
77
|
+
const p = join(project, "src", BUILTIN_LIST_FILE);
|
|
78
|
+
expect(existsSync(p)).toBe(true);
|
|
79
|
+
const doc = JSON.parse(readFileSync(p, "utf-8"));
|
|
80
|
+
expect(doc.modules).toEqual(["auth", "home", "login"]);
|
|
81
|
+
expect(typeof doc["//"]).toBe("string");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("t2 已存在 → 返回 skipped 且内容原样不动(用户手改权)", () => {
|
|
85
|
+
const include = new Set(["src/auth/api"]);
|
|
86
|
+
expect(writeBuiltinList(project, include)).toBe("created");
|
|
87
|
+
const p = join(project, "src", BUILTIN_LIST_FILE);
|
|
88
|
+
writeFileSync(p, JSON.stringify({ modules: ["my-edit"] }));
|
|
89
|
+
expect(writeBuiltinList(project, include)).toBe("skipped");
|
|
90
|
+
expect(JSON.parse(readFileSync(p, "utf-8")).modules).toEqual(["my-edit"]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("派生集为空 → 不写文件,返回 none", () => {
|
|
94
|
+
expect(writeBuiltinList(project, new Set(["config.yaml"]))).toBe("none");
|
|
95
|
+
expect(existsSync(join(project, "src", BUILTIN_LIST_FILE))).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// BDD:`ojm build --bundle` 方案包组装(P1-11)。
|
|
2
2
|
// 组装契约(对齐 tgz 内布局,契约 §2.9 + P1-9 staging copy 源定版):
|
|
3
|
-
// <root>/manifest.yaml
|
|
3
|
+
// <root>/solution.yaml | manifest.yaml ← 必填(solution.yaml 优先,避开 oj 的 api 枚举冲突),契约校验 fail-fast 先于打包
|
|
4
|
+
// <root>/src/<模块>/solution.yaml | manifest.yaml ← 模块级方案:契约与所打包模块同位(同优先级回退)
|
|
4
5
|
// api/dist/<name>-<version>/ ← oj build 产物原样 → tgz 内 api/<name>-<version>/(C2-31 分层核对)
|
|
5
6
|
// web/dist/modules/<name>/ ← 站点构建模块产物 → tgz 内 web/modules/<name>/(只入包方案自己的模块)
|
|
6
7
|
// 禁携 modules.json(C2-31 红线);产物确定性(C2-32)由 deterministic-tar 承担,此处断言端到端复现。
|
|
@@ -48,7 +49,25 @@ function makePrebuiltArtifacts(): void {
|
|
|
48
49
|
writeFileSync(join(webDist, "demo-solution.css"), ".demo{}\n");
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
const pack = () => buildBundle(project, { out });
|
|
52
|
+
const pack = () => buildBundle(project, { out }).then(r => r[0]!);
|
|
53
|
+
|
|
54
|
+
/** 造一个 web-only 用户方案(模块级契约 + 站点产物,无 api 段校验面最小) */
|
|
55
|
+
function addWebSolution(name: string, version = "0.1.0"): void {
|
|
56
|
+
const dir = join(project, "src", name);
|
|
57
|
+
mkdirSync(dir, { recursive: true });
|
|
58
|
+
writeFileSync(join(dir, "solution.yaml"), [
|
|
59
|
+
`name: ${name}`,
|
|
60
|
+
`version: ${version}`,
|
|
61
|
+
`summary: ${name} 用户方案`,
|
|
62
|
+
`changelog: ${name} 首个版本`,
|
|
63
|
+
"web:",
|
|
64
|
+
` entry: modules/${name}/${version}/entry.js`,
|
|
65
|
+
"",
|
|
66
|
+
].join("\n"));
|
|
67
|
+
const webDist = join(project, "web/dist/modules", name, version);
|
|
68
|
+
mkdirSync(webDist, { recursive: true });
|
|
69
|
+
writeFileSync(join(webDist, "entry.js"), "export default {};\n");
|
|
70
|
+
}
|
|
52
71
|
|
|
53
72
|
describe("buildBundle · 正路径", () => {
|
|
54
73
|
it("产出 out/<name>-<version>.tgz + 64 位 sha256,布局单根三分(manifest/api/web)", async () => {
|
|
@@ -85,17 +104,161 @@ describe("buildBundle · 正路径", () => {
|
|
|
85
104
|
expect(tarList(r.tgz).some(n => n.endsWith("/modules.json"))).toBe(false);
|
|
86
105
|
});
|
|
87
106
|
|
|
107
|
+
it("solution.yaml 作为 bundle 契约(优先于 manifest.yaml,避开 oj 按 manifest.yaml 枚举 api 后端的冲突)", async () => {
|
|
108
|
+
// 根目录若用 manifest.yaml 当 bundle 契约,会被 oj 的 api 模块搜索枚举误判为 api 模块;
|
|
109
|
+
// 故解决方案改用 solution.yaml,bundle 工具优先认它(manifest.yaml 仅旧方案回退)。
|
|
110
|
+
const mf = join(project, "manifest.yaml");
|
|
111
|
+
const sf = join(project, "solution.yaml");
|
|
112
|
+
cpSync(mf, sf);
|
|
113
|
+
rmSync(mf);
|
|
114
|
+
const r = await pack();
|
|
115
|
+
expect(r.name).toBe("demo-solution");
|
|
116
|
+
expect(existsSync(r.tgz)).toBe(true);
|
|
117
|
+
// tgz 内固定为 manifest.yaml(安装侧只认这个名字),与源文件名无关
|
|
118
|
+
const names = tarList(r.tgz);
|
|
119
|
+
expect(names).toContain("demo-solution-0.1.0/manifest.yaml");
|
|
120
|
+
expect(names.some(n => n.endsWith("/solution.yaml"))).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("src/<模块>/solution.yaml 作为 bundle 契约(模块级方案:契约与所打包模块同位)", async () => {
|
|
124
|
+
// 单模块方案:契约紧贴它打包的模块 src/<name>/,比工程根更直观;
|
|
125
|
+
// 仍不在 api/ 下、也不叫 manifest.yaml,等价避开 oj 按 manifest.yaml 枚举 api 后端的冲突。
|
|
126
|
+
const mf = join(project, "manifest.yaml");
|
|
127
|
+
const sf = join(project, "src/demo-solution/solution.yaml");
|
|
128
|
+
mkdirSync(dirname(sf), { recursive: true });
|
|
129
|
+
cpSync(mf, sf);
|
|
130
|
+
rmSync(mf);
|
|
131
|
+
const r = await pack();
|
|
132
|
+
expect(r.name).toBe("demo-solution");
|
|
133
|
+
expect(existsSync(r.tgz)).toBe(true);
|
|
134
|
+
// tgz 内固定为 manifest.yaml(安装侧只认这个名字),与源文件名/源位置无关
|
|
135
|
+
const names = tarList(r.tgz);
|
|
136
|
+
expect(names).toContain("demo-solution-0.1.0/manifest.yaml");
|
|
137
|
+
expect(names.some(n => n.endsWith("/solution.yaml"))).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("src/ 下多份模块级契约 → 全部打包(多方案;B2)", async () => {
|
|
141
|
+
// 多方案正是本特性要支持的场景:每份契约独立校验、独立出包
|
|
142
|
+
rmSync(join(project, "manifest.yaml"));
|
|
143
|
+
addWebSolution("mod-a");
|
|
144
|
+
addWebSolution("mod-b");
|
|
145
|
+
const results = await buildBundle(project, { out });
|
|
146
|
+
expect(results.map(r => r.name).sort()).toEqual(["mod-a", "mod-b"]);
|
|
147
|
+
for (const r of results)
|
|
148
|
+
expect(existsSync(r.tgz)).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
88
151
|
it("产物树混入 modules.json → 打包自检拒绝(C2-31 红线)", async () => {
|
|
89
152
|
writeFileSync(join(project, "web/dist/modules/demo-solution/0.1.0/modules.json"), "[]");
|
|
90
153
|
await expect(pack()).rejects.toThrow(/modules\.json/);
|
|
91
154
|
});
|
|
92
155
|
|
|
93
|
-
it("私钥/凭证混入包树 → 打包自检拒绝(P2-4 入包红线:*.pem/*.key/.env/id_ed25519/id_rsa)", async () => {
|
|
156
|
+
it("私钥/凭证混入包树 → 打包自检拒绝(P2-4 入包红线:*.pem/*.key/.env(.local…)/id_ed25519/id_rsa/*.p12/*.pfx)", async () => {
|
|
94
157
|
writeFileSync(join(project, "api/dist/demo-solution-0.1.0/signing.pem"), "-----BEGIN PRIVATE KEY-----\n");
|
|
95
158
|
await expect(pack()).rejects.toThrow(/私钥\/凭证/);
|
|
96
159
|
rmSync(join(project, "api/dist/demo-solution-0.1.0/signing.pem"));
|
|
97
160
|
writeFileSync(join(project, "web/dist/modules/demo-solution/0.1.0/.env"), "SECRET=1\n");
|
|
98
161
|
await expect(pack()).rejects.toThrow(/私钥\/凭证/);
|
|
162
|
+
rmSync(join(project, "web/dist/modules/demo-solution/0.1.0/.env"));
|
|
163
|
+
writeFileSync(join(project, "web/dist/modules/demo-solution/0.1.0/.env.production"), "SECRET=1\n");
|
|
164
|
+
await expect(pack()).rejects.toThrow(/私钥\/凭证/);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("buildBundle · 多方案与内置排除(B3/B7/B8/B9/B10/B12)", () => {
|
|
169
|
+
it("b3 内置方案跳过:名单内的模块契约不出包,用户契约照打", async () => {
|
|
170
|
+
rmSync(join(project, "manifest.yaml"));
|
|
171
|
+
addWebSolution("crm");
|
|
172
|
+
addWebSolution("oj-market"); // 自带方案也带契约(第一方可分发形态)
|
|
173
|
+
mkdirSync(join(project, "src"), { recursive: true });
|
|
174
|
+
writeFileSync(join(project, "src/.ojm-builtin.json"), JSON.stringify({ modules: ["oj-market"] }));
|
|
175
|
+
const results = await buildBundle(project, { out });
|
|
176
|
+
expect(results.map(r => r.name)).toEqual(["crm"]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("b6/B3 无名单文件 → 无内置概念,多份契约全部打包", async () => {
|
|
180
|
+
rmSync(join(project, "manifest.yaml"));
|
|
181
|
+
addWebSolution("crm");
|
|
182
|
+
addWebSolution("oj-market");
|
|
183
|
+
const results = await buildBundle(project, { out });
|
|
184
|
+
expect(results.map(r => r.name).sort()).toEqual(["crm", "oj-market"]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("b7+B8:目录不同契约 name 撞车 → 被 name===目录名 校验拦下(B8 使 B7 向量不可达,决策见计划文档)", async () => {
|
|
188
|
+
rmSync(join(project, "manifest.yaml"));
|
|
189
|
+
addWebSolution("crm");
|
|
190
|
+
// 拷贝改目录忘改 name 的典型症状——B8 在收集期即拒,dist/ 不会出现同名产物
|
|
191
|
+
const dir = join(project, "src", "crm-copy");
|
|
192
|
+
mkdirSync(dir, { recursive: true });
|
|
193
|
+
writeFileSync(join(dir, "solution.yaml"), "name: crm\nversion: 0.1.0\nsummary: dup\nchangelog: x\nweb:\n entry: modules/crm/0.1.0/entry.js\n");
|
|
194
|
+
await expect(buildBundle(project, { out })).rejects.toThrow(/与目录名「crm-copy」不一致/);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("b8 模块级契约 name ≠ 目录名 → fail-fast", async () => {
|
|
198
|
+
rmSync(join(project, "manifest.yaml"));
|
|
199
|
+
const dir = join(project, "src", "crm");
|
|
200
|
+
mkdirSync(dir, { recursive: true });
|
|
201
|
+
writeFileSync(join(dir, "solution.yaml"), "name: erp\nversion: 0.1.0\nsummary: x\n");
|
|
202
|
+
await expect(pack()).rejects.toThrow(/与目录名「crm」不一致/);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("b9 两段式:多契约其一校验失败 → dist/ 零增量", async () => {
|
|
206
|
+
rmSync(join(project, "manifest.yaml"));
|
|
207
|
+
addWebSolution("crm");
|
|
208
|
+
// erp 契约违规(name 大写触发契约校验)
|
|
209
|
+
const dir = join(project, "src", "erp");
|
|
210
|
+
mkdirSync(dir, { recursive: true });
|
|
211
|
+
writeFileSync(join(dir, "solution.yaml"), "name: erp\nversion: 0.1.0\nsummary: x\n");
|
|
212
|
+
await expect(buildBundle(project, { out })).rejects.toThrow(/C2-08/);
|
|
213
|
+
expect(existsSync(join(out, "crm-0.1.0.tgz"))).toBe(false);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("b10 旧契约回退:src/<name>/manifest.yaml(非内置)参与收集", async () => {
|
|
217
|
+
rmSync(join(project, "manifest.yaml"));
|
|
218
|
+
const dir = join(project, "src", "crm");
|
|
219
|
+
mkdirSync(dir, { recursive: true });
|
|
220
|
+
writeFileSync(join(dir, "manifest.yaml"), "name: crm\nversion: 0.1.0\nsummary: x\nchangelog: x\nweb:\n entry: modules/crm/0.1.0/entry.js\n");
|
|
221
|
+
const webDist = join(project, "web/dist/modules/crm/0.1.0");
|
|
222
|
+
mkdirSync(webDist, { recursive: true });
|
|
223
|
+
writeFileSync(join(webDist, "entry.js"), "export default {};\n");
|
|
224
|
+
const results = await buildBundle(project, { out });
|
|
225
|
+
expect(results.map(r => r.name)).toEqual(["crm"]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("b12 坏 .ojm-builtin.json → fail-fast 报路径与错因", async () => {
|
|
229
|
+
mkdirSync(join(project, "src"), { recursive: true });
|
|
230
|
+
writeFileSync(join(project, "src/.ojm-builtin.json"), "{ not json");
|
|
231
|
+
await expect(pack()).rejects.toThrow(/\.ojm-builtin\.json/);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("b4 --bundle <name>:按名打包;内置名拒绝;非法名拒绝", async () => {
|
|
235
|
+
rmSync(join(project, "manifest.yaml"));
|
|
236
|
+
addWebSolution("crm");
|
|
237
|
+
addWebSolution("erp");
|
|
238
|
+
const one = await buildBundle(project, { out, name: "crm" });
|
|
239
|
+
expect(one.map(r => r.name)).toEqual(["crm"]);
|
|
240
|
+
await expect(buildBundle(project, { out, name: "erp2" })).rejects.toThrow(/未找到「erp2」/);
|
|
241
|
+
writeFileSync(join(project, "src/.ojm-builtin.json"), JSON.stringify({ modules: ["erp"] }));
|
|
242
|
+
await expect(buildBundle(project, { out, name: "erp" })).rejects.toThrow(/框架自带方案/);
|
|
243
|
+
await expect(buildBundle(project, { out, name: "../evil" })).rejects.toThrow(/非法/);
|
|
244
|
+
await expect(buildBundle(project, { out, name: "Bad Name" })).rejects.toThrow(/非法/);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("b1 根契约优先于 src 契约(旧行为)+ B11 hint", async () => {
|
|
248
|
+
addWebSolution("crm"); // 根 manifest.yaml(demo-solution)仍在
|
|
249
|
+
const spy: string[] = [];
|
|
250
|
+
const orig = console.log;
|
|
251
|
+
console.log = (m?: unknown) => {
|
|
252
|
+
spy.push(String(m));
|
|
253
|
+
};
|
|
254
|
+
try {
|
|
255
|
+
const results = await buildBundle(project, { out });
|
|
256
|
+
expect(results.map(r => r.name)).toEqual(["demo-solution"]);
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
console.log = orig;
|
|
260
|
+
}
|
|
261
|
+
expect(spy.join("\n")).toMatch(/工程根契约优先/);
|
|
99
262
|
});
|
|
100
263
|
});
|
|
101
264
|
|
|
@@ -106,9 +269,9 @@ describe("buildBundle · 校验先于打包(fail-fast)", () => {
|
|
|
106
269
|
expect(existsSync(join(out, "Demo-1.0.0+build.5.tgz"))).toBe(false);
|
|
107
270
|
});
|
|
108
271
|
|
|
109
|
-
it("manifest.yaml
|
|
272
|
+
it("manifest.yaml 缺失且无用户方案:可读报错指向 ojm new(B5)", async () => {
|
|
110
273
|
rmSync(join(project, "manifest.yaml"));
|
|
111
|
-
await expect(pack()).rejects.toThrow(
|
|
274
|
+
await expect(pack()).rejects.toThrow(/没有可打包的用户方案/);
|
|
112
275
|
});
|
|
113
276
|
|
|
114
277
|
it("c2-31 分层核对:模块级 desc ≠ bundle summary → 放行(等值要求已移除)", async () => {
|