@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.
Files changed (117) hide show
  1. package/devkit/manual.md +5 -5
  2. package/devkit/module-dev-and-cdn-playground.md +1 -1
  3. package/package.json +2 -2
  4. package/shell-dist/assets/{index-CnJsHOe-.js → index-FYt9CjFd.js} +50 -17
  5. package/shell-dist/assets/runtime.js +2 -2
  6. package/shell-dist/index.html +3 -3
  7. package/shell-dist/versions.json +3 -3
  8. package/src/__tests__/cli.test.ts +23 -14
  9. package/src/__tests__/list.test.ts +176 -0
  10. package/src/__tests__/registry-cli.test.ts +522 -0
  11. package/src/__tests__/registry-export.test.ts +319 -0
  12. package/src/__tests__/registry-import.test.ts +336 -0
  13. package/src/__tests__/registry-sign.test.ts +352 -0
  14. package/src/cli-output.ts +81 -0
  15. package/src/cli.ts +298 -39
  16. package/src/config-cmd.ts +100 -0
  17. package/src/contract/__tests__/handler-scan.test.ts +74 -2
  18. package/src/contract/handler-scan.ts +27 -2
  19. package/src/enable.ts +1 -1
  20. package/src/exit-codes.ts +33 -0
  21. package/src/index.ts +5 -9
  22. package/src/install.ts +120 -38
  23. package/src/lifecycle/__tests__/authoring.test.ts +6 -5
  24. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.bad.sig +3 -2
  25. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.sig +3 -2
  26. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.unknown.sig +3 -2
  27. package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.d.mts +5 -4
  28. package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.mjs +9 -6
  29. package/src/lifecycle/__tests__/helpers.ts +10 -6
  30. package/src/lifecycle/__tests__/install.test.ts +72 -15
  31. package/src/lifecycle/__tests__/manifest-contract.test.ts +40 -1
  32. package/src/lifecycle/__tests__/publish.test.ts +174 -33
  33. package/src/lifecycle/__tests__/sig-fixtures.test.ts +9 -6
  34. package/src/lifecycle/__tests__/sign.test.ts +9 -8
  35. package/src/lifecycle/__tests__/smoke.test.ts +1 -1
  36. package/src/lifecycle/__tests__/uninstall.test.ts +4 -4
  37. package/src/lifecycle/sign.ts +8 -7
  38. package/src/list.ts +146 -0
  39. package/src/manifest-contract.ts +11 -5
  40. package/src/market/__tests__/bootstrap.test.ts +4 -1
  41. package/src/market/__tests__/build-index.test.ts +529 -42
  42. package/src/market/__tests__/fixture.test.ts +17 -1
  43. package/src/market/__tests__/publish.test.ts +8 -7
  44. package/src/market/__tests__/setup.test.ts +4 -4
  45. package/src/market/__tests__/sharding.test.ts +90 -0
  46. package/src/market/__tests__/verify.test.ts +205 -12
  47. package/src/market/__tests__/yank-ledger.test.ts +60 -0
  48. package/src/market/bootstrap.ts +8 -3
  49. package/src/market/build-index.ts +407 -40
  50. package/src/market/cdn-layout.ts +3 -2
  51. package/src/market/command.ts +40 -6
  52. package/src/market/failure-playbook.ts +1 -1
  53. package/src/market/fixture.ts +20 -3
  54. package/src/market/index-builder.ts +120 -47
  55. package/src/market/publish.ts +3 -1
  56. package/src/market/setup.ts +3 -3
  57. package/src/market/sharding.ts +94 -0
  58. package/src/market/trial.ts +1 -1
  59. package/src/market/verify.ts +213 -50
  60. package/src/market/walkthrough.ts +7 -6
  61. package/src/market/yank-ledger.ts +91 -0
  62. package/src/publish.ts +250 -38
  63. package/src/registry-export.ts +405 -0
  64. package/src/registry-import.ts +410 -0
  65. package/src/registry-sidecar.ts +84 -0
  66. package/src/registry-sign.ts +322 -0
  67. package/src/registry-source.ts +192 -0
  68. package/src/sign.ts +13 -10
  69. package/src/site.ts +4 -4
  70. package/src/trust.ts +1 -1
  71. package/src/uninstall.ts +14 -2
  72. package/src/usage.ts +130 -25
  73. package/src/view.ts +94 -0
  74. package/templates/cdn/README.md +35 -5
  75. package/templates/config.yaml +10 -0
  76. package/templates/init.include.yaml +1 -1
  77. package/templates/src/_platform/api/_lifecycle/atomic.ts +3 -0
  78. package/templates/src/_platform/api/_lifecycle/installer.ts +44 -7
  79. package/templates/src/_platform/api/_lifecycle/limits.ts +21 -0
  80. package/templates/src/_platform/api/_lifecycle/source-seq.ts +88 -0
  81. package/templates/src/_platform/api/_lifecycle/steps/download.ts +15 -0
  82. package/templates/src/_platform/api/_lifecycle/steps/extract.ts +61 -2
  83. package/templates/src/_platform/api/_lifecycle/steps/preflight.ts +55 -6
  84. package/templates/src/_platform/api/_lifecycle/steps/register.ts +2 -1
  85. package/templates/src/_platform/api/_lifecycle/steps/verify.ts +22 -8
  86. package/templates/src/_platform/api/_lifecycle/tgz-guard.ts +96 -0
  87. package/templates/src/_platform/api/_lifecycle/types.ts +6 -3
  88. package/templates/src/_platform/api/_lifecycle/verify-checks.ts +8 -0
  89. package/templates/src/_platform/api/_shared/service/domain/solution-perms.ts +9 -1
  90. package/templates/src/_platform/api/_shared/site-facade.ts +62 -11
  91. package/templates/src/_platform/api/manifest.yaml +1 -0
  92. package/templates/src/_platform/api/market/api.ts +362 -99
  93. package/templates/src/_platform/api/market/index-source.ts +112 -0
  94. package/templates/src/_platform/api/migrations/0014__reg_source_seq.mysql.sql +9 -0
  95. package/templates/src/_platform/api/migrations/0014__reg_source_seq.pg.sql +7 -0
  96. package/templates/src/_platform/api/migrations/0014__reg_source_seq.sql +8 -0
  97. package/templates/src/_platform/api/schema.yaml +8 -0
  98. package/templates/src/_platform/api/solutions/api.ts +8 -3
  99. package/templates/src/oj-market/web/__tests__/CardGrid.test.tsx +13 -0
  100. package/templates/src/oj-market/web/__tests__/InstallDialog.test.tsx +38 -0
  101. package/templates/src/oj-market/web/components/CardItem.tsx +10 -0
  102. package/templates/src/oj-market/web/components/InstallDialog.tsx +18 -0
  103. package/templates/src/oj-market/web/locales/en-US.json +1 -0
  104. package/templates/src/oj-market/web/locales/zh-CN.json +1 -0
  105. package/templates/src/oj-market/web/types.ts +3 -1
  106. package/templates/src/system/web/entry.ts +5 -2
  107. package/templates/tests/lifecycle/installer.spec.ts +24 -0
  108. package/templates/tests/lifecycle/market-api.spec.ts +641 -85
  109. package/templates/tests/lifecycle/preflight.spec.ts +52 -0
  110. package/templates/tests/lifecycle/sig-fixtures.spec.ts +7 -4
  111. package/templates/tests/lifecycle/source-seq.spec.ts +55 -0
  112. package/templates/tests/lifecycle/steps-fs.spec.ts +166 -7
  113. package/templates/tests/lifecycle/tgz-guard.spec.ts +133 -1
  114. package/templates/tests/lifecycle/verify-checks.spec.ts +27 -13
  115. package/templates/tests/lifecycle/verify-report.spec.ts +3 -1
  116. package/templates/tests/rbac-solution-perms.spec.ts +41 -0
  117. 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 { exit?: (code: number) => never }
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 1——打帮助退 0 会掩盖拼写错误。
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 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
- };
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
- return { text: usageText(lang), code: 1 };
59
+ else {
60
+ console.log(r.text);
61
+ }
62
+ return { text: r.text, code: r.code };
38
63
  }
