@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.
- package/devkit/manual.md +100 -12
- package/devkit/module-dev-and-cdn-playground.md +10 -10
- package/package.json +4 -4
- package/shell-dist/assets/runtime.js +61 -35
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/build-backend.test.ts +13 -13
- package/src/__tests__/cli.test.ts +225 -0
- package/src/__tests__/init-packagejson.test.ts +29 -0
- package/src/__tests__/layout.test.ts +8 -5
- package/src/__tests__/new-scaffold.test.ts +59 -0
- package/src/__tests__/usage.test.ts +45 -47
- package/src/build.ts +4 -4
- package/src/builtin.ts +93 -0
- package/src/bundle.ts +237 -33
- package/src/cli.ts +265 -0
- package/src/contract/__tests__/market-client.integration.test.ts +12 -12
- package/src/contract/check.ts +4 -4
- package/src/dev.ts +75 -2
- package/src/index.ts +31 -191
- package/src/info.ts +49 -7
- package/src/init.ts +25 -11
- package/src/keygen.ts +0 -19
- package/src/layout.ts +11 -11
- package/src/lifecycle/__tests__/authoring.test.ts +3 -14
- package/src/lifecycle/__tests__/builtin.test.ts +97 -0
- package/src/lifecycle/__tests__/bundle.test.ts +188 -25
- package/src/lifecycle/__tests__/install.test.ts +0 -23
- package/src/lifecycle/__tests__/new.test.ts +129 -0
- package/src/lifecycle/__tests__/smoke.test.ts +4 -3
- package/src/manifest-contract.ts +16 -3
- package/src/market/__tests__/bootstrap.test.ts +24 -0
- package/src/market/__tests__/setup.test.ts +8 -1
- package/src/market/bootstrap.ts +10 -8
- package/src/market/cdn-layout.ts +3 -3
- package/src/market/command.ts +0 -23
- package/src/market/publish.ts +10 -2
- package/src/market/setup.ts +14 -2
- package/src/market/trial.ts +3 -3
- package/src/market/walkthrough.ts +7 -6
- package/src/new.ts +226 -0
- package/src/preview-config.ts +2 -2
- package/src/preview.ts +5 -4
- package/src/publish.ts +0 -22
- package/src/sign.ts +0 -22
- package/src/usage.ts +482 -198
- package/templates/README.md +3 -3
- package/templates/cdn/README.md +6 -6
- package/templates/config.yaml +5 -4
- package/templates/init.include.yaml +1 -1
- package/templates/package.json +3 -1
- package/templates/seed.mysql.sql +2 -1
- package/templates/seed.pg.sql +2 -1
- package/templates/src/_platform/api/fixtures/l1-test-user.sql +4 -5
- package/templates/src/_platform/api/market/api.ts +11 -8
- package/templates/src/_platform/api/seed.mysql.sql +11 -3
- package/templates/src/_platform/api/seed.pg.sql +11 -3
- package/templates/src/_platform/api/seed.sql +18 -6
- package/templates/src/_platform/api/solutions/api.ts +2 -2
- package/templates/src/oj-market/web/api/context.ts +2 -2
- package/templates/src/oj-market/web/components/InstallDialog.tsx +13 -6
- package/templates/src/oj-market/web/components/RollbackDialog.tsx +7 -3
- package/templates/src/oj-market/web/components/UninstallConfirm.tsx +18 -7
- package/templates/src/oj-market/web/hooks/useMarket.ts +30 -18
- package/templates/src/system/web/locales/en-US.json +1 -1
- package/templates/src/system/web/locales/zh-CN.json +1 -1
- package/templates/src/system/web/pages/users/roles.tsx +3 -1
- package/templates/tests/l1-builder.test.ts +2 -2
- package/templates/tests/lifecycle/market-api.spec.ts +35 -5
- package/templates/tests/lifecycle/verify-checks.spec.ts +1 -1
- package/templates/tests/lifecycle/verify-report.spec.ts +1 -1
- package/templates/tests/rbac-seed.spec.ts +12 -10
- package/templates/tests/seed-role-accounts.spec.ts +87 -0
- package/vendor/host-versions.json +3 -3
- package/src/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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" });
|
package/src/contract/check.ts
CHANGED
|
@@ -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
|
|
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, "
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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);
|