@oj-module/cli 0.2.23 → 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 (55) hide show
  1. package/devkit/manual.md +7 -5
  2. package/devkit/module-dev-and-cdn-playground.md +10 -10
  3. package/package.json +4 -4
  4. package/shell-dist/assets/runtime.js +4 -3
  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__/layout.test.ts +8 -5
  10. package/src/__tests__/new-scaffold.test.ts +59 -0
  11. package/src/__tests__/usage.test.ts +45 -47
  12. package/src/build.ts +4 -4
  13. package/src/bundle.ts +9 -9
  14. package/src/cli.ts +265 -0
  15. package/src/contract/check.ts +4 -4
  16. package/src/dev.ts +17 -0
  17. package/src/index.ts +31 -211
  18. package/src/init.ts +2 -4
  19. package/src/keygen.ts +0 -19
  20. package/src/layout.ts +11 -11
  21. package/src/lifecycle/__tests__/authoring.test.ts +3 -14
  22. package/src/lifecycle/__tests__/bundle.test.ts +24 -24
  23. package/src/lifecycle/__tests__/install.test.ts +0 -23
  24. package/src/lifecycle/__tests__/new.test.ts +1 -1
  25. package/src/lifecycle/__tests__/smoke.test.ts +2 -2
  26. package/src/market/__tests__/bootstrap.test.ts +24 -0
  27. package/src/market/__tests__/setup.test.ts +8 -1
  28. package/src/market/bootstrap.ts +10 -8
  29. package/src/market/cdn-layout.ts +3 -3
  30. package/src/market/command.ts +0 -23
  31. package/src/market/setup.ts +14 -2
  32. package/src/market/trial.ts +3 -3
  33. package/src/market/walkthrough.ts +7 -6
  34. package/src/new.ts +21 -3
  35. package/src/preview-config.ts +2 -2
  36. package/src/preview.ts +5 -4
  37. package/src/publish.ts +0 -22
  38. package/src/sign.ts +0 -22
  39. package/src/usage.ts +482 -210
  40. package/templates/README.md +3 -3
  41. package/templates/cdn/README.md +6 -6
  42. package/templates/config.yaml +5 -4
  43. package/templates/init.include.yaml +1 -1
  44. package/templates/seed.mysql.sql +2 -1
  45. package/templates/seed.pg.sql +2 -1
  46. package/templates/src/_platform/api/market/api.ts +11 -8
  47. package/templates/src/_platform/api/seed.sql +2 -1
  48. package/templates/src/_platform/api/solutions/api.ts +2 -2
  49. package/templates/src/system/web/pages/users/roles.tsx +3 -1
  50. package/templates/tests/lifecycle/market-api.spec.ts +35 -5
  51. package/templates/tests/lifecycle/verify-checks.spec.ts +1 -1
  52. package/templates/tests/lifecycle/verify-report.spec.ts +1 -1
  53. package/vendor/host-versions.json +3 -3
  54. package/src/args.ts +0 -172
  55. package/src/lifecycle/__tests__/args.test.ts +0 -20
@@ -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
  *
package/src/bundle.ts CHANGED
@@ -23,8 +23,8 @@
23
23
  // <project>/solution.yaml | manifest.yaml → 工程根契约(旧单方案写法)
24
24
  // <project>/src/<模块>/solution.yaml | manifest.yaml → 模块级契约(绝不进 <模块>/api/,§1.2 红线)
25
25
  // 任一处均 → <name>-<version>/manifest.yaml(Form A;tgz 内名固定为 manifest.yaml)
26
- // <project>/api/dist/<name>-<version>/ → <name>-<version>/api/<name>-<version>/(oj build 产物原样,C2-31 分层核对)
27
- // <project>/web/dist/modules/<name>/ → <name>-<version>/web/modules/<name>/(只入包方案自己的模块;
26
+ // <project>/dist/api/<name>-<version>/ → <name>-<version>/api/<name>-<version>/(oj build 产物原样,C2-31 分层核对)
27
+ // <project>/dist/web/modules/<name>/ → <name>-<version>/web/modules/<name>/(只入包方案自己的模块;
28
28
  // web.entry/css 存在性复核)
