@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.
Files changed (75) hide show
  1. package/devkit/manual.md +100 -12
  2. package/devkit/module-dev-and-cdn-playground.md +10 -10
  3. package/package.json +4 -4
  4. package/shell-dist/assets/runtime.js +61 -35
  5. package/shell-dist/index.html +2 -2
  6. package/shell-dist/versions.json +3 -3
  7. package/src/__tests__/build-backend.test.ts +13 -13
  8. package/src/__tests__/cli.test.ts +225 -0
  9. package/src/__tests__/init-packagejson.test.ts +29 -0
  10. package/src/__tests__/layout.test.ts +8 -5
  11. package/src/__tests__/new-scaffold.test.ts +59 -0
  12. package/src/__tests__/usage.test.ts +45 -47
  13. package/src/build.ts +4 -4
  14. package/src/builtin.ts +93 -0
  15. package/src/bundle.ts +237 -33
  16. package/src/cli.ts +265 -0
  17. package/src/contract/__tests__/market-client.integration.test.ts +12 -12
  18. package/src/contract/check.ts +4 -4
  19. package/src/dev.ts +75 -2
  20. package/src/index.ts +31 -191
  21. package/src/info.ts +49 -7
  22. package/src/init.ts +25 -11
  23. package/src/keygen.ts +0 -19
  24. package/src/layout.ts +11 -11
  25. package/src/lifecycle/__tests__/authoring.test.ts +3 -14
  26. package/src/lifecycle/__tests__/builtin.test.ts +97 -0
  27. package/src/lifecycle/__tests__/bundle.test.ts +188 -25
  28. package/src/lifecycle/__tests__/install.test.ts +0 -23
  29. package/src/lifecycle/__tests__/new.test.ts +129 -0
  30. package/src/lifecycle/__tests__/smoke.test.ts +4 -3
  31. package/src/manifest-contract.ts +16 -3
  32. package/src/market/__tests__/bootstrap.test.ts +24 -0
  33. package/src/market/__tests__/setup.test.ts +8 -1
  34. package/src/market/bootstrap.ts +10 -8
  35. package/src/market/cdn-layout.ts +3 -3
  36. package/src/market/command.ts +0 -23
  37. package/src/market/publish.ts +10 -2
  38. package/src/market/setup.ts +14 -2
  39. package/src/market/trial.ts +3 -3
  40. package/src/market/walkthrough.ts +7 -6
  41. package/src/new.ts +226 -0
  42. package/src/preview-config.ts +2 -2
  43. package/src/preview.ts +5 -4
  44. package/src/publish.ts +0 -22
  45. package/src/sign.ts +0 -22
  46. package/src/usage.ts +482 -198
  47. package/templates/README.md +3 -3
  48. package/templates/cdn/README.md +6 -6
  49. package/templates/config.yaml +5 -4
  50. package/templates/init.include.yaml +1 -1
  51. package/templates/package.json +3 -1
  52. package/templates/seed.mysql.sql +2 -1
  53. package/templates/seed.pg.sql +2 -1
  54. package/templates/src/_platform/api/fixtures/l1-test-user.sql +4 -5
  55. package/templates/src/_platform/api/market/api.ts +11 -8
  56. package/templates/src/_platform/api/seed.mysql.sql +11 -3
  57. package/templates/src/_platform/api/seed.pg.sql +11 -3
  58. package/templates/src/_platform/api/seed.sql +18 -6
  59. package/templates/src/_platform/api/solutions/api.ts +2 -2
  60. package/templates/src/oj-market/web/api/context.ts +2 -2
  61. package/templates/src/oj-market/web/components/InstallDialog.tsx +13 -6
  62. package/templates/src/oj-market/web/components/RollbackDialog.tsx +7 -3
  63. package/templates/src/oj-market/web/components/UninstallConfirm.tsx +18 -7
  64. package/templates/src/oj-market/web/hooks/useMarket.ts +30 -18
  65. package/templates/src/system/web/locales/en-US.json +1 -1
  66. package/templates/src/system/web/locales/zh-CN.json +1 -1
  67. package/templates/src/system/web/pages/users/roles.tsx +3 -1
  68. package/templates/tests/l1-builder.test.ts +2 -2
  69. package/templates/tests/lifecycle/market-api.spec.ts +35 -5
  70. package/templates/tests/lifecycle/verify-checks.spec.ts +1 -1
  71. package/templates/tests/lifecycle/verify-report.spec.ts +1 -1
  72. package/templates/tests/rbac-seed.spec.ts +12 -10
  73. package/templates/tests/seed-role-accounts.spec.ts +87 -0
  74. package/vendor/host-versions.json +3 -3
  75. package/src/args.ts +0 -122
