@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
@@ -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.20",
11
- "@oj-module/runtime/contract": "0.2.20",
12
- "@oj-module/runtime/contract/errors": "0.2.20",
10
+ "@oj-module/runtime": "0.2.25",
11
+ "@oj-module/runtime/contract": "0.2.25",
12
+ "@oj-module/runtime/contract/errors": "0.2.25",
13
13
  "@plane/kit": "0.1.20",
14
14
  "@plane/editor": "0.1.20",
15
15
  "antd": "6.6.1",
@@ -1,7 +1,7 @@
1
1
  // buildBackend spec:宿主 versions.json 双落点(方案安装的 C2-09 前置)。
2
2
  //
3
- // 安装器按 B-11 布局读 `<api>/dist/versions.json`(与 manifests.yaml 同层)做 peerRuntime
4
- // 强制比对;而站点壳的 versions.json 落在 web/dist。两者不同源时,新工程 `ojm build`
3
+ // 安装器按 B-11 布局读 `dist/api/versions.json`(与 manifests.yaml 同层)做 peerRuntime
4
+ // 强制比对;而站点壳的 versions.json 落在 dist/web。两者不同源时,新工程 `ojm build`
5
5
  // 完仍会在 preflight 被 C2-09 拒装——这条用例钉住「同源双写」,防止回归。
6
6
  import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { tmpdir } from "node:os";
@@ -27,19 +27,19 @@ function newProject(): string {
27
27
  writeFileSync(join(root, "api", "src", "demo", "manifest.yaml"), "name: demo\nversion: 0.1.0\n", "utf-8");
28
28
  writeFileSync(join(root, "api", "config.yaml"), "server:\n port: 9778\n", "utf-8");
29
29
  // 模拟 oj build 已产出的 manifests.yaml(验证收尾动作不破坏既有产物)
30
- mkdirSync(join(root, "api", "dist"), { recursive: true });
31
- writeFileSync(join(root, "api", "dist", "manifests.yaml"), "demo: 0.1.0\n", "utf-8");
30
+ mkdirSync(join(root, "dist", "api"), { recursive: true });
31
+ writeFileSync(join(root, "dist", "api", "manifests.yaml"), "demo: 0.1.0\n", "utf-8");
32
32
  fakeOj(root);
33
33
  return root;
34
34
  }
35
35
 
