@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
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
+ }
@@ -63,7 +63,7 @@ function mockRequest(responder: (c: Call) => { status?: number, data?: unknown }
63
63
 
64
64
  // 让 index() 直连的 request 与生成 client 的 bindRequest 指向同一 mock,行为一致。
65
65
  // 桥接:占位方法零参签名 ↔ ScopedRequestLike 真实签名,经 any 双向打通(测试桩,无运行时差异)。
66
- function useMock(m: ReturnType<typeof mockRequest>) {
66
+ function bindMock(m: ReturnType<typeof mockRequest>) {
67
67
  bindRequest(m.req);
68
68
  const req = m.req as unknown as Record<keyof typeof requestImpl, () => never>;
69
69
  for (const k of Object.keys(requestImpl) as Array<keyof typeof requestImpl>)
@@ -75,12 +75,12 @@ describe("httpMarketClient · 统一委托生成 client 集成回归", () => {
75
75
 
76
76
  beforeEach(() => {
77
77
  mock = mockRequest(() => ({ data: {} }));
78
- useMock(mock);
78
+ bindMock(mock);
79
79
  });
80
80
 
81
81
  it("meta() → GET _platform/market/meta,返回 { apiVersion }", async () => {
82
82
  mock = mockRequest(() => ({ data: { apiVersion: "1.2.3" } }));
83
- useMock(mock);
83
+ bindMock(mock);
84
84
  const r = await httpMarketClient.meta();
85
85
  expect(mock.calls).toEqual([{ method: "get", url: "_platform/market/meta", opts: undefined }]);
86
86
  expect(r).toEqual({ apiVersion: "1.2.3" });
@@ -89,7 +89,7 @@ describe("httpMarketClient · 统一委托生成 client 集成回归", () => {
89
89
  it("index() → GET _platform/market/index,返回 IndexRow[]", async () => {
90
90
  const rows = [{ name: "a", latest: "1.0", installed: "1.0", enabled: true, upgradable: false }];
91
91
  mock = mockRequest(() => ({ data: rows }));
92
- useMock(mock);
92
+ bindMock(mock);
93
93
  const r = await httpMarketClient.index();
94
94
  expect(mock.calls[0]).toMatchObject({ method: "get", url: "_platform/market/index" });
95
95
  expect(r).toEqual(rows);
@@ -97,7 +97,7 @@ describe("httpMarketClient · 统一委托生成 client 集成回归", () => {
97
97
 
98
98
  it("index() 遇 503(未配置市场地址)→ [] 空态,不抛(PRD §5.3)", async () => {
99
99
  mock = mockRequest(c => (c.url === "_platform/market/index" ? { status: 503 } : { data: {} }));
100
- useMock(mock);
100
+ bindMock(mock);
101
101
  const r = await httpMarketClient.index();
102
102
  expect(r).toEqual([]);
103
103
  // 503 是配置态而非故障:silentError 抑制全局 toast + retry:0 不重试(原
@@ -108,7 +108,7 @@ describe("httpMarketClient · 统一委托生成 client 集成回归", () => {
108
108
 
109
109
  it("install(input) → POST _platform/market/install,body 含 name/version/source", async () => {
110
110
  mock = mockRequest(() => ({ data: { task_id: "t-1" } }));
111
- useMock(mock);
111
+ bindMock(mock);
112
112
  const r = await httpMarketClient.install({ name: "alpha", version: "2.0", source: "file" });
113
113
  expect(mock.calls[0]).toMatchObject({ method: "post", url: "_platform/market/install" });
114
114
  expect(mock.calls[0].opts?.json).toEqual({ name: "alpha", version: "2.0", source: "file" });
@@ -117,7 +117,7 @@ describe("httpMarketClient · 统一委托生成 client 集成回归", () => {
117
117
 
118
118
  it("install(input) 缺省 version/source → null / cdn 兜底", async () => {
119
119
  mock = mockRequest(() => ({ data: { task_id: "t-2" } }));
120
- useMock(mock);
120
+ bindMock(mock);
121
121
  await httpMarketClient.install({ name: "beta" });
122
122
  expect(mock.calls[0].opts?.json).toEqual({ name: "beta", version: null, source: "cdn" });
123
123
  });
@@ -125,7 +125,7 @@ describe("httpMarketClient · 统一委托生成 client 集成回归", () => {
125
125
  it("task(id) → GET _platform/market/task? 带 id", async () => {
126
126
  const rec = { id: "t-1", name: "a", version: "1.0", status: "done", stage: "done", progress: 100, message: "ok", stages: [], startedAt: 1 };
127
127
  mock = mockRequest(() => ({ data: rec }));
128
- useMock(mock);
128
+ bindMock(mock);
129
129
  const r = await httpMarketClient.task("t-1");
130
130
  expect(mock.calls[0]).toMatchObject({ method: "get", url: "_platform/market/task" });
131
131
  expect(mock.calls[0].opts?.searchParams).toEqual({ id: "t-1" });
@@ -134,7 +134,7 @@ describe("httpMarketClient · 统一委托生成 client 集成回归", () => {
134
134
 
135
135
  it("enable(input) → POST _platform/market/enable,body {name,enabled}", async () => {
136
136
  mock = mockRequest(() => ({ data: { ok: true, message: "done" } }));
137
- useMock(mock);
137
+ bindMock(mock);
138
138
  const r = await httpMarketClient.enable({ name: "a", enabled: false });
139
139
  expect(mock.calls[0]).toMatchObject({ method: "post", url: "_platform/market/enable" });
140
140
  expect(mock.calls[0].opts?.json).toEqual({ name: "a", enabled: false });
@@ -144,7 +144,7 @@ describe("httpMarketClient · 统一委托生成 client 集成回归", () => {
144
144
  it("history() → GET _platform/market/history,返回 MarketHistory[]", async () => {
145
145
  const hist = [{ id: 1, solution: "a", version: "1.0", action: "install", status: "ok", operator: null, message: null, created_at: 1 }];
146
146
  mock = mockRequest(() => ({ data: hist }));
147
- useMock(mock);
147
+ bindMock(mock);
148
148
  const r = await httpMarketClient.history();
149
149
  expect(mock.calls[0]).toMatchObject({ method: "get", url: "_platform/market/history" });
150
150
  expect(r).toEqual(hist);
@@ -152,7 +152,7 @@ describe("httpMarketClient · 统一委托生成 client 集成回归", () => {
152
152
 
153
153
  it("uninstallImpact(name) → GET _platform/market/uninstall-impact? 带 name", async () => {
154
154
  mock = mockRequest(() => ({ data: { name: "a", affectedRoles: 2, affectedUsers: 5 } }));
155
- useMock(mock);
155
+ bindMock(mock);
156
156
  const r = await httpMarketClient.uninstallImpact("a");
157
157
  expect(mock.calls[0]).toMatchObject({ method: "get", url: "_platform/market/uninstall-impact" });
158
158
  expect(mock.calls[0].opts?.searchParams).toEqual({ name: "a" });
@@ -161,7 +161,7 @@ describe("httpMarketClient · 统一委托生成 client 集成回归", () => {
161
161
 
162
162
  it("uninstall(name) → POST _platform/market/uninstall,body {name}", async () => {
163
163
  mock = mockRequest(() => ({ data: { ok: true, message: "removed" } }));
164
- useMock(mock);
164
+ bindMock(mock);
165
165
  const r = await httpMarketClient.uninstall("a");
166
166
  expect(mock.calls[0]).toMatchObject({ method: "post", url: "_platform/market/uninstall" });
167
167
  expect(mock.calls[0].opts?.json).toEqual({ name: "a" });
@@ -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
 
@@ -294,6 +311,8 @@ export async function devServer(projectRoot: string, opts: DevOptions = {}): Pro
294
311
  // 仅 TTY 默认启用(管道/CI 下不抢 stdin);测试可经 keymap + inputStream 强制。
295
312
  const keymapEnabled = opts.keymap ?? (process.stdin.isTTY ?? false);
296
313
  if (keymapEnabled) {
314
+ const footerText = "快捷键:r 热更新 · h 帮助 · c 清屏 · o 打开浏览器 · q 退出";
315
+ const footer = pinFooter(`[ojm] ${footerText}`);
297
316
  setupDevKeymap({
298
317
  server,
299
318
  oj,
@@ -301,12 +320,15 @@ export async function devServer(projectRoot: string, opts: DevOptions = {}): Pro
301
320
  port: actualPort,
302
321
  input: opts.inputStream,
303
322
  openBrowser: opts.openBrowser,
323
+ clearScreen: footer ? () => footer.clear() : undefined,
304
324
  onReload: () => {
305
325
  void trigger();
306
326
  },
307
327
  onQuit: shutdown,
308
328
  });
309
- console.log("[ojm] 快捷键:r 热更新 · h 帮助 · c 清屏 · o 打开浏览器 · q 退出");
329
+ // 非 TTY(pinFooter 拒绝)回退一次性打印,行为与历史一致
330
+ if (!footer)
331
+ console.log(`[ojm] ${footerText}`);
310
332
  }
311
333
 
312
334
  return server;
@@ -335,6 +357,52 @@ export async function shutdownDev(
335
357
  setTimeout(exit, 1500, 0).unref();
336
358
  }
337
359
 
360
+ /**
361
+ * 快捷键提示常驻底栏:DECSTBM 把滚动区设为 1..rows-1,末行留给提示。
362
+ * 之后本进程 console.log 与 oj 透传(均经 process.stdout.write)只在滚动区内滚动,
363
+ * 末行由终端保持,无需逐条重绘。非 TTY / 行数不足返回 undefined(调用方回退一次性打印)。
364
+ */
365
+ export interface PinnedFooter {
366
+ /** 清屏:ED(\x1b[2J)保留滚动区(RIS \x1bc 会复位滚动区,禁用),随后重绘提示 */
367
+ clear: () => void
368
+ /** 恢复全屏滚动区并落光标到底行(进程退出/shell 接管前调用);幂等 */
369
+ stop: () => void
370
+ }
371
+
372
+ export function pinFooter(text: string, stream: NodeJS.WriteStream = process.stdout): PinnedFooter | undefined {
373
+ if (!stream.isTTY || (stream.rows ?? 0) < 3)
374
+ return undefined;
375
+ const write = (s: string) => {
376
+ stream.write(s);
377
+ };
378
+ const render = () => {
379
+ const rows = stream.rows || 24;
380
+ write(`\x1B[1;${rows - 1}r`); // 滚动区:首行 ~ 倒数第二行(DECSTBM 会把光标送回 1;1)
381
+ write(`\x1B[${rows};1H\x1B[2K${text}`); // 末行清行后画常驻提示
382
+ write(`\x1B[${rows - 1};1H`); // 光标回滚动区底行,后续输出照常滚动
383
+ };
384
+ render();
385
+ const onResize = () => render();
386
+ let stopped = false;
387
+ const stop = () => {
388
+ if (stopped)
389
+ return;
390
+ stopped = true;
391
+ process.removeListener("SIGWINCH", onResize);
392
+ process.removeListener("exit", stop);
393
+ write("\x1B[r\x1B[999;1H\n"); // 恢复全屏滚动区,光标夹到底行避免 shell 提示符悬空
394
+ };
395
+ process.on("SIGWINCH", onResize);
396
+ process.on("exit", stop);
397
+ return {
398
+ clear: () => {
399
+ write("\x1B[2J");
400
+ render();
401
+ },
402
+ stop,
403
+ };
404
+ }
405
+
338
406
  /**
339
407
  * Vite 风格 dev 快捷键(默认仅在 TTY 启用,见 devServer 内的 keymapEnabled 判定)。
340
408
  * r 强制重建并热更新(复用 trigger)
@@ -356,6 +424,8 @@ export interface DevKeymapDeps {
356
424
  */
357
425
  input?: NodeJS.ReadableStream & { setRawMode?: (mode: boolean) => unknown }
358
426
  openBrowser?: (url: string) => void
427
+ /** 清屏实现(默认 \x1Bc);devServer 注入底栏版以保住滚动区 */
428
+ clearScreen?: () => void
359
429
  onReload: () => void
360
430
  onQuit: () => void
361
431
  log?: (msg: string) => void
@@ -404,7 +474,10 @@ export function setupDevKeymap(deps: DevKeymapDeps): void {
404
474
  help();
405
475
  }
406
476
  else if (k === "c") {
407
- process.stdout.write("\x1Bc");
477
+ if (deps.clearScreen)
478
+ deps.clearScreen();
479
+ else
480
+ process.stdout.write("\x1Bc");
408
481
  }
409
482
  else if (k === "o") {
410
483
  open(url);