@@ -1,8 +1,9 @@
1
1
  // BDD:`ojm build --bundle` 方案包组装(P1-11)。
2
2
  // 组装契约(对齐 tgz 内布局,契约 §2.9 + P1-9 staging copy 源定版):
3
- // <root>/manifest.yaml ← 必填,契约校验 fail-fast 先于打包
4
- // api/dist/<name>-<version>/ ← oj build 产物原样 → tgz 内 api/<name>-<version>/(C2-31 分层核对)
5
- // web/dist/modules/<name>/ ← 站点构建模块产物 → tgz 内 web/modules/<name>/(只入包方案自己的模块)
3
+ // <root>/solution.yaml | manifest.yaml ← 必填(solution.yaml 优先,避开 oj 的 api 枚举冲突),契约校验 fail-fast 先于打包
4
+ // <root>/src/<模块>/solution.yaml | manifest.yaml ← 模块级方案:契约与所打包模块同位(同优先级回退)
5
+ // dist/api/<name>-<version>/ ← oj build 产物原样 → tgz 内 api/<name>-<version>/(C2-31 分层核对)
6
+ // dist/web/modules/<name>/ ← 站点构建模块产物 → tgz 内 web/modules/<name>/(只入包方案自己的模块)
6
7
  // 禁携 modules.json(C2-31 红线);产物确定性(C2-32)由 deterministic-tar 承担,此处断言端到端复现。
7
8
  import { execFileSync } from "node:child_process";
8
9
  import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -31,24 +32,42 @@ afterEach(() => {
31
32
  rmSync(out, { recursive: true, force: true });
32
33
  });
33
34
 