36
36
  describe("given 宿主工程有 api/src When ojm build 后端", () => {
37
- it("then api/dist/versions.json 与 shell-dist 同源落地(安装器 C2-09 前置)", async () => {
37
+ it("then dist/api/versions.json 与 shell-dist 同源落地(安装器 C2-09 前置)", async () => {
38
38
  const root = newProject();
39
39
 
40
40
  await buildBackend(root);
41
41
 
42
- const hostVersions = join(root, "api", "dist", "versions.json");
42
+ const hostVersions = join(root, "dist", "api", "versions.json");
43
43
  expect(existsSync(hostVersions)).toBe(true);
44
44
  const parsed = JSON.parse(readFileSync(hostVersions, "utf-8")) as Record<string, string>;
45
45
  expect(typeof parsed["@oj-module/runtime"]).toBe("string");
@@ -50,16 +50,16 @@ describe("given 宿主工程有 api/src When ojm build 后端", () => {
50
50
 
51
51
  await buildBackend(root);
52
52
 
53
- expect(readFileSync(join(root, "api", "dist", "manifests.yaml"), "utf-8")).toBe("demo: 0.1.0\n");
53
+ expect(readFileSync(join(root, "dist", "api", "manifests.yaml"), "utf-8")).toBe("demo: 0.1.0\n");
54
54
  });
55
55
  });
56
56
 
57
57
  describe("given 纯前端工程(无 api/src)When ojm build 后端", () => {
58
- it("then 跳过构建、不产出 api/dist", async () => {
58
+ it("then 跳过构建、不产出 dist/api", async () => {
59
59
  const root = mkdtempSync(join(tmpdir(), "ojm-build-backend-fe-"));
60
60
 
61
61
  expect(await buildBackend(root)).toBe(false);
62
- expect(existsSync(join(root, "api", "dist"))).toBe(false);
62
+ expect(existsSync(join(root, "dist", "api"))).toBe(false);
63
63
  });
64
64
  });
65
65
 
@@ -93,7 +93,7 @@ function newModuleRootProject(): string {
93
93
  }
94
94
 
95
95
  describe("given module-root 布局 When ojm build 后端", () => {
96
- it("then 一次调用、单一输出根:--module-root src + -o api/dist(与 -d 互斥)", async () => {
96
+ it("then 一次调用、单一输出根:--module-root src + -o dist/api(与 -d 互斥)", async () => {
97
97
  const root = newModuleRootProject();
98
98
  fakeOjRecording(root, true);
99
99
 
@@ -102,9 +102,9 @@ describe("given module-root 布局 When ojm build 后端", () => {
102
102
  // 逐模块 `-o <tmp>` 会让每个 tmp 各写一份 manifests.yaml,锁与兄弟目录关系全丢
103
103
  const invoked = readFileSync(join(root, "bin", "invoked"), "utf-8").trim();
104
104
  expect(invoked).toBe(
105
- `build -c ${join(root, "config.yaml")} --module-root ${join(root, "src")} -o ${join(root, "api", "dist")} --no-minify`,
105
+ `build -c ${join(root, "config.yaml")} --module-root ${join(root, "src")} -o ${join(root, "dist", "api")} --no-minify`,
106
106
  );
107
- expect(existsSync(join(root, "api", "dist", "versions.json"))).toBe(true);
107
+ expect(existsSync(join(root, "dist", "api", "versions.json"))).toBe(true);
108
108
  });
109
109
 
110
110
  it("then oj 缺 --module-root 能力时 fail-fast,不落任何产物", async () => {
@@ -113,6 +113,6 @@ describe("given module-root 布局 When ojm build 后端", () => {
113
113
 
114
114
  await expect(buildBackend(root)).rejects.toThrow(/不支持 --module-root/);
115
115
  expect(existsSync(join(root, "bin", "invoked"))).toBe(false); // 没跑 oj build
116
- expect(existsSync(join(root, "api", "dist"))).toBe(false);
116
+ expect(existsSync(join(root, "dist", "api"))).toBe(false);
117
117
  });
118
118
  });
@@ -0,0 +1,225 @@
1
+ import type { Lang } from "../usage";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { buildCli, rewriteHelpArgv } from "../cli";
7
+
8
+ /**
9
+ * dispatch 契约(cac 驱动后):
10
+ * - 帮助请求不执行 action(market setup --help 不建 CDN)
11
+ * - 未知命令触发 command:*(index.ts 兜底打帮助 + exit 1)
12
+ * - 校验从 parse*Args 搬入 action 后行为保持(new --layout / vendor tag)
13
+ * - 位置参数与 variadic 映射(merge out in1 in2)
14
+ * - --bundle 值映射到 buildBundle
15
+ * - help 词形重写(ojm help <cmd> → <cmd> --help)
16
+ */
17
+
18
+ const logs: string[] = [];
19
+ let exitSpy: ReturnType<typeof vi.fn>;
20
+
21
+ // 提升式 mock:vitest 测试体内注册的 vi.mock 拦不到 cli.ts 的 dynamic import,须 hoisted 顶层注册
22
+ const buildBundleMock = vi.hoisted(() => vi.fn(async () => [{ file: "x.tgz" }]));
23
+ vi.mock("../bundle", () => ({ buildBundle: buildBundleMock }));
24
+ const vendorCommandMock = vi.hoisted(() => vi.fn(async () => {}));
25
+ vi.mock("../vendor", () => ({ vendorCommand: vendorCommandMock }));
26
+
27
+ beforeEach(() => {
28
+ logs.length = 0;
29
+ exitSpy = vi.fn();
30
+ vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => logs.push(args.join(" ")));
31
+ vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => logs.push(args.join(" ")));
32
+ });
33
+
34
+ afterEach(() => {
35
+ vi.restoreAllMocks();
36
+ buildBundleMock.mockClear();
37
+ vendorCommandMock.mockClear();
38
+ });
39
+
40
+ interface RunResult { out: string, unknown: string[], exitCode: number | undefined }
41
+
42
+ /** 跑一次 parse:捕获输出、command:* 与 exit;动作异步链经条件轮询拍平(vitest 下 dynamic import 解析需数百毫秒) */
43
+ async function run(argv: string[], lang: Lang, cwd: string, until?: () => boolean): Promise<RunResult> {
44
+ const cli = buildCli(cwd, lang, { exit: exitSpy as unknown as (code: number) => never });
45
+ const unknown: string[] = [];
46
+ cli.on("command:*", () => unknown.push(String(cli.args[0])));
47
+ cli.parse(["node", "ojm", ...argv], { run: true });
48
+ try {
49
+ await vi.waitUntil(() => logs.length > 0 || exitSpy.mock.calls.length > 0 || unknown.length > 0 || !!until?.(), { timeout: 2500, interval: 10 });
50
+ }
51
+ catch {
52
+ // 无输出型动作(如帮助打屏后无日志):兜底继续
53
+ }
54
+ await new Promise(r => setTimeout(r, 50));
55
+ return { out: logs.join("\n"), unknown, exitCode: exitSpy.mock.calls[0]?.[0] as number | undefined };
56
+ }
57
+
58
+ let tmp: string;
59
+ beforeEach(() => {
60
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ojm-cli-test-"));
61
+ });
62
+
63
+ describe("帮助不执行 action", () => {
64
+ it("子命令 --help 只出单命令帮助(不叠顶层分组帮助)", async () => {
65
+ // 回归:cac --help 后 unsetMatchedCommand 会让 args[0]=子命令,误叠顶层帮助(index.ts 已按 options.help 过滤)
66
+ const r = await run(["market", "verify", "--help"], "zh-CN", tmp);
67
+ expect(r.out).toContain("离线验包");
68
+ expect(r.out).not.toContain("oj 子命令透传");
69
+ });
70
+
71
+ it("market setup --help 打帮助、不触发 setup、不 exit", async () => {
72
+ const r = await run(["market", "setup", "--help"], "zh-CN", tmp);
73
+ expect(r.out).toContain("market");
74
+ expect(r.out).not.toContain("[ojm] 站点");
75
+ expect(r.exitCode).toBeUndefined();
76
+ expect(exitSpy).not.toHaveBeenCalled();
77
+ });
78
+ });
79
+
80
+ describe("单命令帮助(双语)", () => {
81
+ it("build --help 中文:语法 + 选项 + 详解", async () => {
82
+ const r = await run(["build", "--help"], "zh-CN", tmp);
83
+ expect(r.out).toContain("ojm build");
84
+ expect(r.out).toContain("--bundle");
85
+ expect(r.out).toContain("构建后端");
86
+ });
87
+
88
+ it("build --help 英文", async () => {
89
+ const r = await run(["build", "--help"], "en-US", tmp);
90
+ expect(r.out).toContain("Builds the backend");
91
+ });
92
+
93
+ it("market verify --help 按子命令注入详解(中/英)", async () => {
94
+ expect((await run(["market", "verify", "--help"], "zh-CN", tmp)).out).toContain("离线验包");
95
+ expect((await run(["market", "verify", "--help"], "en-US", tmp)).out).toMatch(/offline/i);
96
+ });
97
+
98
+ it("trust add --help 按子命令注入详解(中/英)", async () => {
99
+ expect((await run(["trust", "add", "--help"], "zh-CN", tmp)).out).toContain("新增信任");
100
+ expect((await run(["trust", "add", "--help"], "en-US", tmp)).out).toContain("64-hex public key");
101
+ });
102
+ });
103
+
104
+ describe("未知命令与兜底", () => {
105
+ it("未知命令触发 command:*", async () => {
106
+ const r = await run(["nope"], "zh-CN", tmp);
107
+ expect(r.unknown).toEqual(["nope"]);
108
+ });
109
+ });
110
+
111
+ describe("反常识行为修正(20261023-0937)", () => {
112
+ it("isKnownCommand:已知命令 true / 未知 false", async () => {
113
+ const { isKnownCommand } = await import("../usage");
114
+ expect(isKnownCommand("market")).toBe(true);
115
+ expect(isKnownCommand("trust")).toBe(true);
116
+ expect(isKnownCommand("nope")).toBe(false);
117
+ });
118
+
119
+ it("unknownCommandResult:help 形态报未知命令(双语),code 1", async () => {
120
+ const { unknownCommandResult } = await import("../cli");
121
+ const zh = unknownCommandResult("nope", "zh-CN", true);
122
+ expect(zh.text).toContain("[ojm] 未知命令:nope");
123
+ expect(zh.code).toBe(1);
124
+ const en = unknownCommandResult("nope", "en-US", true);
125
+ expect(en.text).toContain("[ojm] Unknown command: nope");
126
+ expect(en.code).toBe(1);
127
+ });
128
+
129
+ it("unknownCommandResult:非 help 形态给顶层帮助,code 1", async () => {
130
+ const { unknownCommandResult } = await import("../cli");
131
+ const { usageText } = await import("../usage");
132
+ const r = unknownCommandResult("nope", "zh-CN", false);
133
+ expect(r.text).toBe(usageText("zh-CN"));
134
+ expect(r.code).toBe(1);
135
+ });
136
+
137
+ it("localizeCacError:未知选项本地化(双语)+ 指引", async () => {
138
+ const { localizeCacError } = await import("../cli");
139
+ const zh = localizeCacError(new Error("Unknown option `--foo`"), "zh-CN");
140
+ expect(zh).toBeInstanceOf(Error);
141
+ expect((zh as Error).message).toContain("未知选项:--foo");
142
+ expect((zh as Error).message).toContain("--help");
143
+ const en = localizeCacError(new Error("Unknown option `--foo`"), "en-US");
144
+ expect((en as Error).message).toContain("Unknown option: --foo");
145
+ });
146
+
147
+ it("localizeCacError:非匹配错误原样返回(同一实例)", async () => {
148
+ const { localizeCacError } = await import("../cli");
149
+ const err = new Error("[ojm] 别的错误");
150
+ expect(localizeCacError(err, "zh-CN")).toBe(err);
151
+ });
152
+ });
153
+
154
+ describe("校验搬入 action 后行为保持", () => {
155
+ it("new 多余位置参数显式拒绝(F8)", async () => {
156
+ const r = await run(["new", "crm", "erp"], "zh-CN", tmp);
157
+ expect(r.out).toContain("只接受一个方案名");
158
+ expect(r.exitCode).toBe(1);
159
+ });
160
+
161
+ it("new --layout 非 container|none 报错", async () => {
162
+ const r = await run(["new", "demo", "--layout", "bogus"], "zh-CN", tmp);
163
+ expect(r.out).toContain("--layout 只接受 container | none");
164
+ expect(r.exitCode).toBe(1);
165
+ });
166
+
167
+ it("new 缺 name 打印用法并 exit 1", async () => {
168
+ const r = await run(["new"], "zh-CN", tmp);
169
+ expect(r.out).toContain("ojm new <name>");
170
+ expect(r.exitCode).toBe(1);
171
+ });
172
+
173
+ it("vendor 非法 tag 报错", async () => {
174
+ const r = await run(["vendor", "notatag"], "zh-CN", tmp);
175
+ expect(r.out).toContain("非法版本号");
176
+ expect(r.exitCode).toBe(1);
177
+ });
178
+
179
+ it("vendor 0.1.12 规范化补 v 前缀后传入 vendorCommand", async () => {
180
+ await run(["vendor", "0.1.12"], "zh-CN", tmp, () => vendorCommandMock.mock.calls.length > 0);
181
+ expect(vendorCommandMock).toHaveBeenCalledWith(tmp, { force: false, tag: "v0.1.12" });
182
+ });
183
+
184
+ it("vendor --force 透传 force", async () => {
185
+ await run(["vendor", "v0.1.12", "--force"], "zh-CN", tmp, () => vendorCommandMock.mock.calls.length > 0);
186
+ expect(vendorCommandMock).toHaveBeenCalledWith(tmp, { force: true, tag: "v0.1.12" });
187
+ });
188
+ });
189
+
190
+ describe("参数映射", () => {
191
+ it("merge <out> <inputs...> variadic 映射(缺文件报错提及输入名)", async () => {
192
+ const r = await run(["merge", "out.json", "in1.json", "in2.json"], "zh-CN", tmp);
193
+ expect(r.out).toContain("in1.json");
194
+ expect(r.exitCode).toBe(1);
195
+ });
196
+
197
+ it("build --bundle demo 映射 bundleName", async () => {
198
+ const r = await run(["build", "--bundle", "demo"], "zh-CN", tmp, () => buildBundleMock.mock.calls.length > 0);
199
+ expect(buildBundleMock).toHaveBeenCalledWith(tmp, { out: undefined, name: "demo" });
200
+ expect(r.exitCode).toBeUndefined();
201
+ });
202
+
203
+ it("build 裸 --bundle 无名称(全部用户方案)", async () => {
204
+ await run(["build", "--bundle"], "zh-CN", tmp, () => buildBundleMock.mock.calls.length > 0);
205
+ expect(buildBundleMock).toHaveBeenCalledWith(tmp, { out: undefined, name: undefined });
206
+ });
207
+ });
208
+
209
+ describe("help 词形", () => {
210
+ it("ojm help build → build --help", () => {
211
+ expect(rewriteHelpArgv(["help", "build"])).toEqual(["build", "--help"]);
212
+ });
213
+
214
+ it("ojm help trust add → trust add --help", () => {
215
+ expect(rewriteHelpArgv(["help", "trust", "add"])).toEqual(["trust", "add", "--help"]);
216
+ });
217
+
218
+ it("ojm help → 顶层 --help", () => {
219
+ expect(rewriteHelpArgv(["help"])).toEqual(["--help"]);
220
+ });
221
+
222
+ it("非 help 调用不改动", () => {
223
+ expect(rewriteHelpArgv(["build", "--bundle"])).toEqual(["build", "--bundle"]);
224
+ });
225
+ });
@@ -0,0 +1,29 @@
1
+ // BDD:脚手架 package.json scripts 与模板的双写守卫(T3,发布工程师评审建议)。
2
+ // 生成器(init.ts generatePackageJson)与模板(templates/package.json)各写一份 scripts,
3
+ // 人肉同步必漂移(实证:typecheck:api 曾漏发生成器)——钉成测试:生成 scripts 的每个键
4
+ // 都必须存在于模板 scripts(值一致),新增 script 先改模板再改生成器。
5
+ import { readFileSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { describe, expect, it } from "vitest";
9
+ import { generatePackageJson } from "../init";
10
+
11
+ const cliRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
12
+
13
+ describe("脚手架 package.json scripts 双写守卫(T3)", () => {
14
+ it("生成 scripts 的每个键都存在于模板且值一致", () => {
15
+ const template = JSON.parse(readFileSync(join(cliRoot, "templates/package.json"), "utf-8"));
16
+ const generated = generatePackageJson(cliRoot, "guard-check");
17
+ for (const [key, value] of Object.entries(generated.scripts)) {
18
+ expect(template.scripts[key], `生成器有而模板无的 script:${key}(先改 templates/package.json)`)
19
+ .toBe(value);
20
+ }
21
+ });
22
+
23
+ it("多方案 scripts 齐备:new / bundle / typecheck:api", () => {
24
+ const generated = generatePackageJson(cliRoot, "guard-check");
25
+ expect(generated.scripts.new).toBe("ojm new");
26
+ expect(generated.scripts.bundle).toBe("ojm build --bundle");
27
+ expect(generated.scripts["typecheck:api"]).toBe("tsc --noEmit -p src/tsconfig.json");
28
+ });
29
+ });
@@ -43,16 +43,16 @@ describe("resolveLayout / module-root", () => {
43
43
 
44
44
  expect(layout.kind).toBe("module-root");
45
45
  expect(layout.modulesSrc).toBe(join(root, "src"));
46
- expect(layout.distDir).toBe(join(root, "web", "dist"));
47
- expect(layout.apiDist).toBe(join(root, "api", "dist"));
46
+ expect(layout.distDir).toBe(join(root, "dist", "web"));
47
+ expect(layout.apiDist).toBe(join(root, "dist", "api"));
48
48
  expect(layout.watchTarget).toBe(join(root, "src"));
49
49
  // --module-root 取「源码树根目录」(oj 在其子树内递归找 manifest.yaml)
50
50
  expect(layout.moduleRoot).toBe(join(root, "src"));
51
51
  });
52
52
 
53
- it("web/dist 存在不会被误判成 flat 布局", () => {
53
+ it("dist/web 存在不会被误判成 flat 布局(历史产物目录不算源码)", () => {
54
54
  const root = newModuleRootProject();
55
- mkdirSync(join(root, "web", "dist", "modules", "wiki", "0.1.0"), { recursive: true });
55
+ mkdirSync(join(root, "dist", "web", "modules", "wiki", "0.1.0"), { recursive: true });
56
56
  expect(resolveLayout(root).kind).toBe("module-root");
57
57
  });
58
58
 
@@ -102,11 +102,13 @@ describe("resolveLayout / 历史布局", () => {
102
102
  expect(layout.kind).toBe("new");
103
103
  expect(layout.modulesSrc).toBe(join(root, "web", "src"));
104
104
  expect(layout.moduleRoot).toBeUndefined();
105
+ expect(layout.distDir).toBe(join(root, "dist", "web"));
106
+ expect(layout.apiDist).toBe(join(root, "dist", "api"));
105
107
  expect(apiSrcOf(layout, "wiki")).toBe(join(root, "api", "src", "wiki"));
106
108
  expect(webSrcOf(layout, "wiki")).toBe(join(root, "web", "src", "wiki"));
107
109
  });
108
110
 
109
- it("web/<name>/entry.ts 识别为 flat,产物落 build/", () => {
111
+ it("web/<name>/entry.ts 识别为 flat,web 产物落 build/(框架根仓约定不变),api 产物归 dist/api", () => {
110
112
  const root = newRoot();
111
113
  writeFile(root, "web/home/entry.ts", "export default {};\n");
112
114
  writeFile(root, "web/home/__client/api.ts", "export {};\n");
@@ -115,6 +117,7 @@ describe("resolveLayout / 历史布局", () => {
115
117
  expect(layout.kind).toBe("flat");
116
118
  expect(layout.modulesSrc).toBe(join(root, "web"));
117
119
  expect(layout.distDir).toBe(join(root, "build"));
120
+ expect(layout.apiDist).toBe(join(root, "dist", "api"));
118
121
  expect(webSrcOf(layout, "home")).toBe(join(root, "web", "home"));
119
122
  });
120
123
 
@@ -0,0 +1,59 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+
6
+ import { createSolution } from "../new";
7
+
8
+ /**
9
+ * ojm new 骨架内容契约(20261023-1144 计划)。
10
+ * 背景:--api 骨架曾只产出裸命名导出 `export async function ping()`,
11
+ * oj 内省只读 default export 且链尾须为 HTTP 动词 → 打包后 routes.js 恒空、
12
+ * 装后探活 /api/<name>/ping 必然 404。骨架必须「生成即可路由」。
13
+ */
14
+
15
+ const tmpRoots: string[] = [];
16
+
17
+ function projectRoot(): string {
18
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "ojm-new-scaffold-"));
19
+ tmpRoots.push(root);
20
+ // createSolution 的 N0 工程判定:web.config.ts 与 src/ 至少一个
21
+ fs.mkdirSync(path.join(root, "src"), { recursive: true });
22
+ return root;
23
+ }
24
+
25
+ afterEach(() => {
26
+ for (const root of tmpRoots.splice(0)) {
27
+ fs.rmSync(root, { recursive: true, force: true });
28
+ }
29
+ });
30
+
31
+ describe("ojm new --api 骨架可路由(BDD)", () => {
32
+ it("给定 createSolution(api: true),生成的 api.ts 是 default-export 嵌套组形状", () => {
33
+ const root = projectRoot();
34
+ const result = createSolution(root, "crm", { api: true, layout: "none" });
35
+ const apiTs = fs.readFileSync(path.join(result.dir, "api", "api.ts"), "utf8");
36
+
37
+ // Then:default export 承载路由组(oj 内省只读 default export)
38
+ expect(apiTs).toContain("export default { ping }");
39
+ // Then:链尾是 HTTP 动词 get(METHODS 白名单),组名 ping → GET /api/crm/ping
40
+ expect(apiTs).toMatch(/const ping = \{[\s\S]*async get\(\)/);
41
+ // Then:落实注释承诺——出参挂平级 .response(pong: boolean)
42
+ expect(apiTs).toContain("ping.get.response");
43
+ expect(apiTs).toContain("pong");
44
+ // Then:不再有「裸命名导出而无 default export」的旧形状
45
+ expect(apiTs).not.toMatch(/^export async function ping/m);
46
+ });
47
+
48
+ it("给定 createSolution(api: true),solution.yaml 过自家 market verify 五必填(20261023-1155)", async () => {
49
+ const root = projectRoot();
50
+ const result = createSolution(root, "crm", { api: true, layout: "none" });
51
+ const { parse: yamlParse } = await import("yaml");
52
+ const doc = yamlParse(fs.readFileSync(path.join(result.dir, "solution.yaml"), "utf8")) as Record<string, unknown>;
53
+
54
+ // Then:market/verify.ts structureCheck 五必填 name/version/summary/peerRuntime/archive 齐备
55
+ for (const key of ["name", "version", "summary", "peerRuntime", "archive"])
56
+ expect(doc[key], `solution.yaml 缺 ${key}(market verify 必填)`).toBeDefined();
57
+ expect((doc.archive as Record<string, unknown>).file).toBe("crm-0.1.0.tgz");
58
+ });
59
+ });
@@ -1,69 +1,67 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { getCommandHelp, resolveHelpRequest } from "../usage";
2
+ import { resolveLang, usageText } from "../usage";
3
3
 
4
- describe("getCommandHelp", () => {
5
- it("返回命令级帮助(init)", () => {
6
- const t = getCommandHelp("init");
7
- expect(t).toBeDefined();
8
- expect(t).toContain("ojm init");
4
+ describe("resolveLang", () => {
5
+ it("显式 OJM_LANG 优先于系统 locale", () => {
6
+ expect(resolveLang({ OJM_LANG: "en-US", LANG: "zh_CN.UTF-8" } as NodeJS.ProcessEnv)).toBe("en-US");
7
+ expect(resolveLang({ OJM_LANG: "zh-CN", LANG: "en_US.UTF-8" } as NodeJS.ProcessEnv)).toBe("zh-CN");
9
8
  });
10
9
 
11
- it("子命令优先:market setup 命中专属条目", () => {
12
- expect(getCommandHelp("market", "setup")).toContain("ojm market setup");
10
+ it("无显式设置时按 LANG/LC_ALL 前缀探测英文", () => {
11
+ expect(resolveLang({ LANG: "en_US.UTF-8" } as NodeJS.ProcessEnv)).toBe("en-US");
12
+ expect(resolveLang({ LC_ALL: "en_GB.UTF-8" } as NodeJS.ProcessEnv)).toBe("en-US");
13
+ expect(resolveLang({ LANG: "C" } as NodeJS.ProcessEnv)).toBe("zh-CN");
13
14
  });
14
15
 
15
- it("子命令无专属条目时回退到命令级(market 无 sub)", () => {
16
- const t = getCommandHelp("market");
17
- expect(t).toContain("ojm market");
16
+ it("非法值回退 zh-CN", () => {
17
+ expect(resolveLang({ OJM_LANG: "fr" } as NodeJS.ProcessEnv)).toBe("zh-CN");
18
18
  });
19
19
 
20
- it("trust 子命令(add/revoke/confirm/ls)各自有专属条目", () => {
21
- for (const sub of ["ls", "add", "revoke", "confirm"]) {
22
- const t = getCommandHelp("trust", sub);
23
- expect(t, `trust ${sub}`).toBeDefined();
24
- expect(t).toContain(`ojm trust ${sub}`);
25
- }
26
- });
27
-
28
- it("未知命令返回 undefined", () => {
29
- expect(getCommandHelp("nope")).toBeUndefined();
20
+ it("全缺省为 zh-CN", () => {
21
+ expect(resolveLang({} as NodeJS.ProcessEnv)).toBe("zh-CN");
30
22
  });
31
23
  });
32
24
 
33
- describe("resolveHelpRequest", () => {
34
- it("ojm --help → 顶层(无 cmd)", () => {
35
- expect(resolveHelpRequest(["--help"])).toEqual({ cmd: undefined, sub: undefined });
36
- });
25
+ describe("usageText 分组(cac 渲染)", () => {
26
+ const ZH_TITLES = ["工程脚手架", "开发与构建", "清单与诊断", "方案市场(站点侧生命周期)", "发布与签名(作者侧)", "本地市场模拟 CDN", "oj 子命令透传"];
27
+ const EN_TITLES = ["Project scaffolding", "Development & build", "Manifest & diagnostics", "Marketplace (site-side lifecycle)", "Publishing & signing (author side)", "Local marketplace mock CDN", "oj subcommand passthrough"];
37
28
 
38
- it("ojm -h → 顶层(无 cmd)", () => {
39
- expect(resolveHelpRequest(["-h"])).toEqual({ cmd: undefined, sub: undefined });
40
- });
41
-
42
- it("ojm init --help → cmd=init", () => {
43
- expect(resolveHelpRequest(["init", "--help"])).toEqual({ cmd: "init", sub: undefined });
44
- });
45
-
46
- it("ojm trust add --help → cmd=trust sub=add", () => {
47
- expect(resolveHelpRequest(["trust", "add", "--help"])).toEqual({ cmd: "trust", sub: "add" });
48
- });
49
-
50
- it("ojm market setup --help → cmd=market sub=setup", () => {
51
- expect(resolveHelpRequest(["market", "setup", "--help"])).toEqual({ cmd: "market", sub: "setup" });
29
+ it("中文分组头齐全且顺序固定", () => {
30
+ const text = usageText("zh-CN");
31
+ let last = -1;
32
+ for (const title of ZH_TITLES) {
33
+ const i = text.indexOf(`${title}:`);
34
+ expect(i, title).toBeGreaterThan(last);
35
+ last = i;
36
+ }
52
37
  });
53
38
 
54
- it("ojm help init → cmd=init", () => {
55
- expect(resolveHelpRequest(["help", "init"])).toEqual({ cmd: "init", sub: undefined });
39
+ it("英文分组头齐全且顺序固定", () => {
40
+ const text = usageText("en-US");
41
+ let last = -1;
42
+ for (const title of EN_TITLES) {
43
+ const i = text.indexOf(`${title}:`);
44
+ expect(i, title).toBeGreaterThan(last);
45
+ last = i;
46
+ }
56
47
  });
57
48
 
58
- it("ojm help trust add → cmd=trust sub=add", () => {
59
- expect(resolveHelpRequest(["help", "trust", "add"])).toEqual({ cmd: "trust", sub: "add" });
49
+ it("两种语言均含全部命令语法行", () => {
50
+ for (const lang of ["zh-CN", "en-US"] as const) {
51
+ const text = usageText(lang);
52
+ for (const cmd of ["ojm init [dir]", "ojm dev [port]", "ojm build", "ojm preview [port]", "ojm api [dir]", "ojm info", "ojm merge", "ojm vendor", "ojm install", "ojm trust", "ojm keygen", "ojm sign", "ojm publish", "ojm market verify", "ojm test", "ojm migrate"])
53
+ expect(text, `${lang} ${cmd}`).toContain(cmd);
54
+ }
60
55
  });
61
56
 
62
- it("ojm help → 顶层(无 cmd)", () => {
63
- expect(resolveHelpRequest(["help"])).toEqual({ cmd: undefined, sub: undefined });
57
+ it("trust 子命令索引行是 short 一句话,不复读语法行(回归)", () => {
58
+ const zh = usageText("zh-CN");
59
+ expect(zh).toMatch(/ojm trust ls\s+列出已信任 key/);
60
+ expect(zh).not.toContain("ojm trust ls ojm trust ls");
64
61
  });
65
62
 
66
- it("非帮助调用返回 null(ojm build)", () => {
67
- expect(resolveHelpRequest(["build", "--bundle"])).toBeNull();
63
+ it("版本项保留(双语)", () => {
64
+ expect(usageText("zh-CN")).toContain("ojm --version | -V");
65
+ expect(usageText("en-US")).toContain("ojm --version | -V");
68
66
  });
69
67
  });
package/src/build.ts CHANGED
@@ -522,7 +522,7 @@ export async function buildBackend(projectRoot: string): Promise<boolean> {
522
522
  if (syncedPaths === "created" || syncedPaths === "updated")
523
523
  console.log(`[ojm] 已${syncedPaths === "created" ? "生成" : "更新"} ${ALIAS_PATHS_FILE}(\`#/<模块名>/…\` 的 TS paths 投影)`);
524
524
 
525
- const apiDist = layout?.apiDist ?? path.join(projectRoot, "api", "dist");
525
+ const apiDist = layout?.apiDist ?? path.join(projectRoot, "dist", "api");
526
526
  console.log("[ojm] oj build(后端)…");
527
527
  // 必须显式给 -c:oj build 的 -c 缺省是 ./config.yaml(相对 CWD),而 config 在
528
528
  // <project>/config/ 下——漏传会静默读不到配置,连带 tasks 镜像目录回落默认 "tasks"、
@@ -546,8 +546,8 @@ export async function buildBackend(projectRoot: string): Promise<boolean> {
546
546
  apiDist,
547
547
  "--no-minify",
548
548
  ], { stdio: "inherit" });
549
- // 宿主 versions.json 双落点:站点壳从 shell-dist 拷进 web/dist(mergeShellSite),
550
- // 而方案安装器读的是 `<api>/dist/versions.json`(B-11:与 manifests.yaml 同层,用于
549
+ // 宿主 versions.json 双落点:站点壳从 shell-dist 拷进 dist/web(mergeShellSite),
550
+ // 而方案安装器读的是 `dist/api/versions.json`(B-11:与 manifests.yaml 同层,用于
551
551
  // peerRuntime 强制比对)。两处必须同源,否则新工程 `ojm build` 完仍装不上方案——
552
552
  // preflight 直接 C2-09 fail-closed「无法读取宿主 runtime 版本」。
553
553
  const versionsSrc = path.join(resolveShellDist(), "versions.json");
@@ -585,7 +585,7 @@ function mergeShellSite(distDir: string, siteBase: string): void {
585
585
  /**
586
586
  * 构建模块工程。
587
587
  *
588
- * 产出(web 布局:web/dist):
588
+ * 产出(layout.distDir:module-root / new → dist/web;flat → build):
589
589
  * <dist>/modules/<name>/<version>/{entry.js, chunk-*.js, *.css}
590
590
  * <dist>/modules.json
591
591
  *