29
29
  // 红线:modules.json 禁入包(C2-31——该清单由安装器在目标站点 upsert,防覆盖宿主清单);
30
30
  // 文件面契约(tables↔schema.yaml、locales 双语齐备)构建期 fail-fast(契约 §4.1 构建列)。
@@ -257,8 +257,8 @@ async function planBundle(projectRoot: string, layout: ProjectLayout, target: Bu
257
257
 
258
258
  /**
259
259
  * 组装段(写盘):staging 单根 <name>-<version>/ → 红线自检 → 确定性 tgz + sha256。
260
- * 入包内容与 §11.1 一致:只组装本方案的模块产物(api/dist/<name>-<version>/ 与
261
- * web/dist/modules/<name>/),模板/宿主模块不随包。
260
+ * 入包内容与 §11.1 一致:只组装本方案的模块产物(layout.apiDist/<name>-<version>/ 与
261
+ * layout.distDir/modules/<name>/),模板/宿主模块不随包。
262
262
  */
263
263
  async function assembleBundle(projectRoot: string, layout: ProjectLayout, plan: BundlePlan, outDir: string): Promise<BundleBuildResult> {
264
264
  const m = plan.manifest;
@@ -274,7 +274,7 @@ async function assembleBundle(projectRoot: string, layout: ProjectLayout, plan:
274
274
 
275
275
  // api:oj build 产物原样入包(编译编排不在此重跑——作者先 oj build,本命令只校验与组装)
276
276
  if (m.api !== undefined) {
277
- const built = path.join(projectRoot, "api/dist", `${name}-${version}`);
277
+ const built = path.join(layout.apiDist, `${name}-${version}`);
278
278
  if (!fs.existsSync(built)) {
279
279
  throw new Error(`[ojm] 方案包构建失败:缺少 oj build 产物 ${path.relative(projectRoot, built)}(manifest 声明了 api 段;先对 api/ 运行 oj build,产物原样入包)`);
280
280
  }
@@ -286,18 +286,18 @@ async function assembleBundle(projectRoot: string, layout: ProjectLayout, plan:
286
286
  }
287
287
 
288
288
  // web:只入包方案自己的模块产物(web 段缺省 = 纯后端方案,整段跳过)。
289
- // 为何不是整树:web/dist/modules 里除本方案模块外还有脚手架模板模块(home/login/
289
+ // 为何不是整树:dist/web/modules 里除本方案模块外还有脚手架模板模块(home/login/
290
290
  // personal-center/notification/system/oj-market…),而安装器 deployWeb 会把包内
291
291
  // web/modules 整树覆盖进目标站 <dist>/modules——整树入包等于用开发机上的模板产物
292
292
  // 覆盖目标站同名前端。方案包只该带自己那一份,模板模块属宿主面,不由方案分发。
293
293
  if (m.web !== undefined) {
294
- const webModules = path.join(projectRoot, "web/dist/modules");
294
+ const webModules = path.join(layout.distDir, "modules");
295
295
  if (!fs.existsSync(webModules)) {
296
- throw new Error("[ojm] 方案包构建失败:缺少站点构建产物 web/dist/modules(manifest 声明了 web 段;先运行 ojm build 生成模块产物)");
296
+ throw new Error(`[ojm] 方案包构建失败:缺少站点构建产物 ${path.relative(projectRoot, webModules)}(manifest 声明了 web 段;先运行 ojm build 生成模块产物)`);
297
297
  }
298
298
  const ownWeb = path.join(webModules, name);
299
299
  if (!fs.existsSync(ownWeb)) {
300
- throw new Error(`[ojm] 方案包构建失败:缺少本方案的站点产物 web/dist/modules/${name}/(方案包只入包方案自己的模块;请确认 web.config.ts 已登记 ${name} 且本次 ojm build 已产出)`);
300
+ throw new Error(`[ojm] 方案包构建失败:缺少本方案的站点产物 ${path.relative(projectRoot, ownWeb)}/(方案包只入包方案自己的模块;请确认 web.config.ts 已登记 ${name} 且本次 ojm build 已产出)`);
301
301
  }
302
302
  fs.mkdirSync(path.join(root, "web", "modules"), { recursive: true });
303
303
  fs.cpSync(ownWeb, path.join(root, "web", "modules", name), { recursive: true });
package/src/cli.ts ADDED
@@ -0,0 +1,265 @@
1
+ import type { CAC } from "cac";
2
+ import type { HelpEntry, Lang } from "./usage";
3
+ /**
4
+ * `ojm` 的 cac 接线:命令注册 + action 薄分发(解析 → 既有 handler)。
5
+ * 帮助文本(双语注册表)在 usage.ts;handler 真逻辑在各业务模块,本模块不掺业务。
6
+ */
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import process from "node:process";
10
+ import cac from "cac";
11
+ import { findCommand, groupHelpText, HELP_GROUPS, resolveLang, topHelpSections, usageText } from "./usage";
12
+
13
+ export interface CliDeps { exit?: (code: number) => never }
14
+
15
+ /** `ojm help [cmd [sub]]` → `<cmd> [sub] --help`:词形归一,交给 cac 的 help 机制。 */
16
+ export function rewriteHelpArgv(argv: string[]): string[] {
17
+ if (argv[0] !== "help")
18
+ return argv;
19
+ if (argv.length === 1)
20
+ return ["--help"];
21
+ return [...argv.slice(1), "--help"];
22
+ }
23
+
24
+ /**
25
+ * 未知命令兜底结果(index.ts 打印 + 退出用)。
26
+ * help 形态(ojm nope --help / ojm help nope):报「未知命令」exit 1——打帮助退 0 会掩盖拼写错误。
27
+ */
28
+ export function unknownCommandResult(cmd: string, lang: Lang, helpRequested: boolean): { text: string, code: number } {
29
+ if (helpRequested) {
30
+ return {
31
+ text: lang === "zh-CN"
32
+ ? `[ojm] 未知命令:${cmd}(ojm --help 查看全部命令)`
33
+ : `[ojm] Unknown command: ${cmd} (run ojm --help for the full command list)`,
34
+ code: 1,
35
+ };
36
+ }
37
+ return { text: usageText(lang), code: 1 };
38
+ }
39
+
40
+ /** cac 的内置报错是英文裸消息(Unknown option)——按语言重写成人话,带修复指引。 */
41
+ export function localizeCacError(error: unknown, lang: Lang): unknown {
42
+ if (!(error instanceof Error))
43
+ return error;
44
+ const m = /^Unknown option `([^`]+)`/.exec(error.message);
45
+ if (!m)
46
+ return error;
47
+ return new Error(lang === "zh-CN"
48
+ ? `[ojm] 未知选项:${m[1]}(ojm <cmd> --help 查看可用选项)`
49
+ : `[ojm] Unknown option: ${m[1]} (run ojm <cmd> --help for available options)`);
50
+ }
51
+
52
+ /** 与旧 index.ts 顶层 catch 同源:message 已是人话,堆栈仅 OJM_DEBUG=1(兼容旧名 RAM_DEBUG)。 */
53
+ export function reportCliError(error: unknown, exit: (code: number) => never): void {
54
+ const isDebug = !!(process.env.OJM_DEBUG ?? process.env.RAM_DEBUG);
55
+ const body = error instanceof Error ? (isDebug ? error.stack ?? error.message : error.message) : String(error);
56
+ console.error(/^\[ojm/.test(body) ? body : `[ojm] ${body}`);
57
+ exit(1);
58
+ }
59
+
60
+ interface SiteOpts { url?: string, user?: string, pass?: string }
61
+
62
+ function makeAction(def: HelpEntry, projectRoot: string, lang: Lang, exit: (code: number) => never): (...args: any[]) => Promise<void> {
63
+ switch (def.key) {
64
+ case "init":
65
+ return async (dir?: string, opts: { yes?: boolean } = {}) => {
66
+ const { initProject } = await import("./init");
67
+ await initProject(dir ? path.resolve(dir) : projectRoot, { yes: !!opts.yes });
68
+ };
69
+ case "dev":
70
+ return async (port?: string) => {
71
+ const { devServer } = await import("./dev");
72
+ const portArg = Number(port);
73
+ await devServer(projectRoot, { port: Number.isFinite(portArg) && portArg > 0 ? portArg : 5174 });
74
+ };
75
+ case "build":
76
+ return async (opts: { bundle?: unknown, out?: string } = {}) => {
77
+ // mri 可选值:裸 --bundle 得到 "",--bundle name / --bundle=name 得到字符串
78
+ const bundle = opts.bundle !== undefined;
79
+ const bundleName = typeof opts.bundle === "string" && opts.bundle !== "" ? opts.bundle : undefined;
80
+ if (bundle) {
81
+ const { buildBundle } = await import("./bundle");
82
+ const results = await buildBundle(projectRoot, { out: opts.out, name: bundleName });
83
+ if (results.length > 1)
84
+ console.log(`[ojm] 共打包 ${results.length} 份方案包(逐包签名:ojm sign dist/<name>-<version>.tgz --id <publisher>.<key> --check)`);
85
+ return;
86
+ }
87
+ const { buildBackend, buildModules } = await import("./build");
88
+ await buildBackend(projectRoot);
89
+ await buildModules(projectRoot, { mergeSite: true });
90
+ };
91
+ case "new":
92
+ return async (name: string | undefined, extra: string[] = [], opts: { layout?: string, api?: boolean } = {}) => {
93
+ if (!name) {
94
+ console.error("[ojm] 用法:ojm new <name> [--layout container|none] [--api](非交互环境必须显式给 name)");
95
+ exit(1);
96
+ return;
97
+ }
98
+ // 统一审查 F8:多余位置参数显式拒绝,不能静默丢弃(ojm new crm erp 只建 crm 是事故)
99
+ if (extra.length > 0)
100
+ throw new Error(`[ojm] ojm new 只接受一个方案名(多余参数:${extra.join(" ")})`);
101
+ if (opts.layout !== undefined && opts.layout !== "container" && opts.layout !== "none")
102
+ throw new Error(`[ojm] --layout 只接受 container | none(实际:${opts.layout})`);
103
+ const { createSolution } = await import("./new");
104
+ const created = createSolution(projectRoot, name, { layout: opts.layout as "container" | "none" | undefined, api: !!opts.api });
105
+ console.log(`[ojm] 已生成方案骨架:${path.relative(process.cwd(), created.dir)}`);
106
+ for (const f of created.files)
107
+ console.log(`[ojm] + ${f}`);
108
+ console.log(`[ojm] 下一步:pnpm dev 看效果 → ojm build && ojm build --bundle ${name} 打包`);
109
+ };
110
+ case "preview":
111
+ return async (port?: string) => {
112
+ const { runPreview } = await import("./preview");
113
+ const portArg = Number(port);
114
+ await runPreview(projectRoot, { port: Number.isInteger(portArg) && portArg > 0 ? portArg : undefined });
115
+ };
116
+ case "api":
117
+ return async (dir?: string, opts: { check?: boolean, docs?: boolean, exempt?: string } = {}) => {
118
+ // S5:显式目录必须先存在——否则静默在错的目录下生成/对账
119
+ if (dir && !fs.existsSync(path.resolve(dir)))
120
+ throw new Error(`[ojm-api] 项目目录不存在:${dir}`);
121
+ const apiRoot = dir ? path.resolve(dir) : projectRoot;
122
+ if (opts.docs) {
123
+ const { runApiDocs } = await import("./contract/run");
124
+ const out = await runApiDocs(apiRoot);
125
+ console.log(`[ojm-api] 文档站已生成:${path.relative(projectRoot, out)}(浏览器直接打开即可)`);
126
+ return;
127
+ }
128
+ if (opts.check) {
129
+ const { checkApi } = await import("./contract/check");
130
+ // --exempt <path>:相对路径按项目根解析;缺省按布局找 .ojm-api-exempt.json(工程根 → api/)
131
+ const { violations, hints } = await checkApi({ cwd: apiRoot, exempt: opts.exempt ? path.resolve(projectRoot, opts.exempt) : undefined });
132
+ for (const v of violations)
133
+ console[v.level === "error" ? "error" : "warn"](`${v.level === "error" ? "✗" : "⚠"} ${v.message}`);
134
+ for (const h of hints)
135
+ console.log(h);
136
+ if (violations.length === 0)
137
+ console.log("[ojm-api] --check 通过:生成物同步、route 双向对账、routes.js 均无 drift。");
138
+ const errors = violations.filter(v => v.level === "error").length;
139
+ console.log(`[ojm-api] --check 结果:${errors} error / ${violations.length - errors} warn`);
140
+ if (errors > 0)
141
+ exit(1);
142
+ return;
143
+ }
144
+ const { runApi } = await import("./contract/run");
145
+ const result = await runApi({ cwd: apiRoot });
146
+ console.log(`[ojm-api] 契约 ${result.contracts} 份;写入 ${result.written.length} 个文件,跳过(未变)${result.skipped.length} 个;stub 新建 ${result.stubs.created} / 更新 ${result.stubs.updated} / 跳过 ${result.stubs.skipped}`);
147
+ for (const p of result.written)
148
+ console.log(` + ${path.relative(apiRoot, p)}`);
149
+ };
150
+ case "info":
151
+ return async (opts: { all?: boolean } = {}) => {
152
+ const { printInfo } = await import("./info");
153
+ await printInfo(projectRoot, undefined, { all: !!opts.all });
154
+ };
155
+ case "vendor":
156
+ return async (tag?: string, opts: { force?: boolean } = {}) => {
157
+ if (tag !== undefined && !/^v?\d+\.\d+\.\d+(?:-[\w.]+)?$/.test(tag))
158
+ throw new Error(`[ojm] 非法版本号:${tag}(形如 v0.1.11 或 0.1.11)`);
159
+ const { vendorCommand } = await import("./vendor");
160
+ await vendorCommand(projectRoot, { force: !!opts.force, tag: tag === undefined ? undefined : (tag.startsWith("v") ? tag : `v${tag}`) });
161
+ };
162
+ case "merge":
163
+ return async (out?: string, inputs: string[] = []) => {
164
+ const { mergeManifests } = await import("./info");
165
+ await mergeManifests(out ?? "", inputs);
166
+ };
167
+ case "keygen":
168
+ return async (opts: { id?: string, out?: string, yes?: boolean } = {}) => {
169
+ const { keygenCommand } = await import("./keygen");
170
+ keygenCommand({ id: opts.id, out: opts.out, yes: opts.yes });
171
+ };
172
+ case "sign":
173
+ return async (file: string | undefined, opts: { id?: string, key?: string, check?: boolean } = {}) => {
174
+ const { signCommand } = await import("./sign");
175
+ signCommand(file ?? "", { id: opts.id, key: opts.key, check: opts.check });
176
+ };
177
+ case "publish":
178
+ return async (dir: string | undefined, opts: { id?: string, key?: string, check?: boolean } = {}) => {
179
+ const { publishCommand } = await import("./publish");
180
+ publishCommand(dir ?? "", { id: opts.id, key: opts.key, check: opts.check });
181
+ };
182
+ case "install":
183
+ return async (target: string | undefined, opts: SiteOpts & { locale?: string, name?: string, version?: string } = {}) => {
184
+ const { installCommand } = await import("./install");
185
+ await installCommand(target ?? "", { url: opts.url, user: opts.user, pass: opts.pass, locale: opts.locale, name: opts.name, version: opts.version });
186
+ };
187
+ case "enable":
188
+ case "disable":
189
+ return async (name: string | undefined, opts: SiteOpts = {}) => {
190
+ const { setEnabledCommand } = await import("./enable");
191
+ await setEnabledCommand(name ?? "", def.key === "enable", { url: opts.url, user: opts.user, pass: opts.pass });
192
+ };
193
+ case "uninstall":
194
+ return async (name: string | undefined, opts: SiteOpts = {}) => {
195
+ const { uninstallCommand } = await import("./uninstall");
196
+ await uninstallCommand(name ?? "", { url: opts.url, user: opts.user, pass: opts.pass });
197
+ };
198
+ case "trust":
199
+ return async (sub: string | undefined, keyId: string | undefined, pub: string | undefined, opts: SiteOpts & { task?: string } = {}) => {
200
+ const { trustCommand } = await import("./trust");
201
+ await trustCommand(sub ?? "", [keyId, pub].filter((v): v is string => v !== undefined), { url: opts.url, user: opts.user, pass: opts.pass, task: opts.task });
202
+ };
203
+ case "market":
204
+ return async (sub: string | undefined) => {
205
+ if (!sub) {
206
+ // bare `ojm market`:打印该组帮助(子命令索引),同旧 marketUsage 语义
207
+ console.log(groupHelpText("market-cdn", lang));
208
+ return;
209
+ }
210
+ // market 子命令自带解析器(trial/walkthrough 等 flag 各异):raw 转发 process.argv
211
+ const { marketCommand } = await import("./market/command");
212
+ await marketCommand(sub, process.argv.slice(4));
213
+ };
214
+ case "oj":
215
+ return async () => {
216
+ // 透传:参数原样转发(process.argv 未被 help 词形改写污染——真实调用才走到这里)
217
+ const { runOjSubcommand } = await import("./oj-cmd");
218
+ runOjSubcommand(projectRoot, def.name, process.argv.slice(3));
219
+ };
220
+ default:
221
+ throw new Error(`[ojm] 未接线的命令键:${def.key}`);
222
+ }
223
+ }
224
+
225
+ export function buildCli(projectRoot: string, lang: Lang = resolveLang(), deps: CliDeps = {}): CAC {
226
+ const exit = deps.exit ?? ((code: number) => process.exit(code) as never);
227
+ const cli = cac("ojm");
228
+ for (const group of HELP_GROUPS) {
229
+ for (const def of group.commands) {
230
+ const cmd = cli.command(def.name, lang === "zh-CN" ? def.zh : def.en);
231
+ for (const o of def.options ?? [])
232
+ cmd.option(o.raw, lang === "zh-CN" ? o.zh : o.en);
233
+ if (def.allowUnknownOptions)
234
+ cmd.allowUnknownOptions();
235
+ const action = makeAction(def, projectRoot, lang, exit);
236
+ cmd.action((...args: unknown[]) => {
237
+ Promise.resolve(action(...args)).catch((error: unknown) => reportCliError(error, exit));
238
+ });
239
+ }
240
+ }
241
+
242
+ // help 回调:顶层 = 分组 sections;单命令 = 本地化小节标题 + 详解/子命令详解注入
243
+ const SECTION_TITLES: Record<string, { zh: string, en: string }> = {
244
+ Usage: { zh: "用法", en: "Usage" },
245
+ Options: { zh: "选项", en: "Options" },
246
+ Examples: { zh: "示例", en: "Examples" },
247
+ };
248
+ cli.help((sections) => {
249
+ const matched = cli.matchedCommand;
250
+ if (!matched)
251
+ return topHelpSections(lang);
252
+ const def = findCommand(matched.rawName);
253
+ const out = sections.map((s) => {
254
+ const t = s.title && SECTION_TITLES[s.title];
255
+ return t ? { ...s, title: lang === "zh-CN" ? t.zh : t.en } : s;
256
+ });
257
+ if (def?.detail)
258
+ out.push({ title: lang === "zh-CN" ? "详细说明" : "Details", body: lang === "zh-CN" ? def.detail.zh : def.detail.en });
259
+ const sub = cli.args[0];
260
+ if (def?.subs && sub && def.subs[sub])
261
+ out.push({ title: `${sub}:`, body: lang === "zh-CN" ? def.subs[sub].zh : def.subs[sub].en });
262
+ return out;
263
+ });
264
+ return cli;
265
+ }
@@ -18,7 +18,7 @@ import { findOpenapiJson, groupIrByModule } from "./run";
18
18
  * ① 生成物同步:内存重生成 vs 磁盘逐字节 diff(含 stub 待更新)
19
19
  * ② route 双向对账:AST 扫 oj api.ts(default 导出方法名 + 语句起始 `.route = "..."` 赋值)
20
20
  * vs 契约路由表:未实现 warn / 未登记 error / 参数段不一致 error
21
- * ③ routes.js diff:release 路由表(oj build 产物,api/dist 下各模块的 routes.js)vs openapi.json
21
+ * ③ routes.js diff:release 路由表(oj build 产物,dist/api 下各模块的 routes.js)vs openapi.json
22
22
  * 契约路由表;无 dist 给提示不判违规
23
23
  *
24
24
  * 原则:check 永不修文件——修复动作永远由人执行(重跑 ojm api / 补 handler / oj build)。
@@ -360,8 +360,8 @@ export async function checkApi(opts: { cwd: string, exempt?: string }): Promise<
360
360
  // ④ WS 路由鉴权对账(oj 0.1.30 起握手过鉴权守卫)
361
361
  violations.push(...checkWsAnonymous(roots, resolveConfigPath(opts.cwd)));
362
362
 
363
- // ③ routes.js diff
364
- const distDir = join(opts.cwd, "api/dist");
363
+ // ③ routes.js diff(release 路由表读 layout.apiDist;无布局工程回退同一约定 dist/api)
364
+ const distDir = tryResolveLayout(opts.cwd)?.apiDist ?? join(opts.cwd, "dist", "api");
365
365
  const distRows: { method: string, pattern: string }[] = [];
366
366
  if (existsSync(distDir)) {
367
367
  for (const entry of readdirSync(distDir, { withFileTypes: true })) {
@@ -373,7 +373,7 @@ export async function checkApi(opts: { cwd: string, exempt?: string }): Promise<
373
373
  }
374
374
  }
375
375
  if (distRows.length === 0) {
376
- hints.push("[ojm-api] 未发现 api/dist/**/routes.js——release 路由表对账跳过;发布前请先 oj build 再跑 ojm api --check。");
376
+ hints.push(`[ojm-api] 未发现 ${relative(opts.cwd, distDir)}/**/routes.js——release 路由表对账跳过;发布前请先 oj build 再跑 ojm api --check。`);
377
377
  }
378
378
  else {
379
379
  // 尾斜杠规范化:契约侧 fullPath 对「模块根路径」带尾斜杠(stripBase 的
package/src/dev.ts CHANGED
@@ -35,6 +35,7 @@ import { loadProjectMocks, mockStatusCode } from "./dev-mock";
35
35
  import { createReloadHub, proxyApi, proxyWsUpgrade, sseScript } from "./dev-proxy";
36
36
  import { loadHtmlTransform } from "./html-transform";
37
37
  import { resolveConfigPath, resolveLayout } from "./layout";
38
+ import { resolveCdnLayout } from "./market/cdn-layout";
38
39
  import { startOj } from "./oj";
39
40
  import { requireModuleRootCapability } from "./oj-caps";
40
41
  import { ensureDevCert } from "./oj-cert";
@@ -123,6 +124,16 @@ export async function devServer(projectRoot: string, opts: DevOptions = {}): Pro
123
124
 
124
125
  const hub = createReloadHub();
125
126
 
127
+ // /cdn 反代(feature:market_cdn_url 可为相对路径 = 本站点自供):config 有 /cdn 挂载
128
+ // 才反代到 oj;无挂载(旧模板/手改工程)或纯前端形态 → 回落静态,行为不变
129
+ let cdnPrefix: string | null = null;
130
+ try {
131
+ cdnPrefix = resolveCdnLayout(projectRoot).prefix;
132
+ }
133
+ catch {
134
+ cdnPrefix = null;
135
+ }
136
+
126
137
  // 3) 静态半边(dev:模块产物优先,shell dist 兜底;no-store;注入刷新通道)
127
138
  // hostRoots 钉死 shell dist:ojm build 的合并残留不得反向遮蔽宿主(F11)
128
139
  // transform 来自 web.config.ts(可选):按路由改写 title/OG,先于 reload 注入
@@ -175,6 +186,12 @@ export async function devServer(projectRoot: string, opts: DevOptions = {}): Pro
175
186
  return;
176
187
  }
177
188
 
189
+ // 相对基址 CDN(/cdn):站点自供挂载直发 oj(不剥前缀),否则静态层会回落成 index.html
190
+ if (cdnPrefix !== null && ojTarget !== undefined && (urlPath === cdnPrefix || urlPath.startsWith(`${cdnPrefix}/`))) {
191
+ void proxyApi(ojTarget)(req, res);
192
+ return;
193
+ }
194
+
178
195
  serveStatic(req, res);
179
196
  });
180
197