@oj-module/cli 0.2.29 → 0.2.42
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 +5 -5
- package/devkit/module-dev-and-cdn-playground.md +1 -1
- package/package.json +2 -2
- package/shell-dist/assets/{index-CnJsHOe-.js → index-FYt9CjFd.js} +50 -17
- package/shell-dist/assets/runtime.js +2 -2
- package/shell-dist/index.html +3 -3
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/cli.test.ts +23 -14
- package/src/__tests__/list.test.ts +176 -0
- package/src/__tests__/registry-cli.test.ts +522 -0
- package/src/__tests__/registry-export.test.ts +319 -0
- package/src/__tests__/registry-import.test.ts +336 -0
- package/src/__tests__/registry-sign.test.ts +352 -0
- package/src/cli-output.ts +81 -0
- package/src/cli.ts +298 -39
- package/src/config-cmd.ts +100 -0
- package/src/contract/__tests__/handler-scan.test.ts +74 -2
- package/src/contract/handler-scan.ts +27 -2
- package/src/enable.ts +1 -1
- package/src/exit-codes.ts +33 -0
- package/src/index.ts +5 -9
- package/src/install.ts +120 -38
- package/src/lifecycle/__tests__/authoring.test.ts +6 -5
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.bad.sig +3 -2
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.sig +3 -2
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.unknown.sig +3 -2
- package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.d.mts +5 -4
- package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.mjs +9 -6
- package/src/lifecycle/__tests__/helpers.ts +10 -6
- package/src/lifecycle/__tests__/install.test.ts +72 -15
- package/src/lifecycle/__tests__/manifest-contract.test.ts +40 -1
- package/src/lifecycle/__tests__/publish.test.ts +174 -33
- package/src/lifecycle/__tests__/sig-fixtures.test.ts +9 -6
- package/src/lifecycle/__tests__/sign.test.ts +9 -8
- package/src/lifecycle/__tests__/smoke.test.ts +1 -1
- package/src/lifecycle/__tests__/uninstall.test.ts +4 -4
- package/src/lifecycle/sign.ts +8 -7
- package/src/list.ts +146 -0
- package/src/manifest-contract.ts +11 -5
- package/src/market/__tests__/bootstrap.test.ts +4 -1
- package/src/market/__tests__/build-index.test.ts +529 -42
- package/src/market/__tests__/fixture.test.ts +17 -1
- package/src/market/__tests__/publish.test.ts +8 -7
- package/src/market/__tests__/setup.test.ts +4 -4
- package/src/market/__tests__/sharding.test.ts +90 -0
- package/src/market/__tests__/verify.test.ts +205 -12
- package/src/market/__tests__/yank-ledger.test.ts +60 -0
- package/src/market/bootstrap.ts +8 -3
- package/src/market/build-index.ts +407 -40
- package/src/market/cdn-layout.ts +3 -2
- package/src/market/command.ts +40 -6
- package/src/market/failure-playbook.ts +1 -1
- package/src/market/fixture.ts +20 -3
- package/src/market/index-builder.ts +120 -47
- package/src/market/publish.ts +3 -1
- package/src/market/setup.ts +3 -3
- package/src/market/sharding.ts +94 -0
- package/src/market/trial.ts +1 -1
- package/src/market/verify.ts +213 -50
- package/src/market/walkthrough.ts +7 -6
- package/src/market/yank-ledger.ts +91 -0
- package/src/publish.ts +250 -38
- package/src/registry-export.ts +405 -0
- package/src/registry-import.ts +410 -0
- package/src/registry-sidecar.ts +84 -0
- package/src/registry-sign.ts +322 -0
- package/src/registry-source.ts +192 -0
- package/src/sign.ts +13 -10
- package/src/site.ts +4 -4
- package/src/trust.ts +1 -1
- package/src/uninstall.ts +14 -2
- package/src/usage.ts +130 -25
- package/src/view.ts +94 -0
- package/templates/cdn/README.md +35 -5
- package/templates/config.yaml +10 -0
- package/templates/init.include.yaml +1 -1
- package/templates/src/_platform/api/_lifecycle/atomic.ts +3 -0
- package/templates/src/_platform/api/_lifecycle/installer.ts +44 -7
- package/templates/src/_platform/api/_lifecycle/limits.ts +21 -0
- package/templates/src/_platform/api/_lifecycle/source-seq.ts +88 -0
- package/templates/src/_platform/api/_lifecycle/steps/download.ts +15 -0
- package/templates/src/_platform/api/_lifecycle/steps/extract.ts +61 -2
- package/templates/src/_platform/api/_lifecycle/steps/preflight.ts +55 -6
- package/templates/src/_platform/api/_lifecycle/steps/register.ts +2 -1
- package/templates/src/_platform/api/_lifecycle/steps/verify.ts +22 -8
- package/templates/src/_platform/api/_lifecycle/tgz-guard.ts +96 -0
- package/templates/src/_platform/api/_lifecycle/types.ts +6 -3
- package/templates/src/_platform/api/_lifecycle/verify-checks.ts +8 -0
- package/templates/src/_platform/api/_shared/service/domain/solution-perms.ts +9 -1
- package/templates/src/_platform/api/_shared/site-facade.ts +62 -11
- package/templates/src/_platform/api/manifest.yaml +1 -0
- package/templates/src/_platform/api/market/api.ts +362 -99
- package/templates/src/_platform/api/market/index-source.ts +112 -0
- package/templates/src/_platform/api/migrations/0014__reg_source_seq.mysql.sql +9 -0
- package/templates/src/_platform/api/migrations/0014__reg_source_seq.pg.sql +7 -0
- package/templates/src/_platform/api/migrations/0014__reg_source_seq.sql +8 -0
- package/templates/src/_platform/api/schema.yaml +8 -0
- package/templates/src/_platform/api/solutions/api.ts +8 -3
- package/templates/src/oj-market/web/__tests__/CardGrid.test.tsx +13 -0
- package/templates/src/oj-market/web/__tests__/InstallDialog.test.tsx +38 -0
- package/templates/src/oj-market/web/components/CardItem.tsx +10 -0
- package/templates/src/oj-market/web/components/InstallDialog.tsx +18 -0
- package/templates/src/oj-market/web/locales/en-US.json +1 -0
- package/templates/src/oj-market/web/locales/zh-CN.json +1 -0
- package/templates/src/oj-market/web/types.ts +3 -1
- package/templates/src/system/web/entry.ts +5 -2
- package/templates/tests/lifecycle/installer.spec.ts +24 -0
- package/templates/tests/lifecycle/market-api.spec.ts +641 -85
- package/templates/tests/lifecycle/preflight.spec.ts +52 -0
- package/templates/tests/lifecycle/sig-fixtures.spec.ts +7 -4
- package/templates/tests/lifecycle/source-seq.spec.ts +55 -0
- package/templates/tests/lifecycle/steps-fs.spec.ts +166 -7
- package/templates/tests/lifecycle/tgz-guard.spec.ts +133 -1
- package/templates/tests/lifecycle/verify-checks.spec.ts +27 -13
- package/templates/tests/lifecycle/verify-report.spec.ts +3 -1
- package/templates/tests/rbac-solution-perms.spec.ts +41 -0
- package/vendor/host-versions.json +3 -3
package/src/cli.ts
CHANGED
|
@@ -8,9 +8,15 @@ import fs from "node:fs";
|
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import process from "node:process";
|
|
10
10
|
import cac from "cac";
|
|
11
|
+
import { emit, failEnvelope, OK_CODE } from "./cli-output";
|
|
12
|
+
import { EXIT_FAILURE, EXIT_USAGE, exitCodeOf, UsageError } from "./exit-codes";
|
|
11
13
|
import { findCommand, groupHelpText, HELP_GROUPS, resolveLang, topHelpSections, usageText } from "./usage";
|
|
12
14
|
|
|
13
|
-
export interface CliDeps {
|
|
15
|
+
export interface CliDeps {
|
|
16
|
+
exit?: (code: number) => never
|
|
17
|
+
/** 本次调用的参数(`--json` 失败信封归因用);缺省 process.argv.slice(2) */
|
|
18
|
+
argv?: string[]
|
|
19
|
+
}
|
|
14
20
|
|
|
15
21
|
/** `ojm help [cmd [sub]]` → `<cmd> [sub] --help`:词形归一,交给 cac 的 help 机制。 */
|
|
16
22
|
export function rewriteHelpArgv(argv: string[]): string[] {
|
|
@@ -23,41 +29,84 @@ export function rewriteHelpArgv(argv: string[]): string[] {
|
|
|
23
29
|
|
|
24
30
|
/**
|
|
25
31
|
* 未知命令兜底结果(index.ts 打印 + 退出用)。
|
|
26
|
-
* help 形态(ojm nope --help / ojm help nope):报「未知命令」exit
|
|
32
|
+
* help 形态(ojm nope --help / ojm help nope):报「未知命令」exit 2——拼错属于**用法错误**,
|
|
33
|
+
* 与业务失败(1)分开,CI 才不会把「脚本写错」当成「发版被拒」。
|
|
34
|
+
* `summary` 是给机器通道的一句话(`--json` 信封的 message),`text` 是给人看的(可能是一屏帮助)。
|
|
35
|
+
*/
|
|
36
|
+
export function unknownCommandResult(cmd: string, lang: Lang, helpRequested: boolean): { text: string, code: number, summary: string } {
|
|
37
|
+
const summary = lang === "zh-CN"
|
|
38
|
+
? `[ojm] 未知命令:${cmd}(ojm --help 查看全部命令)`
|
|
39
|
+
: `[ojm] Unknown command: ${cmd} (run ojm --help for the full command list)`;
|
|
40
|
+
if (helpRequested)
|
|
41
|
+
return { text: summary, code: EXIT_USAGE, summary };
|
|
42
|
+
return { text: usageText(lang), code: EXIT_USAGE, summary };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 未知命令的兜底输出。`--json` 下帮助文本改走 stderr、stdout 只留失败信封——
|
|
47
|
+
* 机器通道的承诺是「一行 JSON 或空」,不能让一屏帮助混进去(实测踩过:CI 在 `ojm nope --json`
|
|
48
|
+
* 上拿到的是整页帮助,`JSON.parse` 直接炸)。
|
|
27
49
|
*/
|
|
28
|
-
export function
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
50
|
+
export function reportUnknownCommand(cmd: string, lang: Lang, helpRequested: boolean, argv: string[] = process.argv.slice(2)): { text: string, code: number } {
|
|
51
|
+
const r = unknownCommandResult(cmd, lang, helpRequested);
|
|
52
|
+
if (argv.includes("--json")) {
|
|
53
|
+
console.error(r.text);
|
|
54
|
+
console.log(JSON.stringify(failEnvelope(cmd, "usage_error", r.summary)));
|
|
55
|
+
}
|
|
56
|
+
else if (helpRequested) {
|
|
57
|
+
console.error(r.text);
|
|
36
58
|
}
|
|
37
|
-
|
|
59
|
+
else {
|
|
60
|
+
console.log(r.text);
|
|
61
|
+
}
|
|
62
|
+
return { text: r.text, code: r.code };
|
|
38
63
|
}
|
|
39
64
|
|
|
40
|
-
/**
|
|
65
|
+
/**
|
|
66
|
+
* cac 的内置报错是英文裸消息(Unknown option / missing required arg)——按语言重写成人话,带修复指引。
|
|
67
|
+
* 同时**升格为 UsageError**:这些错误一律是「命令行写错了」(exit 2),不是业务失败。
|
|
68
|
+
*/
|
|
41
69
|
export function localizeCacError(error: unknown, lang: Lang): unknown {
|
|
42
70
|
if (!(error instanceof Error))
|
|
43
71
|
return error;
|
|
44
|
-
const
|
|
45
|
-
if (
|
|
46
|
-
return
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
72
|
+
const unknownOption = /^Unknown option `([^`]+)`/.exec(error.message);
|
|
73
|
+
if (unknownOption) {
|
|
74
|
+
return new UsageError(lang === "zh-CN"
|
|
75
|
+
? `[ojm] 未知选项:${unknownOption[1]}(ojm <cmd> --help 查看可用选项)`
|
|
76
|
+
: `[ojm] Unknown option: ${unknownOption[1]} (run ojm <cmd> --help for available options)`);
|
|
77
|
+
}
|
|
78
|
+
const missingArg = /^missing required arg for `([^`]+)`/.exec(error.message);
|
|
79
|
+
if (missingArg) {
|
|
80
|
+
return new UsageError(lang === "zh-CN"
|
|
81
|
+
? `[ojm] 缺少必需参数:${missingArg[1]}(ojm <cmd> --help 查看语法)`
|
|
82
|
+
: `[ojm] Missing required argument: ${missingArg[1]} (run ojm <cmd> --help for the syntax)`);
|
|
83
|
+
}
|
|
84
|
+
return error;
|
|
50
85
|
}
|
|
51
86
|
|
|
52
|
-
/**
|
|
53
|
-
|
|
87
|
+
/**
|
|
88
|
+
* 与旧 index.ts 顶层 catch 同源:message 已是人话,堆栈仅 OJM_DEBUG=1(兼容旧名 RAM_DEBUG)。
|
|
89
|
+
* 退出码走 `exitCodeOf`:UsageError → 2,其余 → 1(REG-R12 三档契约)。
|
|
90
|
+
*
|
|
91
|
+
* `--json` 下失败也要给信封:CI 读完 stdout 就能分支,不必「先看退出码再猜哪条是人话」。
|
|
92
|
+
* 三种失败各一个稳定机器码:`usage_error`(2)/ `failed`(1);成功恒 `ok`。
|
|
93
|
+
* 人话**始终**走 stderr——stdout 在有 `--json` 时恒为「一行可解析 JSON 或空」。
|
|
94
|
+
*/
|
|
95
|
+
export function reportCliError(error: unknown, exit: (code: number) => never, argv: string[] = process.argv.slice(2)): void {
|
|
54
96
|
const isDebug = !!(process.env.OJM_DEBUG ?? process.env.RAM_DEBUG);
|
|
55
97
|
const body = error instanceof Error ? (isDebug ? error.stack ?? error.message : error.message) : String(error);
|
|
56
98
|
console.error(/^\[ojm/.test(body) ? body : `[ojm] ${body}`);
|
|
57
|
-
|
|
99
|
+
if (argv.includes("--json")) {
|
|
100
|
+
const command = argv.find(a => !a.startsWith("-")) ?? "";
|
|
101
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
102
|
+
const code = error instanceof UsageError ? "usage_error" : "failed";
|
|
103
|
+
// 走 console.log 与 `emit` 同一条输出通道:机器通道只有一处实现,测试也能捕获
|
|
104
|
+
console.log(JSON.stringify(failEnvelope(command, code, message)));
|
|
105
|
+
}
|
|
106
|
+
exit(exitCodeOf(error));
|
|
58
107
|
}
|
|
59
108
|
|
|
60
|
-
interface SiteOpts {
|
|
109
|
+
interface SiteOpts { siteUrl?: string, user?: string, pass?: string }
|
|
61
110
|
|
|
62
111
|
function makeAction(def: HelpEntry, projectRoot: string, lang: Lang, exit: (code: number) => never): (...args: any[]) => Promise<void> {
|
|
63
112
|
switch (def.key) {
|
|
@@ -91,15 +140,14 @@ function makeAction(def: HelpEntry, projectRoot: string, lang: Lang, exit: (code
|
|
|
91
140
|
case "new":
|
|
92
141
|
return async (name: string | undefined, extra: string[] = [], opts: { layout?: string, api?: boolean } = {}) => {
|
|
93
142
|
if (!name) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
return;
|
|
143
|
+
// 用法错误(exit 2):缺位置参数,改命令行就能解决
|
|
144
|
+
throw new UsageError("[ojm] 用法:ojm new <name> [--layout container|none] [--api](非交互环境必须显式给 name)");
|
|
97
145
|
}
|
|
98
146
|
// 统一审查 F8:多余位置参数显式拒绝,不能静默丢弃(ojm new crm erp 只建 crm 是事故)
|
|
99
147
|
if (extra.length > 0)
|
|
100
|
-
throw new
|
|
148
|
+
throw new UsageError(`[ojm] ojm new 只接受一个方案名(多余参数:${extra.join(" ")})`);
|
|
101
149
|
if (opts.layout !== undefined && opts.layout !== "container" && opts.layout !== "none")
|
|
102
|
-
throw new
|
|
150
|
+
throw new UsageError(`[ojm] --layout 只接受 container | none(实际:${opts.layout})`);
|
|
103
151
|
const { createSolution } = await import("./new");
|
|
104
152
|
const created = createSolution(projectRoot, name, { layout: opts.layout as "container" | "none" | undefined, api: !!opts.api });
|
|
105
153
|
console.log(`[ojm] 已生成方案骨架:${path.relative(process.cwd(), created.dir)}`);
|
|
@@ -155,7 +203,7 @@ function makeAction(def: HelpEntry, projectRoot: string, lang: Lang, exit: (code
|
|
|
155
203
|
case "vendor":
|
|
156
204
|
return async (tag?: string, opts: { force?: boolean } = {}) => {
|
|
157
205
|
if (tag !== undefined && !/^v?\d+\.\d+\.\d+(?:-[\w.]+)?$/.test(tag))
|
|
158
|
-
throw new
|
|
206
|
+
throw new UsageError(`[ojm] 非法版本号:${tag}(形如 v0.1.11 或 0.1.11)`);
|
|
159
207
|
const { vendorCommand } = await import("./vendor");
|
|
160
208
|
await vendorCommand(projectRoot, { force: !!opts.force, tag: tag === undefined ? undefined : (tag.startsWith("v") ? tag : `v${tag}`) });
|
|
161
209
|
};
|
|
@@ -170,35 +218,245 @@ function makeAction(def: HelpEntry, projectRoot: string, lang: Lang, exit: (code
|
|
|
170
218
|
keygenCommand({ id: opts.id, out: opts.out, yes: opts.yes });
|
|
171
219
|
};
|
|
172
220
|
case "sign":
|
|
173
|
-
return async (file: string | undefined, opts: { id?: string, key?: string, check?: boolean } = {}) => {
|
|
221
|
+
return async (file: string | undefined, opts: { id?: string, key?: string, check?: boolean, registryId?: string } = {}) => {
|
|
174
222
|
const { signCommand } = await import("./sign");
|
|
175
|
-
signCommand(file ?? "", { id: opts.id, key: opts.key, check: opts.check });
|
|
223
|
+
signCommand(file ?? "", { id: opts.id, key: opts.key, check: opts.check, registryId: opts.registryId });
|
|
224
|
+
};
|
|
225
|
+
case "config":
|
|
226
|
+
return async (sub: string | undefined, opts: { json?: boolean, showOrigin?: boolean } = {}) => {
|
|
227
|
+
const { configCommand } = await import("./config-cmd");
|
|
228
|
+
await configCommand(sub, { json: !!opts.json, showOrigin: !!opts.showOrigin, cwd: projectRoot });
|
|
176
229
|
};
|
|
177
230
|
case "publish":
|
|
178
|
-
return async (dir: string | undefined, opts: { id?: string, key?: string, check?: boolean } = {}) => {
|
|
231
|
+
return async (dir: string | undefined, opts: { id?: string, key?: string, check?: boolean, registry?: string, dryRun?: boolean, json?: boolean } = {}) => {
|
|
179
232
|
const { publishCommand } = await import("./publish");
|
|
180
|
-
|
|
233
|
+
const { describeCredential, describeSourceLevel, resolveRegistryCredential, resolveRegistrySourceOptional } = await import("./registry-source");
|
|
234
|
+
// 源:给了就必须合法(`--registry` 拼错 → exit 2);没配则明说「只写本地、不上传」。
|
|
235
|
+
// 这里不抛「未指定仓库源」:`ojm publish` 今天仍是本地索引落盘(上传随 S4 落地),
|
|
236
|
+
// 逼它必须有源会把「本地自建 CDN」这条既有链路打死。
|
|
237
|
+
const source = resolveRegistrySourceOptional({ flag: opts.registry, cwd: projectRoot });
|
|
238
|
+
const credential = source === null ? null : resolveRegistryCredential(source, { cwd: projectRoot });
|
|
239
|
+
const human: string[] = [];
|
|
240
|
+
human.push(source === null
|
|
241
|
+
? "[ojm] 未配置仓库源:本次只重算并签名本地索引目录(不上传);CI 用 --registry 或工程根 .ojmrc 指定"
|
|
242
|
+
: `[ojm] 仓库源 ${source.url}(来源 ${describeSourceLevel(source)});凭据 ${describeCredential(credential!)}`);
|
|
243
|
+
const result = publishCommand(dir ?? "", {
|
|
244
|
+
id: opts.id,
|
|
245
|
+
key: opts.key,
|
|
246
|
+
check: opts.check,
|
|
247
|
+
dryRun: !!opts.dryRun,
|
|
248
|
+
cwd: projectRoot,
|
|
249
|
+
log: line => human.push(line),
|
|
250
|
+
});
|
|
251
|
+
const rel = (p: string) => path.relative(projectRoot, p);
|
|
252
|
+
// `--check` 没过 = 业务失败(1):签名与内容对不上,改数据就不了,得重签
|
|
253
|
+
const checkFailed = result.checkPassed === false;
|
|
254
|
+
emit({
|
|
255
|
+
command: "publish",
|
|
256
|
+
code: checkFailed ? "check_failed" : OK_CODE,
|
|
257
|
+
message: checkFailed ? "索引自检失败:签名与内容不匹配" : undefined,
|
|
258
|
+
data: {
|
|
259
|
+
dryRun: result.dryRun,
|
|
260
|
+
seq: result.seq,
|
|
261
|
+
keyId: result.keyId,
|
|
262
|
+
fingerprint: result.fingerprint,
|
|
263
|
+
registry: source === null ? null : { url: source.url, level: source.level },
|
|
264
|
+
credential: credential?.kind ?? null,
|
|
265
|
+
catalog: rel(result.catalogPath),
|
|
266
|
+
shards: result.shardPaths.map(rel),
|
|
267
|
+
writes: result.writes.map(rel),
|
|
268
|
+
checkPassed: result.checkPassed ?? null,
|
|
269
|
+
},
|
|
270
|
+
human,
|
|
271
|
+
}, { json: !!opts.json });
|
|
272
|
+
if (checkFailed)
|
|
273
|
+
exit(EXIT_FAILURE);
|
|
181
274
|
};
|
|
182
275
|
case "install":
|
|
183
|
-
return async (target: string | undefined, opts: SiteOpts & { locale?: string, name?: string, version?: string } = {}) => {
|
|
276
|
+
return async (target: string | undefined, opts: SiteOpts & { locale?: string, name?: string, version?: string, registry?: string } = {}) => {
|
|
184
277
|
const { installCommand } = await import("./install");
|
|
185
|
-
await installCommand(target ?? "", {
|
|
278
|
+
await installCommand(target ?? "", { siteUrl: opts.siteUrl, user: opts.user, pass: opts.pass, locale: opts.locale, name: opts.name, version: opts.version, registry: opts.registry });
|
|
279
|
+
};
|
|
280
|
+
case "list":
|
|
281
|
+
return async (opts: SiteOpts & { all?: boolean, json?: boolean } = {}) => {
|
|
282
|
+
const { listCommand } = await import("./list");
|
|
283
|
+
await listCommand({ siteUrl: opts.siteUrl, user: opts.user, pass: opts.pass, all: !!opts.all, json: !!opts.json });
|
|
186
284
|
};
|
|
187
285
|
case "enable":
|
|
188
286
|
case "disable":
|
|
189
287
|
return async (name: string | undefined, opts: SiteOpts = {}) => {
|
|
190
288
|
const { setEnabledCommand } = await import("./enable");
|
|
191
|
-
await setEnabledCommand(name ?? "", def.key === "enable", {
|
|
289
|
+
await setEnabledCommand(name ?? "", def.key === "enable", { siteUrl: opts.siteUrl, user: opts.user, pass: opts.pass });
|
|
192
290
|
};
|
|
193
291
|
case "uninstall":
|
|
194
|
-
return async (name: string | undefined, opts: SiteOpts = {}) => {
|
|
292
|
+
return async (name: string | undefined, opts: SiteOpts & { registry?: string } = {}) => {
|
|
195
293
|
const { uninstallCommand } = await import("./uninstall");
|
|
196
|
-
await uninstallCommand(name ?? "", {
|
|
294
|
+
await uninstallCommand(name ?? "", { siteUrl: opts.siteUrl, user: opts.user, pass: opts.pass, registry: opts.registry });
|
|
295
|
+
};
|
|
296
|
+
case "view":
|
|
297
|
+
return async (target: string | undefined, opts: SiteOpts & { json?: boolean } = {}) => {
|
|
298
|
+
const { viewCommand } = await import("./view");
|
|
299
|
+
await viewCommand(target, { siteUrl: opts.siteUrl, user: opts.user, pass: opts.pass, json: opts.json });
|
|
197
300
|
};
|
|
198
301
|
case "trust":
|
|
199
302
|
return async (sub: string | undefined, keyId: string | undefined, pub: string | undefined, opts: SiteOpts & { task?: string } = {}) => {
|
|
200
303
|
const { trustCommand } = await import("./trust");
|
|
201
|
-
await trustCommand(sub ?? "", [keyId, pub].filter((v): v is string => v !== undefined), {
|
|
304
|
+
await trustCommand(sub ?? "", [keyId, pub].filter((v): v is string => v !== undefined), { siteUrl: opts.siteUrl, user: opts.user, pass: opts.pass, task: opts.task });
|
|
305
|
+
};
|
|
306
|
+
case "registry":
|
|
307
|
+
return async (sub: string | undefined, file: string | undefined, opts: { id?: string, key?: string, out?: string, site?: string, siteUrl?: string, keys?: string, registry?: string, revocations?: boolean, dryRun?: boolean, json?: boolean } = {}) => {
|
|
308
|
+
if (!sub) {
|
|
309
|
+
console.log(groupHelpText("registry", lang));
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (sub === "sign") {
|
|
313
|
+
const { registrySignCommand, registryRevocationSignCommand, RegistrySignError } = await import("./registry-sign");
|
|
314
|
+
const human: string[] = [];
|
|
315
|
+
if (opts.revocations) {
|
|
316
|
+
let rev: ReturnType<typeof registryRevocationSignCommand>;
|
|
317
|
+
try {
|
|
318
|
+
rev = registryRevocationSignCommand({ file: file ?? "", id: opts.id, key: opts.key, out: opts.out, json: !!opts.json, cwd: projectRoot });
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
if (!(error instanceof RegistrySignError))
|
|
322
|
+
throw error;
|
|
323
|
+
emit({ command: "registry sign", code: error.code, message: error.message, data: null, human: [`[ojm] ${error.message}`] }, { json: !!opts.json });
|
|
324
|
+
return exit(EXIT_FAILURE);
|
|
325
|
+
}
|
|
326
|
+
human.push(`[ojm] 第 ${rev.seq} 代吊销清单已签:${rev.keyId}(指纹 ${String(rev.fingerprint).slice(0, 16)}…)`);
|
|
327
|
+
if (rev.outPath !== null)
|
|
328
|
+
human.push(`[ojm] 回传体已写入 ${path.relative(projectRoot, rev.outPath) || rev.outPath}(POST /api/oj-registry/revocations/signature 的请求体)`);
|
|
329
|
+
emit({
|
|
330
|
+
command: "registry sign",
|
|
331
|
+
data: {
|
|
332
|
+
kind: "revocations",
|
|
333
|
+
seq: rev.seq,
|
|
334
|
+
keyId: rev.keyId,
|
|
335
|
+
fingerprint: rev.fingerprint,
|
|
336
|
+
signature: rev.signature,
|
|
337
|
+
out: rev.outPath === null ? null : path.relative(projectRoot, rev.outPath),
|
|
338
|
+
},
|
|
339
|
+
human,
|
|
340
|
+
}, { json: !!opts.json });
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
let result: ReturnType<typeof registrySignCommand>;
|
|
344
|
+
try {
|
|
345
|
+
result = registrySignCommand({ file: file ?? "", id: opts.id, key: opts.key, out: opts.out, json: !!opts.json, cwd: projectRoot });
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
if (!(error instanceof RegistrySignError))
|
|
349
|
+
throw error;
|
|
350
|
+
// 业务失败(1)带稳定码:CI 靠 code 分支(usage_error 补参数,这些码只能改数据/换钥匙)
|
|
351
|
+
emit({ command: "registry sign", code: error.code, message: error.message, data: null, human: [`[ojm] ${error.message}`] }, { json: !!opts.json });
|
|
352
|
+
// `return` 是必要的:测试里 exit 是 spy(不真的终止),否则会掉回下面的成功分支再抛一次
|
|
353
|
+
return exit(EXIT_FAILURE);
|
|
354
|
+
}
|
|
355
|
+
if (result.signed === 0) {
|
|
356
|
+
human.push(`[ojm] 第 ${result.gen} 代没有待签文件(已签全或全部沿用旧片)——幂等成功,未触碰私钥`);
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
human.push(`[ojm] 第 ${result.gen} 代:签了 ${result.signed} 个文件(catalog + ${result.signed - 1} 片)`);
|
|
360
|
+
human.push(`[ojm] 签名者 ${result.keyId}(指纹 ${String(result.fingerprint).slice(0, 16)}…)`);
|
|
361
|
+
}
|
|
362
|
+
if (result.outPath !== null)
|
|
363
|
+
human.push(`[ojm] 回传体已写入 ${path.relative(projectRoot, result.outPath) || result.outPath}(POST /api/oj-registry/index/signature 的请求体)`);
|
|
364
|
+
emit({
|
|
365
|
+
command: "registry sign",
|
|
366
|
+
data: {
|
|
367
|
+
kind: "index",
|
|
368
|
+
gen: result.gen,
|
|
369
|
+
keyId: result.keyId,
|
|
370
|
+
fingerprint: result.fingerprint,
|
|
371
|
+
signed: result.signed,
|
|
372
|
+
signatures: result.signatures,
|
|
373
|
+
out: result.outPath === null ? null : path.relative(projectRoot, result.outPath),
|
|
374
|
+
},
|
|
375
|
+
human,
|
|
376
|
+
}, { json: !!opts.json });
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (sub === "import") {
|
|
380
|
+
const { registryImportCommand, RegistryImportError } = await import("./registry-import");
|
|
381
|
+
let result: ReturnType<typeof registryImportCommand>;
|
|
382
|
+
try {
|
|
383
|
+
result = registryImportCommand({
|
|
384
|
+
dir: file ?? "",
|
|
385
|
+
site: opts.site,
|
|
386
|
+
out: opts.out,
|
|
387
|
+
keys: opts.keys,
|
|
388
|
+
registry: opts.registry,
|
|
389
|
+
dryRun: !!opts.dryRun,
|
|
390
|
+
json: !!opts.json,
|
|
391
|
+
cwd: projectRoot,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
if (error instanceof RegistryImportError) {
|
|
396
|
+
emit({ command: "registry import", code: error.code, message: error.message, data: null, human: [`[ojm] ${error.message}`] }, { json: !!opts.json });
|
|
397
|
+
return exit(EXIT_FAILURE);
|
|
398
|
+
}
|
|
399
|
+
throw error;
|
|
400
|
+
}
|
|
401
|
+
emit({
|
|
402
|
+
command: "registry import",
|
|
403
|
+
code: "ok",
|
|
404
|
+
data: {
|
|
405
|
+
dir: file ?? "",
|
|
406
|
+
target: result.written.length > 0 ? result.written[0].dest : null,
|
|
407
|
+
verified: result.verified,
|
|
408
|
+
written: result.written.map(w => w.rel),
|
|
409
|
+
revocationSeq: result.revocationSeq,
|
|
410
|
+
dryRun: !!opts.dryRun,
|
|
411
|
+
},
|
|
412
|
+
human: [result.message],
|
|
413
|
+
}, { json: !!opts.json });
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (sub === "export") {
|
|
417
|
+
const { registryExportCommand, RegistryExportError } = await import("./registry-export");
|
|
418
|
+
let result: Awaited<ReturnType<typeof registryExportCommand>>;
|
|
419
|
+
try {
|
|
420
|
+
result = await registryExportCommand({
|
|
421
|
+
siteUrl: opts.siteUrl ?? "",
|
|
422
|
+
out: opts.out,
|
|
423
|
+
dryRun: !!opts.dryRun,
|
|
424
|
+
json: !!opts.json,
|
|
425
|
+
cwd: projectRoot,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
catch (error) {
|
|
429
|
+
if (error instanceof RegistryExportError) {
|
|
430
|
+
emit({ command: "registry export", code: error.code, message: error.message, data: null, human: [`[ojm] ${error.message}`] }, { json: !!opts.json });
|
|
431
|
+
return exit(EXIT_FAILURE);
|
|
432
|
+
}
|
|
433
|
+
throw error;
|
|
434
|
+
}
|
|
435
|
+
const human = [
|
|
436
|
+
`[ojm] ${result.dryRun ? "dry-run:索引已校验通过(包体未下载)" : "离线包已导出"}:${result.packages} 个包体、${result.shards.length} 个分片、第 ${result.catalogSeq} 代索引、吊销清单第 ${result.revocationSeq} 代 → ${result.target}`,
|
|
437
|
+
`[ojm] 恢复到站点:ojm registry import ${path.relative(projectRoot, result.outDir) || result.outDir} --site <站点数据根> --keys <active索引key.json>`,
|
|
438
|
+
];
|
|
439
|
+
emit({
|
|
440
|
+
command: "registry export",
|
|
441
|
+
code: "ok",
|
|
442
|
+
data: {
|
|
443
|
+
siteUrl: result.siteUrl,
|
|
444
|
+
out: path.relative(projectRoot, result.outDir) || ".",
|
|
445
|
+
target: path.relative(projectRoot, result.target) || "registry",
|
|
446
|
+
catalogSeq: result.catalogSeq,
|
|
447
|
+
revocationSeq: result.revocationSeq,
|
|
448
|
+
shards: result.shards,
|
|
449
|
+
packages: result.packages,
|
|
450
|
+
verified: result.verified,
|
|
451
|
+
bytes: result.bytes,
|
|
452
|
+
written: result.written,
|
|
453
|
+
dryRun: result.dryRun,
|
|
454
|
+
},
|
|
455
|
+
human,
|
|
456
|
+
}, { json: !!opts.json });
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
throw new UsageError(`[ojm] 未知 registry 子命令:${sub}(支持 sign / import / export)`);
|
|
202
460
|
};
|
|
203
461
|
case "market":
|
|
204
462
|
return async (sub: string | undefined) => {
|
|
@@ -224,6 +482,7 @@ function makeAction(def: HelpEntry, projectRoot: string, lang: Lang, exit: (code
|
|
|
224
482
|
|
|
225
483
|
export function buildCli(projectRoot: string, lang: Lang = resolveLang(), deps: CliDeps = {}): CAC {
|
|
226
484
|
const exit = deps.exit ?? ((code: number) => process.exit(code) as never);
|
|
485
|
+
const argv = deps.argv ?? process.argv.slice(2);
|
|
227
486
|
const cli = cac("ojm");
|
|
228
487
|
for (const group of HELP_GROUPS) {
|
|
229
488
|
for (const def of group.commands) {
|
|
@@ -234,7 +493,7 @@ export function buildCli(projectRoot: string, lang: Lang = resolveLang(), deps:
|
|
|
234
493
|
cmd.allowUnknownOptions();
|
|
235
494
|
const action = makeAction(def, projectRoot, lang, exit);
|
|
236
495
|
cmd.action((...args: unknown[]) => {
|
|
237
|
-
Promise.resolve(action(...args)).catch((error: unknown) => reportCliError(error, exit));
|
|
496
|
+
Promise.resolve(action(...args)).catch((error: unknown) => reportCliError(error, exit, argv));
|
|
238
497
|
});
|
|
239
498
|
}
|
|
240
499
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// `ojm config ls [--show-origin] [--json]`(REG-R12):把「这次命令到底会用哪个源、凭据从哪来」
|
|
2
|
+
// 一次性摆出来。
|
|
3
|
+
//
|
|
4
|
+
// 为什么值得一条命令:需求 §10.3 的附加规则是「执行日志必须打印最终生效的源 + 来源级别」——
|
|
5
|
+
// 踩过的坑是「以为在发公司仓,其实发了缺省源」。这里把解析结果**做成可查的事实**,而不是等出错再猜。
|
|
6
|
+
//
|
|
7
|
+
// 三条纪律:
|
|
8
|
+
// · **永不回显 token**:只报来源(env / 用户配置 / 缺失),值一个字都不打(`describeCredential` 单点);
|
|
9
|
+
// · **未配置不是错误**:诊断命令的职责就是把「没配」显示出来(退出码 0);坏配置文件才是失败(1);
|
|
10
|
+
// · `--json` 走 `emit`:stdout 只有一份信封,人话列在 `--json` 下改走 stderr。
|
|
11
|
+
|
|
12
|
+
import type { EmitStreams } from "./cli-output";
|
|
13
|
+
|
|
14
|
+
import process from "node:process";
|
|
15
|
+
import { emit } from "./cli-output";
|
|
16
|
+
import { UsageError } from "./exit-codes";
|
|
17
|
+
import { describeCredential, describeSourceLevel, REGISTRY_TOKEN_ENV, resolveRegistryCredential, resolveRegistrySourceOptional } from "./registry-source";
|
|
18
|
+
import { DEFAULT_SITE_URL } from "./site";
|
|
19
|
+
|
|
20
|
+
export interface ConfigRow {
|
|
21
|
+
key: string
|
|
22
|
+
value: string
|
|
23
|
+
/** 该值的来源(`--show-origin` 一列;机器通道恒有,方便 CI 断言) */
|
|
24
|
+
origin: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ConfigOptions {
|
|
28
|
+
json?: boolean
|
|
29
|
+
showOrigin?: boolean
|
|
30
|
+
cwd?: string
|
|
31
|
+
home?: string
|
|
32
|
+
env?: NodeJS.ProcessEnv
|
|
33
|
+
out?: (line: string) => void
|
|
34
|
+
err?: (line: string) => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 未配置的源:显式一行,而不是抛错(诊断命令要能说「这里空着」) */
|
|
38
|
+
const UNSET = "(未配置)";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 解析当前生效配置。`registry.url` 未配置时整行显示 `(未配置)`,
|
|
42
|
+
* 其余与源相关的行跟着退化——不拿缺省值假装配好了。
|
|
43
|
+
*/
|
|
44
|
+
export function configRows(opts: ConfigOptions = {}): ConfigRow[] {
|
|
45
|
+
const env = opts.env ?? process.env;
|
|
46
|
+
const rows: ConfigRow[] = [];
|
|
47
|
+
|
|
48
|
+
// null = 一条源都没配(诊断命令要能显示这个事实);坏配置照旧抛,不吞成「未配置」
|
|
49
|
+
const source = resolveRegistrySourceOptional({ cwd: opts.cwd, home: opts.home, env });
|
|
50
|
+
rows.push({
|
|
51
|
+
key: "registry.url",
|
|
52
|
+
value: source?.url ?? UNSET,
|
|
53
|
+
origin: source === null ? "(--registry / .ojmrc / ~/.ojm/config.json 都未提供)" : describeSourceLevel(source),
|
|
54
|
+
});
|
|
55
|
+
rows.push({
|
|
56
|
+
key: "registry.token",
|
|
57
|
+
value: source === null ? "(无源可绑定)" : describeCredential(resolveRegistryCredential(source, { home: opts.home, env })),
|
|
58
|
+
origin: source === null ? "—" : `${REGISTRY_TOKEN_ENV} / ~/.ojm/config.json`,
|
|
59
|
+
});
|
|
60
|
+
rows.push({
|
|
61
|
+
key: "site.url",
|
|
62
|
+
value: env.OJM_SITE_URL?.trim() ? String(env.OJM_SITE_URL).trim() : DEFAULT_SITE_URL,
|
|
63
|
+
origin: env.OJM_SITE_URL?.trim() ? "OJM_SITE_URL" : "缺省",
|
|
64
|
+
});
|
|
65
|
+
const siteCreds = (env.OJM_USER ?? "") !== "" && (env.OJM_PASS ?? "") !== "";
|
|
66
|
+
rows.push({
|
|
67
|
+
key: "site.credentials",
|
|
68
|
+
value: siteCreds ? "OJM_USER / OJM_PASS(已设置)" : "缺失(用 --user/--pass 或 OJM_USER/OJM_PASS)",
|
|
69
|
+
// 只报「设没设」,永不回显账号密码
|
|
70
|
+
origin: siteCreds ? "env" : "—",
|
|
71
|
+
});
|
|
72
|
+
return rows;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 人类可读的对齐列(key 22 宽,够放 `site.credentials` 这类长键) */
|
|
76
|
+
function renderRows(rows: ConfigRow[], showOrigin: boolean): string[] {
|
|
77
|
+
const width = Math.max(...rows.map(r => r.key.length));
|
|
78
|
+
return rows.map(r => `${r.key.padEnd(width)} ${r.value}${showOrigin ? ` ← ${r.origin}` : ""}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `ojm config ls`(`ls` 可省略)。未知子命令 → 用法错误(2),不静默当 ls 跑——
|
|
83
|
+
* 「ojm config set」在拼错时被当成 ls 打印一堆东西,是最容易让人以为设置成功的一类假象。
|
|
84
|
+
*/
|
|
85
|
+
export async function configCommand(sub: string | undefined, opts: ConfigOptions = {}): Promise<void> {
|
|
86
|
+
const cmd = (sub ?? "ls").trim();
|
|
87
|
+
if (cmd !== "ls")
|
|
88
|
+
throw new UsageError(`[ojm] config 只支持 ls(当前:${cmd});用法:ojm config ls [--show-origin] [--json]`);
|
|
89
|
+
|
|
90
|
+
const rows = configRows(opts);
|
|
91
|
+
const streams: EmitStreams = {
|
|
92
|
+
out: opts.out ?? (line => console.log(line)),
|
|
93
|
+
err: opts.err ?? (line => console.error(line)),
|
|
94
|
+
};
|
|
95
|
+
emit({
|
|
96
|
+
command: "config ls",
|
|
97
|
+
data: { rows },
|
|
98
|
+
human: renderRows(rows, opts.showOrigin === true),
|
|
99
|
+
}, { json: opts.json === true }, streams);
|
|
100
|
+
}
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// market/solutions 全族失效)。镜像 oj 运行时内省语义:oj 执行工厂拿到返回对象。
|
|
4
4
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
|
-
import { join } from "node:path";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
7
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
8
|
-
import { scanHandlerFile } from "../handler-scan";
|
|
8
|
+
import { handlerAbs, pathKey, scanHandlerFile } from "../handler-scan";
|
|
9
9
|
|
|
10
10
|
let dir: string;
|
|
11
11
|
let apiSrc: string;
|
|
@@ -25,6 +25,14 @@ function scan(name: string, text: string) {
|
|
|
25
25
|
return scanHandlerFile(join(apiSrc, name), join(dir, "api", "src"));
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** 在相对 api/src 的任意子目录下写一个 handler 并扫(目录段参数化用例) */
|
|
29
|
+
function scanAt(rel: string, text: string) {
|
|
30
|
+
const file = join(dir, "api", "src", rel);
|
|
31
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
32
|
+
writeFileSync(file, text);
|
|
33
|
+
return scanHandlerFile(file, join(dir, "api", "src"));
|
|
34
|
+
}
|
|
35
|
+
|
|
28
36
|
describe("handler-scan · 工厂形态(P2-14:export default createXxxApi(env))", () => {
|
|
29
37
|
it("callExpression → 同名函数声明 return 对象字面量:动词键/嵌套组照常展开(dir 镜像起算)", () => {
|
|
30
38
|
const rows = scan("api.ts", `
|
|
@@ -109,3 +117,67 @@ export default api;
|
|
|
109
117
|
expect(viaIdent.map(r => `${r.method} ${r.full}`)).toEqual(["DELETE _platform/market/bye"]);
|
|
110
118
|
});
|
|
111
119
|
});
|
|
120
|
+
|
|
121
|
+
describe("handler-scan · 目录段参数化(`_name_` → `{name}`,v0.1.27)", () => {
|
|
122
|
+
// 契约 route 走 matchit 语法(`{id}`),handler 目录走 `_id_`;两者是同一条路由的
|
|
123
|
+
// 两种写法。扫描器若把目录名原样拼进 full,精确对账永远命中不了,`ojm api --check`
|
|
124
|
+
// 会把「已实现的参数路由」同时报成「契约未实现」+「handler 未登记」。
|
|
125
|
+
it("整段 `_id_` 目录 → `{id}`:单段与深层嵌套都转换", () => {
|
|
126
|
+
const one = scanAt("wiki/_id_/api.ts", "export default { get() { return {}; } };");
|
|
127
|
+
expect(one.map(r => `${r.method} ${r.full}`)).toEqual(["GET wiki/{id}"]);
|
|
128
|
+
|
|
129
|
+
const deep = scanAt("wiki/_id_/item/_sku_/api.ts", "export default { del() { return {}; } };");
|
|
130
|
+
expect(deep.map(r => `${r.method} ${r.full}`)).toEqual(["DELETE wiki/{id}/item/{sku}"]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("`dir` 保留磁盘事实,`full` 才是 wire 路径(两者不混用)", () => {
|
|
134
|
+
const [row] = scanAt("wiki/_id_/api.ts", "export default { get() { return {}; } };");
|
|
135
|
+
expect(row.dir).toBe("wiki/_id_");
|
|
136
|
+
expect(row.full).toBe("wiki/{id}");
|
|
137
|
+
expect(handlerAbs(row)).toBe("/wiki/{id}");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("不满足「整段 _name_」的目录保持字面(与 oj 内省同谓词)", () => {
|
|
141
|
+
const literal = ["__x__", "___", "_a{b}_", "_shared", "a_id_b"];
|
|
142
|
+
for (const seg of literal) {
|
|
143
|
+
const rows = scanAt(`m/${seg}/api.ts`, "export default { get() { return {}; } };");
|
|
144
|
+
expect(rows.map(r => r.full)).toEqual([`m/${seg}`]);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("模块段同样适用:`_aa_/bb/api.ts` → `{aa}/bb`", () => {
|
|
149
|
+
// module-root 布局:apiDir 是模块自己的 api 目录(src/<name>/api),模块名单独传
|
|
150
|
+
const moduleDir = join(dir, "api", "src", "_aa_");
|
|
151
|
+
const file = join(moduleDir, "api", "bb", "api.ts");
|
|
152
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
153
|
+
writeFileSync(file, "export default { get() { return {}; } };");
|
|
154
|
+
const rows = scanHandlerFile(file, join(moduleDir, "api"), "_aa_");
|
|
155
|
+
expect(rows.map(r => r.full)).toEqual(["{aa}/bb"]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("`.route` 尾巴已是 matchit 写法,不再做 `_` 转换;目录参数与相对尾自由组合", () => {
|
|
159
|
+
const rows = scanAt("wiki/_id_/api.ts", `
|
|
160
|
+
function get() { return {}; }
|
|
161
|
+
get.route = "{sub}";
|
|
162
|
+
export default { get };
|
|
163
|
+
`);
|
|
164
|
+
expect(rows.map(r => r.full)).toEqual(["wiki/{id}/{sub}"]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("绝对 `.route` 不受目录影响(根级挂载,full 即其自身)", () => {
|
|
168
|
+
const rows = scanAt("wiki/_id_/api.ts", `
|
|
169
|
+
function get() { return {}; }
|
|
170
|
+
get.route = "/audit/{tokenId}";
|
|
171
|
+
export default { get };
|
|
172
|
+
`);
|
|
173
|
+
expect(rows.map(r => r.full)).toEqual(["audit/{tokenId}"]);
|
|
174
|
+
expect(handlerAbs(rows[0])).toBe("/audit/{tokenId}");
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("handler-scan · 对账键与契约侧同形(参数路由精确命中)", () => {
|
|
179
|
+
it("`_id_` 目录与 `{id}` 契约段产生同一 pathKey", () => {
|
|
180
|
+
const [row] = scanAt("wiki/_id_/api.ts", "export default { del() { return {}; } };");
|
|
181
|
+
expect(pathKey(row.method, row.full)).toBe(pathKey("DELETE", "wiki/{id}"));
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -41,7 +41,7 @@ export interface HandlerRow {
|
|
|
41
41
|
tail?: string
|
|
42
42
|
/** v0.1.64:点分 handler 链("cccc.get";顶层动词即方法名) */
|
|
43
43
|
chain: string
|
|
44
|
-
/** v0.1.64:组前缀解析后的有效路由全路径(base 相对、无前导
|
|
44
|
+
/** v0.1.64:组前缀解析后的有效路由全路径(base 相对、无前导 /;目录段已参数化) */
|
|
45
45
|
full: string
|
|
46
46
|
filePath: string
|
|
47
47
|
}
|
|
@@ -186,11 +186,36 @@ function resolveRoute(own: string, prefix: string): string {
|
|
|
186
186
|
return own.startsWith("/") ? own : (prefix ? `${prefix}/${own}` : own);
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
/**
|
|
190
|
+
* 目录段 `_name_` → 参数段 `{name}`(v0.1.27,与 oj 内省同谓词):只认整段 `_name_`,
|
|
191
|
+
* 内部名非空且不以 `_` 开头/结尾、不含 `{}`;`__x__` / `___` / `_a{b}_` 保持字面,
|
|
192
|
+
* `_shared`(无尾下划线)不受影响。
|
|
193
|
+
*
|
|
194
|
+
* 为什么扫描器必须转换:**目录镜像就是路由声明**,契约侧 route 用 matchit 语法写 `{id}`,
|
|
195
|
+
* 磁盘侧写 `_id_`——同一条路由的两种写法。原样拼进 `full` 会让精确对账永远命中不了,
|
|
196
|
+
* `ojm api --check` 于是把已实现的参数路由同时报成「契约未实现」+「handler 未登记」
|
|
197
|
+
* 两条互相矛盾的违规(且模糊兜底因静态前缀不同也接不住)。
|
|
198
|
+
*/
|
|
199
|
+
function wireSegmentOf(seg: string): string {
|
|
200
|
+
const m = /^_([^/{}]+)_$/.exec(seg);
|
|
201
|
+
if (!m)
|
|
202
|
+
return seg;
|
|
203
|
+
const name = m[1];
|
|
204
|
+
if (name.startsWith("_") || name.endsWith("_"))
|
|
205
|
+
return seg;
|
|
206
|
+
return `{${name}}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** 目录镜像 → wire 路径段(逐段参数化;模块段同样适用:`_aa_/bb` → `{aa}/bb`) */
|
|
210
|
+
function wireDirOf(dir: string): string {
|
|
211
|
+
return dir.split("/").map(wireSegmentOf).join("/");
|
|
212
|
+
}
|
|
213
|
+
|
|
189
214
|
/** 有效路由全路径(base 相对、无前导 /):绝对 .route 取其自身,否则目录镜像 + 相对尾 */
|
|
190
215
|
function fullPathOf(dir: string, tail: string | undefined): string {
|
|
191
216
|
if (tail?.startsWith("/"))
|
|
192
217
|
return tail.replace(/^\//, "");
|
|
193
|
-
return [dir, tail].filter(Boolean).join("/");
|
|
218
|
+
return [wireDirOf(dir), tail].filter(Boolean).join("/");
|
|
194
219
|
}
|
|
195
220
|
|
|
196
221
|
/** 对账键(方法 + 全路径) */
|