34
- /** 预置「已构建」产物:oj build(api/dist/<name>-<version>/)与站点构建(web/dist/modules/) */
35
+ /** 预置「已构建」产物:oj build(dist/api/<name>-<version>/)与站点构建(dist/web/modules/) */
35
36
  function makePrebuiltArtifacts(): void {
36
37
  // oj build 产物(模块级 manifest 仅 oj 原生七键;name/version 与 bundle 级逐一相等,C2-31)
37
- const apiDist = join(project, "api/dist/demo-solution-0.1.0");
38
+ const apiDist = join(project, "dist/api/demo-solution-0.1.0");
38
39
  mkdirSync(join(apiDist, "ping"), { recursive: true });
39
40
  mkdirSync(join(apiDist, "migrations"), { recursive: true });
40
41
  writeFileSync(join(apiDist, "manifest.yaml"), "name: demo-solution\ndesc: manifest.yaml 契约全字段合法夹具\nversion: 0.1.0\ntables:\n - note\n");
41
42
  writeFileSync(join(apiDist, "schema.yaml"), "tables:\n note: {}\n");
42
43
  writeFileSync(join(apiDist, "routes.js"), "export {};\n");
43
44
  writeFileSync(join(apiDist, "ping/api.js"), "export async function ping() {}\n");
44
- // 站点构建产物(web/dist/modules/<name>/<version>/entry.js + css,对应 web.entry/css 声明)
45
- const webDist = join(project, "web/dist/modules/demo-solution/0.1.0");
45
+ // 站点构建产物(dist/web/modules/<name>/<version>/entry.js + css,对应 web.entry/css 声明)
46
+ const webDist = join(project, "dist/web/modules/demo-solution/0.1.0");
46
47
  mkdirSync(webDist, { recursive: true });
47
48
  writeFileSync(join(webDist, "entry.js"), "export default {};\n");
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, "dist/web/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 () => {
@@ -66,7 +85,7 @@ describe("buildBundle · 正路径", () => {
66
85
 
67
86
  it("只入包方案自己的前端模块:模板模块(home/login…)不随包", async () => {
68
87
  // 安装器 deployWeb 把包内 web/modules 整树覆盖进目标站,模板产物随包会覆盖宿主同名前端
69
- const hostWeb = join(project, "web/dist/modules/home/0.1.0");
88
+ const hostWeb = join(project, "dist/web/modules/home/0.1.0");
70
89
  mkdirSync(hostWeb, { recursive: true });
71
90
  writeFileSync(join(hostWeb, "entry.js"), "export default {};\n");
72
91
  const names = tarList((await pack()).tgz);
@@ -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
- writeFileSync(join(project, "web/dist/modules/demo-solution/0.1.0/modules.json"), "[]");
152
+ writeFileSync(join(project, "dist/web/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 () => {
94
- writeFileSync(join(project, "api/dist/demo-solution-0.1.0/signing.pem"), "-----BEGIN PRIVATE KEY-----\n");
156
+ it("私钥/凭证混入包树 → 打包自检拒绝(P2-4 入包红线:*.pem/*.key/.env(.local…)/id_ed25519/id_rsa/*.p12/*.pfx)", async () => {
157
+ writeFileSync(join(project, "dist/api/demo-solution-0.1.0/signing.pem"), "-----BEGIN PRIVATE KEY-----\n");
95
158
  await expect(pack()).rejects.toThrow(/私钥\/凭证/);
96
- rmSync(join(project, "api/dist/demo-solution-0.1.0/signing.pem"));
97
- writeFileSync(join(project, "web/dist/modules/demo-solution/0.1.0/.env"), "SECRET=1\n");
159
+ rmSync(join(project, "dist/api/demo-solution-0.1.0/signing.pem"));
160
+ writeFileSync(join(project, "dist/web/modules/demo-solution/0.1.0/.env"), "SECRET=1\n");
98
161
  await expect(pack()).rejects.toThrow(/私钥\/凭证/);
162
+ rmSync(join(project, "dist/web/modules/demo-solution/0.1.0/.env"));
163
+ writeFileSync(join(project, "dist/web/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, "dist/web/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,29 +269,29 @@ 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 缺失:可读报错指明期望路径", async () => {
272
+ it("manifest.yaml 缺失且无用户方案:可读报错指向 ojm new(B5)", async () => {
110
273
  rmSync(join(project, "manifest.yaml"));
111
- await expect(pack()).rejects.toThrow(/manifest\.yaml/);
274
+ await expect(pack()).rejects.toThrow(/没有可打包的用户方案/);
112
275
  });
113
276
 
114
277
  it("c2-31 分层核对:模块级 desc ≠ bundle summary → 放行(等值要求已移除)", async () => {
115
- writeFileSync(join(project, "api/dist/demo-solution-0.1.0/manifest.yaml"), "name: demo-solution\ndesc: 不一致描述\nversion: 0.1.0\n");
278
+ writeFileSync(join(project, "dist/api/demo-solution-0.1.0/manifest.yaml"), "name: demo-solution\ndesc: 不一致描述\nversion: 0.1.0\n");
116
279
  await expect(pack()).resolves.toMatchObject({ name: "demo-solution" });
117
280
  });
118
281
 
119
- it("c2-31 分层核对:api/dist 缺 <name>-<version>/ → 可读报错指明期望产物路径", async () => {
120
- rmSync(join(project, "api/dist"), { recursive: true, force: true });
121
- await expect(pack()).rejects.toThrow(/api\/dist\/demo-solution-0\.1\.0/);
282
+ it("c2-31 分层核对:dist/api 缺 <name>-<version>/ → 可读报错指明期望产物路径", async () => {
283
+ rmSync(join(project, "dist/api"), { recursive: true, force: true });
284
+ await expect(pack()).rejects.toThrow(/dist\/api\/demo-solution-0\.1\.0/);
122
285
  });
123
286
 
124
287
  it("web 段存在但站点产物缺失 → 可读报错指引先构建", async () => {
125
- rmSync(join(project, "web/dist"), { recursive: true, force: true });
126
- await expect(pack()).rejects.toThrow(/web\/dist\/modules/);
288
+ rmSync(join(project, "dist/web"), { recursive: true, force: true });
289
+ await expect(pack()).rejects.toThrow(/dist\/web\/modules/);
127
290
  });
128
291
 
129
- it("web/dist/modules 在但缺本方案目录 → 可读报错(不整树入包)", async () => {
130
- rmSync(join(project, "web/dist/modules/demo-solution"), { recursive: true, force: true });
131
- await expect(pack()).rejects.toThrow(/web\/dist\/modules\/demo-solution\//);
292
+ it("dist/web/modules 在但缺本方案目录 → 可读报错(不整树入包)", async () => {
293
+ rmSync(join(project, "dist/web/modules/demo-solution"), { recursive: true, force: true });
294
+ await expect(pack()).rejects.toThrow(/dist\/web\/modules\/demo-solution\//);
132
295
  });
133
296
 
134
297
  it("c2-12a:api.tables 与 schema.yaml 表集不一致 → 构建期拒绝", async () => {
@@ -6,7 +6,6 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { tmpdir } from "node:os";
7
7
  import { join } from "node:path";
8
8
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9
- import { parseLifecycleArgs } from "../../args";
10
9
  import { setEnabledCommand } from "../../enable";
11
10
  import { installCommand, parseTarget, renderOutcome } from "../../install";
12
11
  import { pollTask, resolveSite } from "../../site";
@@ -61,28 +60,6 @@ afterEach(() => {
61
60
  vi.restoreAllMocks();
62
61
  });
63
62
 
64
- describe("parseLifecycleArgs · 参数解析", () => {
65
- it("位置参数与值式 flag 分离:--url 等的值不被误当位置参数(P6 同款坑)", () => {
66
- const a = parseLifecycleArgs(["demo-solution@0.1.0", "--url", "http://x:1", "--user", "u", "--pass", "p", "--locale", "en-US"]);
67
- expect(a.positionals).toEqual(["demo-solution@0.1.0"]);
68
- expect(a.url).toBe("http://x:1");
69
- expect(a.user).toBe("u");
70
- expect(a.pass).toBe("p");
71
- expect(a.locale).toBe("en-US");
72
- });
73
-
74
- it("install 专属 --name/--version 覆盖位", () => {
75
- const a = parseLifecycleArgs(["./a.tgz", "--name", "demo-solution", "--version", "0.2.0"]);
76
- expect(a.positionals).toEqual(["./a.tgz"]);
77
- expect(a.name).toBe("demo-solution");
78
- expect(a.version).toBe("0.2.0");
79
- });
80
-
81
- it("未知 --flag 忽略不炸", () => {
82
- expect(parseLifecycleArgs(["x", "--future"]).positionals).toEqual(["x"]);
83
- });
84
- });
85
-
86
63
  describe("resolveSite · 站点寻址(flag > 环境变量 > 缺省模板 serve 端口 9778)", () => {
87
64
  const ENV_BACKUP = { ...process.env };
88
65
 
@@ -0,0 +1,129 @@
1
+ // BDD:`ojm new <name>` 方案骨架生成(docs/plans/2026-10-22-1640-multi-solution-bundle-design.md §C)。
2
+ // N0 非工程拒绝 / N1 骨架 round-trip(生成→补产物→buildBundle 直接过)/ N2 --api
3
+ // N3 非法·保留·内置·重名拒绝 / N5 空名拒绝 / N6 历史布局拒绝 / N7 失败清理
4
+ import fs, { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, writeFileSync as writeFileSyncOrig } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8
+ import { buildBundle } from "../../bundle";
9
+ import { createSolution } from "../../new";
10
+
11
+ let project: string;
12
+
13
+ beforeEach(() => {
14
+ project = mkdtempSync(join(tmpdir(), "ojm-new-"));
15
+ });
16
+
17
+ afterEach(() => {
18
+ rmSync(project, { recursive: true, force: true });
19
+ });
20
+
21
+ /** 最小 module-root 工程面:web.config.ts + src/(含一个自带模块 home 与内置名单) */
22
+ function makeModuleRootProject(builtin = true): void {
23
+ mkdirSync(join(project, "src", "home", "web"), { recursive: true });
24
+ writeFileSync(join(project, "src", "home", "web", "entry.ts"), "export default { name: \"home\" };\n");
25
+ writeFileSync(join(project, "web.config.ts"), "export default { baseUrl: \"\", modules: [] };\n");
26
+ if (builtin)
27
+ writeFileSync(join(project, "src", ".ojm-builtin.json"), JSON.stringify({ modules: ["home"] }));
28
+ }
29
+
30
+ /** 给已生成的方案补站点产物,模拟「ojm build 之后」的打包前置 */
31
+ function addWebArtifacts(name: string, version = "0.1.0"): void {
32
+ const webDist = join(project, "dist/web/modules", name, version);
33
+ mkdirSync(webDist, { recursive: true });
34
+ writeFileSync(join(webDist, "entry.js"), "export default {};\n");
35
+ }
36
+
37
+ describe("createSolution · 守卫", () => {
38
+ it("n0 非 ojm 工程(无 web.config.ts / src/)→ 拒绝且零写入", () => {
39
+ expect(() => createSolution(project, "crm")).toThrow(/不是 ojm 工程/);
40
+ expect(existsSync(join(project, "src"))).toBe(false);
41
+ });
42
+
43
+ it("n6 历史布局工程(web/src 树)→ 拒绝 + 迁移指引", () => {
44
+ mkdirSync(join(project, "web", "src", "old"), { recursive: true });
45
+ writeFileSync(join(project, "web.config.ts"), "export default { modules: [] };\n");
46
+ expect(() => createSolution(project, "crm")).toThrow(/module-root/);
47
+ expect(existsSync(join(project, "src", "crm"))).toBe(false);
48
+ });
49
+
50
+ it("n3 非法名(大写/数字开头/路径穿越)→ 拒绝", () => {
51
+ makeModuleRootProject();
52
+ for (const bad of ["Crm", "9crm", "../crm", "bad name"])
53
+ expect(() => createSolution(project, bad)).toThrow(/非法/);
54
+ });
55
+
56
+ it("n3 保留名(system)与内置名(home)→ 拒绝 + 指引", () => {
57
+ makeModuleRootProject();
58
+ expect(() => createSolution(project, "system")).toThrow(/保留名/);
59
+ expect(() => createSolution(project, "home")).toThrow(/框架自带/);
60
+ });
61
+
62
+ it("n3 目录已存在(非内置共享目录撞名)→ 拒绝", () => {
63
+ makeModuleRootProject();
64
+ mkdirSync(join(project, "src", "crm"), { recursive: true });
65
+ expect(() => createSolution(project, "crm")).toThrow(/已存在/);
66
+ });
67
+
68
+ it("n5 空名 → 拒绝", () => {
69
+ makeModuleRootProject();
70
+ expect(() => createSolution(project, "")).toThrow(/非法/);
71
+ });
72
+ });
73
+
74
+ describe("createSolution · 生成", () => {
75
+ it("n1 生成 web 骨架:solution.yaml/entry.ts/页面/双语 locales 齐备,layout 写进 handle", () => {
76
+ makeModuleRootProject();
77
+ const r = createSolution(project, "crm", { layout: "container" });
78
+ expect(r.files).toHaveLength(5);
79
+ const entry = readFileSync(join(project, "src/crm/web/entry.ts"), "utf-8");
80
+ expect(entry).toContain("layout: \"container\"");
81
+ expect(entry).toContain("crm:menu.main");
82
+ expect(readFileSync(join(project, "src/crm/solution.yaml"), "utf-8")).toContain("name: crm");
83
+ expect(JSON.parse(readFileSync(join(project, "src/crm/web/locales/zh-CN.json"), "utf-8")).menu.main).toBeTruthy();
84
+ expect(JSON.parse(readFileSync(join(project, "src/crm/web/locales/en-US.json"), "utf-8")).menu.main).toBeTruthy();
85
+ // --layout none
86
+ createSolution(project, "erp", { layout: "none" });
87
+ expect(readFileSync(join(project, "src/erp/web/entry.ts"), "utf-8")).toContain("layout: \"none\"");
88
+ });
89
+
90
+ it("n1 round-trip:生成的骨架补产物后 buildBundle({ name }) 直接过", async () => {
91
+ makeModuleRootProject();
92
+ createSolution(project, "crm");
93
+ addWebArtifacts("crm");
94
+ const results = await buildBundle(project, { out: join(project, "dist"), name: "crm" });
95
+ expect(results.map(r => r.name)).toEqual(["crm"]);
96
+ expect(existsSync(results[0]!.tgz)).toBe(true);
97
+ });
98
+
99
+ it("n2 --api:额外生成 api 骨架且 solution.yaml 含 api 段", () => {
100
+ makeModuleRootProject();
101
+ createSolution(project, "crm", { api: true });
102
+ expect(existsSync(join(project, "src/crm/api/manifest.yaml"))).toBe(true);
103
+ expect(readFileSync(join(project, "src/crm/api/manifest.yaml"), "utf-8")).toContain("name: crm");
104
+ expect(readFileSync(join(project, "src/crm/api/api.ts"), "utf-8")).toContain("/api/crm/ping");
105
+ const sol = readFileSync(join(project, "src/crm/solution.yaml"), "utf-8");
106
+ expect(sol).toContain("api:");
107
+ expect(sol).toContain("prefix: /api/crm");
108
+ });
109
+
110
+ it("n7 生成中途失败 → 不残留半截 src/<name>/", () => {
111
+ makeModuleRootProject();
112
+ // 让第二次写盘(entry.ts)必失败:首写(solution.yaml)成功后触发清理路径——
113
+ // 预置冲突文件会先在「目录已存在」守卫被拒,测不到 rmSync 回滚(审查发现 n7 旧用例假绿)
114
+ let calls = 0;
115
+ const spy = vi.spyOn(fs, "writeFileSync").mockImplementation(((...args: unknown[]) => {
116
+ calls++;
117
+ if (calls === 2)
118
+ throw new Error("ENOSPC: disk full");
119
+ return (writeFileSyncOrig as (...a: unknown[]) => unknown)(...args);
120
+ }) as typeof fs.writeFileSync);
121
+ try {
122
+ expect(() => createSolution(project, "crm")).toThrow(/disk full/);
123
+ }
124
+ finally {
125
+ spy.mockRestore();
126
+ }
127
+ expect(existsSync(join(project, "src", "crm"))).toBe(false);
128
+ });
129
+ });
@@ -34,7 +34,7 @@ afterEach(() => {
34
34
  describe.skipIf(!smokeReady)("真机冒烟(需 OJM_SMOKE_URL/OJM_SMOKE_USER/OJM_SMOKE_PASS)", () => {
35
35
  it("file 源安装 fixture-valid 包 → 终态 done", { timeout: 600_000 }, async () => {
36
36
  // 先出可安装 tgz(同 bundle.test.ts:预置已构建产物再 --bundle 组装)
37
- const apiDist = join(project, "api/dist/demo-solution-0.1.0");
37
+ const apiDist = join(project, "dist/api/demo-solution-0.1.0");
38
38
  const { mkdirSync, writeFileSync } = await import("node:fs");
39
39
  mkdirSync(join(apiDist, "ping"), { recursive: true });
40
40
  // 迁移 SQL 随夹具 src 真源(0001__init.sql 建 note 表);B-11 硬验收:appliedSeq > 0 可断言
@@ -45,11 +45,12 @@ describe.skipIf(!smokeReady)("真机冒烟(需 OJM_SMOKE_URL/OJM_SMOKE_USER/OJ
45
45
  cpSync(join(FIXTURE_VALID, "api/src/demo-solution/schema.yaml"), join(apiDist, "schema.yaml"));
46
46
  writeFileSync(join(apiDist, "routes.js"), "export {};\n");
47
47
  writeFileSync(join(apiDist, "ping/api.js"), "export async function ping() {}\n");
48
- const webDist = join(project, "web/dist/modules/demo-solution/0.1.0");
48
+ const webDist = join(project, "dist/web/modules/demo-solution/0.1.0");
49
49
  mkdirSync(webDist, { recursive: true });
50
50
  writeFileSync(join(webDist, "entry.js"), "export default {};\n");
51
51
  writeFileSync(join(webDist, "demo-solution.css"), ".demo{}\n");
52
- const { tgz } = await buildBundle(project, { out: join(project, "dist") });
52
+ const [built] = await buildBundle(project, { out: join(project, "dist") });
53
+ const { tgz } = built!;
53
54
 
54
55
  const record = await installCommand(tgz, { url: SMOKE_URL ?? DEFAULT_SITE_URL, user: SMOKE_USER, pass: SMOKE_PASS });
55
56
  expect(record.status).toBe("done");
@@ -16,11 +16,24 @@ export type ParseResult = { ok: true, manifest: BundleManifest } | { ok: false,
16
16
 
17
17
  // ── 契约 §2/§4 正则与清单(与 preflight.ts 逐字对应;改动须先改契约再同步双端与夹具)──
18
18
 
19
- /** C2-01:name 白名单 */
20
- const NAME_RE = /^[a-z][a-z0-9-]{0,63}$/;
19
+ /** C2-01:name 白名单(导出供 ojm new/--bundle <name> 入口校验共用,DRY) */
20
+ export const NAME_RE = /^[a-z][a-z0-9-]{0,63}$/;
21
+
22
+ /**
23
+ * 方案名入口校验(`--bundle <name>` / `ojm new <name>` 共用,先校验再拼路径):
24
+ * 与契约 C2-01 同一条正则;入口先给可读错误,契约校验再做正式判罚。
25
+ * 放在这里而非 bundle.ts——new 入口不应拖入打包模块图(esbuild/deterministic-tar 等)。
26
+ */
27
+ export function assertValidSolutionName(name: string, where: string): void {
28
+ if (!NAME_RE.test(name)) {
29
+ throw new Error(
30
+ `[ojm] ${where}:「${name}」非法——须为 kebab-case(小写字母/数字/连字符,首字符为小写字母)。`,
31
+ );
32
+ }
33
+ }
21
34
 
22
35
  /** §4.2 保留名清单(C2-01/13/14 共用;market 经 R9 恢复保留) */
23
- const RESERVED_NAMES = new Set(["_platform", "system", "auth", "market", "framework", "health", "plugins", "_system"]);
36
+ export const RESERVED_NAMES = new Set(["_platform", "system", "auth", "market", "framework", "health", "plugins", "_system"]);
24
37
 
25
38
  /** C2-02 字符集:[0-9A-Za-z.-](禁 +——oj validate_version 拒装,附录 B-1;禁 _——契约收紧) */
26
39
  const VERSION_CHARS_RE = /^[0-9A-Z.-]+$/i;
@@ -100,6 +100,30 @@ describe("given 一个装了 oj 二进制的源工程 When 自举隔离站点",
100
100
  await site.cleanup();
101
101
  });
102
102
 
103
+ it("then 起站 api 路径 = 构建产物实际位置 <root>/dist/api(module-root;旧写死 api/dist 起站即挂)", async () => {
104
+ const src = newSourceProject();
105
+ const { started, starter } = fakeStarter();
106
+
107
+ const site = await bootstrapIsolatedSite({ projectRoot: src, starter });
108
+
109
+ // starter 第三参是 oj serve 的 --api-path:必须与 buildBackend 产物落点同源
110
+ // (layout.ts module-root 口径 apiDist = <root>/dist/api;写死 api/dist 会在
111
+ // mounts 校验报 No such file or directory,2026-10-23 演练实踩)
112
+ expect(started[0]?.apiSrc).toBe(join(site.root, "dist", "api"));
113
+ await site.cleanup();
114
+ });
115
+
116
+ it("then 种子管理员 = superadmin(seed 账号名=角色名口径;admin 已被解绑,用它会 403)", async () => {
117
+ const src = newSourceProject();
118
+ const { starter } = fakeStarter();
119
+
120
+ const site = await bootstrapIsolatedSite({ projectRoot: src, starter });
121
+
122
+ expect(site.admin.user).toBe("superadmin");
123
+ expect(site.admin.pass).toBe("123456");
124
+ await site.cleanup();
125
+ });
126
+
103
127
  it("then CDN 目录在临时根内且已落好 fixture 包与索引", async () => {
104
128
  const src = newSourceProject();
105
129
  const { starter } = fakeStarter();
@@ -8,7 +8,7 @@ import { tmpdir } from "node:os";
8
8
  import { join } from "node:path";
9
9
  import { describe, expect, it } from "vitest";
10
10
 
11
- import { setupMarketUrl } from "../setup";
11
+ import { loginPasswordAfterSetup, setupMarketUrl } from "../setup";
12
12
 
13
13
  const MOUNTS = " - prefix: /api\n api: src\n - prefix: /cdn\n web: ../cdn\n";
14
14
  const CONFIG = `mounts:\n${MOUNTS}server:\n host: 127.0.0.1\n port: 9778\n`;
@@ -112,3 +112,10 @@ describe("given 改密后写设置仍被拒 When setup", () => {
112
112
  .toThrow(/must_change|权限|设置/);
113
113
  });
114
114
  });
115
+
116
+ describe("given setup 返回 changedPassword When 选 setup 后的登录口令", () => {
117
+ it("then 改密已发生用 newPass,未改密用原种子口令(must_change=0 是当前 seed 常态)", () => {
118
+ expect(loginPasswordAfterSetup({ siteUrl: "", cdnBaseUrl: "", changedPassword: true }, "new-pass", "123456")).toBe("new-pass");
119
+ expect(loginPasswordAfterSetup({ siteUrl: "", cdnBaseUrl: "", changedPassword: false }, "new-pass", "123456")).toBe("123456");
120
+ });
121
+ });
@@ -36,7 +36,9 @@ import { parseFixtureSpec } from "./fixture";
36
36
  import { publishPackage } from "./publish";
37
37
 
38
38
  /** 种子管理员(隔离库全新 seed;与 templates seed.sql 一致) */
39
- const SEED_ADMIN_USER = "admin";
39
+ // 种子管理员 = superadmin:模板 seed 账号名=角色名(3d93063f 起 admin 被幂等解绑 superadmin
40
+ // 角色),写 market 安全设置要求 actor 持 superadmin 角色——用 admin 会 403(2026-10-23 演练实踩)。
41
+ const SEED_ADMIN_USER = "superadmin";
40
42
  const SEED_ADMIN_PASS = "123456";
41
43
 
42
44
  /** Ed25519 PKCS#8 DER 前缀(RFC 8410 固定头;与 gen-signed-fixtures.mjs 同一手法) */
@@ -50,7 +52,7 @@ export interface IsolatedPublisher {
50
52
  }
51
53
 
52
54
  export interface IsolatedSite {
53
- /** 临时根(含 config.yaml / src/ / api/dist/ / bin/ / cdn/) */
55
+ /** 临时根(含 config.yaml / src/ / dist/api/ / bin/ / cdn/) */
54
56
  root: string
55
57
  port: number
56
58
  baseUrl: string
@@ -96,7 +98,7 @@ export async function bootstrapIsolatedSite(opts: BootstrapOptions = {}): Promis
96
98
 
97
99
  const root = mkdtempSync(path.join(tmpdir(), "ojm-trial-"));
98
100
  // 新布局(`src/<name>/{api,web}`):config.yaml 在隔离根,源码树根为 <root>/src,
99
- // 构建产物仍是扁平根 <root>/api/dist(与真实工程同形)。
101
+ // 构建产物仍是扁平根 <root>/dist/api(与真实工程同形)。
100
102
  const srcDir = path.join(root, "src");
101
103
  const configPath = path.join(root, "config.yaml");
102
104
  const cdnDir = path.join(root, "cdn");
@@ -143,9 +145,9 @@ export async function bootstrapIsolatedSite(opts: BootstrapOptions = {}): Promis
143
145
  mkdirSync(siteDir, { recursive: true });
144
146
  writeFileSync(path.join(siteDir, "index.html"), "<!doctype html><html><body>ojm trial site</body></html>\n", "utf-8");
145
147
  cfg = cfg.replace("web: ./cdn", `web: ${cdnDir}\n - prefix: /\n web: ${siteDir}\n spa: true`);
146
- // 安装目标恒为 release 部署:装好的模块落在 api/dist,dev 形态(--api-path src)发现不了。
148
+ // 安装目标恒为 release 部署:装好的模块落在 dist/api,dev 形态(--api-path src)发现不了。
147
149
  // 故隔离站点以 dist 起站,platform.root 同步改写(oj 校验 root 必须是生效的 api 挂载目录)。
148
- // root 相对 **config 所在目录** 解析——config 在隔离根,产物在 <root>/api/dist,故不能写死
150
+ // root 相对 **config 所在目录** 解析——config 在隔离根,产物在 <root>/dist/api,故不能写死
149
151
  // `dist`(那是 config 还在 api/ 时的旧口径),按布局取值。
150
152
  const apiDistRel = path.relative(root, resolveLayout(root).apiDist).split(path.sep).join("/");
151
153
  cfg = cfg.replace(" root: src", ` root: ${apiDistRel}`);
@@ -172,14 +174,14 @@ export async function bootstrapIsolatedSite(opts: BootstrapOptions = {}): Promis
172
174
  await publishPackage({ projectRoot: root, fixture: opts.fixture, id: keyId, privateKeyPem: publisher.privateKeyPem });
173
175
  await buildIndex({ projectRoot: root, id: keyId, privateKeyPem: publisher.privateKeyPem });
174
176
 
175
- // 7) 后端构建(api/dist + versions.json;安装与 reloading 都依赖它)
177
+ // 7) 后端构建(dist/api + versions.json;安装与 reloading 都依赖它)
176
178
  await buildBackend(root);
177
179
 
178
180
  // 7b) 起 oj(starter 注入供测试);ready 失败即整体作废,不留孤儿
179
181
  const starter = opts.starter ?? ((cfgPath: string, base: string, apiSrc: string) => startOj(cfgPath, base, apiSrc));
180
182
  let oj: OjProcess;
181
183
  try {
182
- oj = starter(configPath, "/api", path.join(root, "api", "dist"));
184
+ oj = starter(configPath, "/api", resolveLayout(root).apiDist);
183
185
  await oj.ready;
184
186
  }
185
187
  catch (error) {
@@ -258,7 +260,7 @@ function teardownReport(root: string, port: number): string[] {
258
260
  `隔离根:${root}`,
259
261
  " config.yaml + config/ 证书 + db.sqlite 站点配置面",
260
262
  " src/ 后端模块源码树(src/<name>/api,拷自 cli 内置模板)",
261
- " api/dist/ 构建产物(安装目标)",
263
+ " dist/api/ 构建产物(安装目标)",
262
264
  " bin/ oj 二进制与插件(拷贝自源工程)",
263
265
  " cdn/ 隔离 CDN(fixture 包 + 两份索引 + 签名)",
264
266
  `监听端口:127.0.0.1:${port}(进程已停)`,
@@ -3,9 +3,9 @@
3
3
  *
4
4
  * 两条不能破的规矩:
5
5
  * 1. **不许硬编码端口/主机/挂载路径**——全部从 `api/config.yaml` 读。
6
- * dev 形态下 `/cdn` 只在 oj 子进程(`server.port`)上成立,dev server 自己(5174)
7
- * 会把 `/cdn/*` 交给静态层、未命中就回落 index.html(设计 §3.1.1);写成相对路径或
8
- * 写死 9778,市场页拿到的会是 HTML 而不是索引 YAML。
6
+ * (2026-10-23 起 dev 形态 ojm 已把 /cdn 反代给 oj 子进程,`framework.market_cdn_url`
7
+ * 允许写相对路径 `/cdn` = 本站点自供,由消费侧按请求源补全;本模块的绝对基址推导
8
+ * 只服务 `market setup` 写外部地址的场景。)
9
9
  * 2. **基址无尾斜杠**——`market/api.ts` 按 `${base}/bundles.yaml` 拼。
10
10
  *
11
11
  * 文本版(`resolveCdnLayoutFromConfig`)与工程版(`resolveCdnLayout`)分工:前者纯函数便于单测,