39
64
 
40
- /** cac 的内置报错是英文裸消息(Unknown option)——按语言重写成人话,带修复指引。 */
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 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)`);
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
- /** 与旧 index.ts 顶层 catch 同源:message 已是人话,堆栈仅 OJM_DEBUG=1(兼容旧名 RAM_DEBUG)。 */
53
- export function reportCliError(error: unknown, exit: (code: number) => never): void {
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
- exit(1);
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 { url?: string, user?: string, pass?: string }
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
- console.error("[ojm] 用法:ojm new <name> [--layout container|none] [--api](非交互环境必须显式给 name)");
95
- exit(1);
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 Error(`[ojm] ojm new 只接受一个方案名(多余参数:${extra.join(" ")})`);
148
+ throw new UsageError(`[ojm] ojm new 只接受一个方案名(多余参数:${extra.join(" ")})`);
101
149
  if (opts.layout !== undefined && opts.layout !== "container" && opts.layout !== "none")
102
- throw new Error(`[ojm] --layout 只接受 container | none(实际:${opts.layout})`);
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 Error(`[ojm] 非法版本号:${tag}(形如 v0.1.11 或 0.1.11)`);
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
- publishCommand(dir ?? "", { id: opts.id, key: opts.key, check: opts.check });
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 ?? "", { url: opts.url, user: opts.user, pass: opts.pass, locale: opts.locale, name: opts.name, version: opts.version });
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", { url: opts.url, user: opts.user, pass: opts.pass });
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 ?? "", { url: opts.url, user: opts.user, pass: opts.pass });
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), { url: opts.url, user: opts.user, pass: opts.pass, task: opts.task });
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 相对、无前导 /;绝对 .route 即其自身) */
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
  /** 对账键(方法 + 全路径) */