@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/enable.ts CHANGED
@@ -4,7 +4,7 @@ import process from "node:process";
4
4
  import { callApi, login, resolveSite } from "./site";
5
5
 
6
6
  export interface EnableOptions {
7
- url?: string
7
+ siteUrl?: string
8
8
  user?: string
9
9
  pass?: string
10
10
  /** 测试注入;缺省 globalThis.fetch */
@@ -0,0 +1,33 @@
1
+ // 退出码契约(REG-R12):0 成功 / 1 业务失败 / 2 用法错误。
2
+ //
3
+ // 为什么要三档而不是「非零即失败」:CI 里这两类失败的处置完全不同——
4
+ // · 1 = 命令跑通了、但业务说不(包不存在、签名不符、站点拒绝)→ 看输出修数据;
5
+ // · 2 = 命令根本没跑(选项拼错、必需参数缺失、互斥选项同时给)→ 改脚本。
6
+ // 混在 1 里,CI 只能靠解析人话文案来分辨,而文案是会改的。
7
+ //
8
+ // 三类例外的边界(`exitCodeOf`):
9
+ // · `UsageError` → 2(本文件显式构造的「用法」类错误,含 cac 的未知选项/未知命令);
10
+ // · 其余一切 Error → 1(业务失败;未预期异常也归这里,不假装是用法问题);
11
+ // · 成功 → 0(调用方不显式设置,进程自然退 0)。
12
+ //
13
+ // 纪律:只有**能指出改哪一行命令行**的错误才配 `UsageError`。拿不准就归 1——
14
+ // 把业务失败误判成 2 会让 CI 去改脚本,比反过来更难查。
15
+
16
+ export const EXIT_OK = 0;
17
+ export const EXIT_FAILURE = 1;
18
+ export const EXIT_USAGE = 2;
19
+
20
+ /** 用法错误:消息必须是「怎么改命令行」的人话(含正确语法) */
21
+ export class UsageError extends Error {
22
+ readonly exitCode = EXIT_USAGE;
23
+
24
+ constructor(message: string) {
25
+ super(message);
26
+ this.name = "UsageError";
27
+ }
28
+ }
29
+
30
+ /** 错误 → 退出码(`reportCliError` 与 index.ts 的兜底共用这一个判定) */
31
+ export function exitCodeOf(error: unknown): number {
32
+ return error instanceof UsageError ? EXIT_USAGE : EXIT_FAILURE;
33
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import process from "node:process";
2
- import { buildCli, localizeCacError, reportCliError, rewriteHelpArgv, unknownCommandResult } from "./cli";
2
+ import { buildCli, localizeCacError, reportCliError, reportUnknownCommand, rewriteHelpArgv } from "./cli";
3
3
  import { isKnownCommand, resolveLang, usageText } from "./usage";
4
4
  import { readCliVersion } from "./versions";
5
5
 
@@ -14,7 +14,7 @@ async function main() {
14
14
  }
15
15
 
16
16
  const rewritten = rewriteHelpArgv(argv);
17
- const cli = buildCli(process.cwd(), lang);
17
+ const cli = buildCli(process.cwd(), lang, { argv: rewritten });
18
18
  let unknown: string | undefined;
19
19
  cli.on("command:*", () => {
20
20
  // cac 在 --help 输出后会 unsetMatchedCommand 再发 command:*(args[0] 可能是子命令,如 verify)——
@@ -25,13 +25,9 @@ async function main() {
25
25
  });
26
26
  cli.parse(["node", "ojm", ...rewritten]);
27
27
 
28
- // 未知命令:help 形态报「未知命令」、否则打帮助;均 exit 1(裸 ojm 打帮助退 0 不变)
28
+ // 未知命令:help 形态报「未知命令」、否则打帮助;均 exit 2(裸 ojm 打帮助退 0 不变)
29
29
  if (unknown !== undefined) {
30
- const r = unknownCommandResult(unknown, lang, !!cli.options.help);
31
- if (cli.options.help)
32
- console.error(r.text);
33
- else
34
- console.log(r.text);
30
+ const r = reportUnknownCommand(unknown, lang, !!cli.options.help, rewritten);
35
31
  process.exit(r.code);
36
32
  }
37
33
  if (argv.length === 0)
@@ -40,4 +36,4 @@ async function main() {
40
36
 
41
37
  main().catch((error: unknown) => reportCliError(localizeCacError(error, resolveLang()), (code) => {
42
38
  process.exit(code);
43
- }));
39
+ }, process.argv.slice(2)));
package/src/install.ts CHANGED
@@ -8,8 +8,67 @@ import type { TaskRecord } from "./site";
8
8
  import fs from "node:fs";
9
9
  import path from "node:path";
10
10
  import process from "node:process";
11
+ import { UsageError } from "./exit-codes";
12
+ import { originOf, resolveRegistrySource } from "./registry-source";
11
13
  import { callApi, isFutureSchema, login, pollTask, resolveSite, TASK_SCHEMA_VERSION_KNOWN, unwrapEnvelope } from "./site";
12
14
 
15
+ /**
16
+ * REG-R5 / S5-2②:安装目标的**唯一**归一点(grep 可证——全仓只有这一处把 `@scope/name`
17
+ * 换算成物理名 slug)。服务端 install 只认 slug(收到 `@`/`/` 即 400 `invalid_physical_name`),
18
+ * 所以「目录键 → 物理名」的换算必须只在 CLI 完成一次,绝不分层;服务端是最后一道闸。
19
+ *
20
+ * 形态:`@scope/name[@version]` / `scope/name[@version]` → slug=`scope-name`;裸 `slug[@version]`
21
+ * 视为已是物理名,原样透传。字符集与下游 `naming.ts` 的 `SEGMENT_RE`/`SLUG_RE` 同口径
22
+ * (小写字母开头 + 小写字母/数字/连字符,slug 上限 64),保证 CLI 推导的 slug 与发布侧落盘名逐字一致。
23
+ */
24
+ const SEGMENT_RE = /^[a-z][a-z0-9-]{0,63}$/;
25
+ const SLUG_RE = /^[a-z][a-z0-9-]{0,63}$/;
26
+
27
+ export function parseRegistryTarget(raw: string): { slug: string, version: string | null } {
28
+ let rest = String(raw ?? "").trim().replace(/^@/, "");
29
+ if (rest === "")
30
+ throw new UsageError(`[ojm] 非法安装目标:「${raw}」(registry 源用法:ojm install @<scope>/<name>[@version] --registry <url>)`);
31
+ // 版本:尾随 @(与 scope 前缀的 @ 不冲突——先剥掉前缀 @ 再取 lastIndexOf)
32
+ let version: string | null = null;
33
+ const at = rest.lastIndexOf("@");
34
+ if (at > 0) {
35
+ version = rest.slice(at + 1);
36
+ rest = rest.slice(0, at);
37
+ }
38
+ if (rest.includes("/")) {
39
+ const slash = rest.indexOf("/");
40
+ const scope = rest.slice(0, slash);
41
+ const name = rest.slice(slash + 1);
42
+ if (!SEGMENT_RE.test(scope) || !SEGMENT_RE.test(name))
43
+ throw new UsageError(`[ojm] 目录键「${raw}」不合法:scope 与 name 都须匹配 ${SEGMENT_RE.source}(REG-R5)`);
44
+ const slug = `${scope}-${name}`;
45
+ if (!SLUG_RE.test(slug))
46
+ throw new UsageError(`[ojm] 物理名「${slug}」不合法:须匹配 ${SLUG_RE.source}(slug 上限 64 字符)`);
47
+ return { slug, version: version === "" ? null : version };
48
+ }
49
+ if (!SLUG_RE.test(rest))
50
+ throw new UsageError(`[ojm] 物理名「${rest}」不合法:须匹配 ${SLUG_RE.source}(裸 slug 须为小写字母开头的小写字母/数字/连字符)`);
51
+ return { slug: rest, version: version === "" ? null : version };
52
+ }
53
+
54
+ /**
55
+ * REG-R9② 旧站点拒:站点平台 API 版本是否 ≥ `min`。`undefined`(meta 端点缺失/未注入)按
56
+ * 旧版本处理——宁可不装,不静默降级到不支持 registry 源的站点。
57
+ */
58
+ export function isSiteApiAtLeast(apiVersion: string | undefined, min: string): boolean {
59
+ if (apiVersion === undefined)
60
+ return false;
61
+ const parse = (v: string): [number, number, number] | null => {
62
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
63
+ return m === null ? null : [Number(m[1]), Number(m[2]), Number(m[3])];
64
+ };
65
+ const a = parse(apiVersion);
66
+ const b = parse(min);
67
+ if (a === null || b === null)
68
+ return false;
69
+ return a[0] > b[0] || (a[0] === b[0] && (a[1] > b[1] || (a[1] === b[1] && a[2] >= b[2])));
70
+ }
71
+
13
72
  export type Locale = "zh-CN" | "en-US";
14
73
 
15
74
  /** 契约附录 A 十阶段指引(zh/en 双语列;文案单一来源 = 契约附录 A 表,此处为渲染镜像) */
@@ -87,8 +146,8 @@ const VERIFY_CODE_COPY: Record<string, { reason: { zh: string, en: string }, hin
87
146
  },
88
147
  sig_format: {
89
148
  reason: {
90
- zh: "签名编码违规:公钥非 64 位 hex、签名非标准 base64(禁 base64url),或规范串版本标记非 v1",
91
- en: "Signature encoding violation: public key is not 64-hex, signature is not standard base64 (base64url forbidden), or the canonical-version marker is not v1",
149
+ zh: "签名编码违规:公钥非 64 位 hex、签名非标准 base64(禁 base64url),或规范串版本标记非 v2",
150
+ en: "Signature encoding violation: public key is not 64-hex, signature is not standard base64 (base64url forbidden), or the canonical-version marker is not v2",
92
151
  },
93
152
  hint: {
94
153
  zh: "请让发布方使用标准签名工具(ojm sign)重新签名",
@@ -184,7 +243,7 @@ export function parseTarget(target: string): InstallTarget {
184
243
  }
185
244
 
186
245
  export interface InstallOptions {
187
- url?: string
246
+ siteUrl?: string
188
247
  user?: string
189
248
  pass?: string
190
249
  /** file 源身份覆盖(文件名不可解析时的显式出口);cdn 源同名冲突时报错提示二选一 */
@@ -193,6 +252,8 @@ export interface InstallOptions {
193
252
  version?: string
194
253
  /** 终态文案语言(缺省 zh-CN) */
195
254
  locale?: string
255
+ /** REG-R9②:从指定 registry 源安装(白名单在服务端;旧站点 API < 0.2.0 不支持 → exit 1) */
256
+ registry?: string
196
257
  /** 测试注入;缺省 globalThis.fetch */
197
258
  fetchFn?: typeof fetch
198
259
  /** 测试注入;缺省真睡 */
@@ -206,48 +267,69 @@ export interface InstallOptions {
206
267
  */
207
268
  export async function installCommand(target: string, opts: InstallOptions = {}): Promise<TaskRecord> {
208
269
  const fetchFn = opts.fetchFn ?? globalThis.fetch;
209
- const parsed = parseTarget(target);
210
270
  const { base, user, pass } = resolveSite(opts);
211
271
  const token = await login(fetchFn, base, user, pass);
212
272
 
213
273
  let response: { task_id: string };
214
- if (parsed.kind === "file") {
215
- const name = opts.name ?? parsed.name;
216
- if (name === null)
217
- throw new Error(`[ojm] 无法从文件名推导方案名:${target}——请用 --name <name>(及 --version <version>)显式指定`);
218
- const version = opts.version ?? parsed.version;
219
- const bytes = new Uint8Array(fs.readFileSync(parsed.path));
220
- const form = new FormData();
221
- form.append("name", name);
222
- if (version !== null)
223
- form.append("version", version);
224
- form.append("source", "file");
225
- form.append("file", new Blob([bytes], { type: "application/gzip" }), path.basename(parsed.path));
226
- // detached 侧车签名(P2-3,G4):同目录 <tgz>.sig 自动探测随 multipart 第二文件上传——
227
- // 缺签即被宿主 G4 拒(sig_missing 人话转述),与 Form B 同一信任模型
228
- const sigPath = `${parsed.path}.sig`;
229
- if (fs.existsSync(sigPath)) {
230
- form.append("sig", new Blob([new Uint8Array(fs.readFileSync(sigPath))], { type: "application/json" }), path.basename(sigPath));
231
- console.log(`[ojm] 已随包上传签名侧车:${path.basename(sigPath)}`);
232
- }
233
- else {
234
- console.log(`[ojm] 未发现签名侧车(${path.basename(sigPath)})——宿主验签硬 fail-closed,未签名包将被拒(sig_missing);签名方法见 devkit「签名与信任」`);
235
- }
236
- const res = await fetchFn(`${base}/api/_platform/market/install`, {
237
- method: "POST",
238
- headers: { authorization: `Bearer ${token}` },
239
- body: form,
274
+ if (opts.registry !== undefined) {
275
+ // REG-R9②:registry 源安装。归一(目录键 → slug)只在此一处;旧站点拒;白名单在服务端。
276
+ const src = resolveRegistrySource({ flag: opts.registry });
277
+ const { slug, version } = parseRegistryTarget(target);
278
+ if (opts.name !== undefined && opts.name !== slug)
279
+ throw new UsageError(`[ojm] --name「${opts.name}」与 registry 目标推导的物理名「${slug}」不一致——registry 源由目标推导身份,请二选一`);
280
+ // 旧站点拒:meta apiVersion < 0.2.0 不支持 registry 源(REG-R9② 升版反噬 F4 的人话面)
281
+ const meta = await callApi<{ apiVersion?: string }>(fetchFn, base, token, "GET", "/api/_platform/market/meta");
282
+ if (!isSiteApiAtLeast(meta.apiVersion, "0.2.0"))
283
+ throw new Error(`[ojm] 目标站点平台 API 版本过低(需 ≥ 0.2.0 才支持 --registry 源安装,实为 ${meta.apiVersion ?? "未知"})——请升级目标站点的 oj-module,或改用 CDN/file 源`);
284
+ const registryOrigin = originOf(src.url) ?? src.url;
285
+ response = await callApi<{ task_id: string }>(fetchFn, base, token, "POST", "/api/_platform/market/install", {
286
+ name: slug,
287
+ version: opts.version ?? version,
288
+ source: "registry",
289
+ registry: registryOrigin,
240
290
  });
241
- response = await unwrapInstall(res);
291
+ console.log(`[ojm] 已从 registry 源提交安装任务(源 ${registryOrigin},物理名 ${slug})`);
242
292
  }
243
293
  else {
244
- if (opts.name !== undefined && opts.name !== parsed.name)
245
- throw new Error(`[ojm] --name「${opts.name}」与目标「${parsed.name}」不一致——请二选一`);
246
- response = await callApi<{ task_id: string }>(fetchFn, base, token, "POST", "/api/_platform/market/install", {
247
- name: parsed.name,
248
- version: opts.version ?? parsed.version,
249
- source: "cdn",
250
- });
294
+ const parsed = parseTarget(target);
295
+ if (parsed.kind === "file") {
296
+ const name = opts.name ?? parsed.name;
297
+ if (name === null)
298
+ throw new Error(`[ojm] 无法从文件名推导方案名:${target}——请用 --name <name>(及 --version <version>)显式指定`);
299
+ const version = opts.version ?? parsed.version;
300
+ const bytes = new Uint8Array(fs.readFileSync(parsed.path));
301
+ const form = new FormData();
302
+ form.append("name", name);
303
+ if (version !== null)
304
+ form.append("version", version);
305
+ form.append("source", "file");
306
+ form.append("file", new Blob([bytes], { type: "application/gzip" }), path.basename(parsed.path));
307
+ // detached 侧车签名(P2-3,G4):同目录 <tgz>.sig 自动探测随 multipart 第二文件上传——
308
+ // 缺签即被宿主 G4 拒(sig_missing 人话转述),与 Form B 同一信任模型
309
+ const sigPath = `${parsed.path}.sig`;
310
+ if (fs.existsSync(sigPath)) {
311
+ form.append("sig", new Blob([new Uint8Array(fs.readFileSync(sigPath))], { type: "application/json" }), path.basename(sigPath));
312
+ console.log(`[ojm] 已随包上传签名侧车:${path.basename(sigPath)}`);
313
+ }
314
+ else {
315
+ console.log(`[ojm] 未发现签名侧车(${path.basename(sigPath)})——宿主验签硬 fail-closed,未签名包将被拒(sig_missing);签名方法见 devkit「签名与信任」`);
316
+ }
317
+ const res = await fetchFn(`${base}/api/_platform/market/install`, {
318
+ method: "POST",
319
+ headers: { authorization: `Bearer ${token}` },
320
+ body: form,
321
+ });
322
+ response = await unwrapInstall(res);
323
+ }
324
+ else {
325
+ if (opts.name !== undefined && opts.name !== parsed.name)
326
+ throw new Error(`[ojm] --name「${opts.name}」与目标「${parsed.name}」不一致——请二选一`);
327
+ response = await callApi<{ task_id: string }>(fetchFn, base, token, "POST", "/api/_platform/market/install", {
328
+ name: parsed.name,
329
+ version: opts.version ?? parsed.version,
330
+ source: "cdn",
331
+ });
332
+ }
251
333
  }
252
334
 
253
335
  const record = await pollTask(fetchFn, base, token, response.task_id, { sleep: opts.sleep });
@@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
8
8
  import { DEFAULT_KEY_ID, isValidKeyId, keygenCommand } from "../../keygen";
9
9
  import { signCommand } from "../../sign";
10
10
  import { readPrivateKeyPem } from "../keygen";
11
- import { fingerprintOfPublicKey, signCanonical as signCanonicalLocal, verifyCanonical } from "../sign";
11
+ import { CANON, fingerprintOfPublicKey, signCanonical as signCanonicalLocal, verifyCanonical } from "../sign";
12
12
 
13
13
  const tmp = path.join(os.tmpdir(), `ojm-authoring-${Date.now()}-${Math.random().toString(36).slice(2)}`);
14
14
 
@@ -66,20 +66,21 @@ describe("ojm sign 命令体(detached .sig,G4 载体)", () => {
66
66
  fs.writeFileSync(TGZ, "demo-archive-bytes\n");
67
67
  });
68
68
 
69
- it("签名产出 .sig 五字段(alg/key_id/public_key/value/canon),且 verifyCanonical 通过", () => {
69
+ it("签名产出 .sig 六字段(alg/key_id/public_key/value/canon/registry_id),且 verifyCanonical 通过", () => {
70
70
  const kp = keygenCommand({ id: "acme.market", out: path.join(tmp, "k"), yes: true, cwd: tmp });
71
- const r = signCommand(TGZ, { id: "acme.market", key: kp.privateKeyPath });
71
+ const r = signCommand(TGZ, { id: "acme.market", key: kp.privateKeyPath, registryId: "oj-registry" });
72
72
  const sig = JSON.parse(fs.readFileSync(r.sigPath, "utf8"));
73
73
  expect(sig.alg).toBe("Ed25519");
74
74
  expect(sig.key_id).toBe("acme.market");
75
75
  expect(sig.public_key).toBe(kp.publicKeyHex);
76
- expect(sig.canon).toBe("v1");
76
+ expect(sig.canon).toBe(CANON);
77
+ expect(sig.registry_id).toBe("oj-registry");
77
78
  expect(verifyCanonical(sig.public_key, r.canonical, sig.value)).toBe(true);
78
79
  });
79
80
 
80
81
  it("--check 离线自检:真签名通过(checkPassed=true);--key+--id 自定义位置", () => {
81
82
  const kp = keygenCommand({ id: "acme.market", out: path.join(tmp, "k"), yes: true, cwd: tmp });
82
- const r = signCommand(TGZ, { id: "acme.market", key: kp.privateKeyPath, check: true });
83
+ const r = signCommand(TGZ, { id: "acme.market", key: kp.privateKeyPath, check: true, registryId: "oj-registry" });
83
84
  expect(r.checkPassed).toBe(true);
84
85
  });
85
86
 
@@ -2,6 +2,7 @@
2
2
  "alg": "Ed25519",
3
3
  "key_id": "ojm.test",
4
4
  "public_key": "6320cd295df4a2cf48b90282daae32e133fefd0459d71af92d6c376889d038ef",
5
- "value": "AizOO9Xhj4iCJJj8GRORgl7G1IzvGHfEZ1tYq6NEuwxiVDdrX7Nu3sqhazxXyrfzybPa11M23RZtji4iLHq1Dw==",
6
- "canon": "v1"
5
+ "value": "AOtQDT2VLSQrVespj5JLMBYTCdXdWVLKh7mowJJw/mrYvfrlTwnLKvpGIMLvDZYtD+XxlHoRN/lBFVHVirpAAw==",
6
+ "canon": "v2",
7
+ "registry_id": "test.registry"
7
8
  }
@@ -2,6 +2,7 @@
2
2
  "alg": "Ed25519",
3
3
  "key_id": "ojm.test",
4
4
  "public_key": "6320cd295df4a2cf48b90282daae32e133fefd0459d71af92d6c376889d038ef",
5
- "value": "ZizOO9Xhj4iCJJj8GRORgl7G1IzvGHfEZ1tYq6NEuwxiVDdrX7Nu3sqhazxXyrfzybPa11M23RZtji4iLHq1Dw==",
6
- "canon": "v1"
5
+ "value": "oOtQDT2VLSQrVespj5JLMBYTCdXdWVLKh7mowJJw/mrYvfrlTwnLKvpGIMLvDZYtD+XxlHoRN/lBFVHVirpAAw==",
6
+ "canon": "v2",
7
+ "registry_id": "test.registry"
7
8
  }
@@ -2,6 +2,7 @@
2
2
  "alg": "Ed25519",
3
3
  "key_id": "stranger.unknown",
4
4
  "public_key": "a329626d90b337a969f66b101ad8bda4dc95f8c6f000731083147c262e8bbabb",
5
- "value": "A8X5XsXQJOvIY55I1Dnz7bB6Y/TY0yLJiGze9AFN+ZsHor5ZPlm09ZLgBRdaymw/cLaEgeSkLr6dnYXaKSDxDw==",
6
- "canon": "v1"
5
+ "value": "qexZRu47FY3tGzjdutR0SxJ75v3ybCZNTZJmD/FbQQhhl+QWMaPCo9OKFsTsQHykB6vNXX3T921kqOM81mzkAA==",
6
+ "canon": "v2",
7
+ "registry_id": "test.registry"
7
8
  }
@@ -2,9 +2,10 @@
2
2
  export declare const FIXTURE_NAME: string;
3
3
  export declare const FIXTURE_VERSION: string;
4
4
  export declare const CANON: string;
5
+ export declare const FIXTURE_REGISTRY_ID: string;
5
6
  export declare const TRUSTED_KEY: { key_id: string, public_key_hex: string };
6
7
  export declare const UNKNOWN_KEY: { key_id: string, public_key_hex: string };
7
- export declare function canonicalString(name: string, version: string, sha256: string): string;
8
+ export declare function canonicalString(registryId: string, slug: string, version: string, sha256: string): string;
8
9
  export declare function fingerprintOf(publicKeyHex: string): string;
9
10
  export declare function signCanonicalB64(privateKey: unknown, canonical: string): string;
10
11
  export declare function verifyCanonicalB64(publicKeyHex: string, canonical: string, valueB64: string): boolean;
@@ -14,8 +15,8 @@ export declare function generate(outDir?: string): {
14
15
  tgzPath: string
15
16
  paths: { tgz: string, good: string, bad: string, unknown: string }
16
17
  sig: {
17
- good: { alg: string, key_id: string, public_key: string, value: string, canon: string }
18
- bad: { alg: string, key_id: string, public_key: string, value: string, canon: string }
19
- unknown: { alg: string, key_id: string, public_key: string, value: string, canon: string }
18
+ good: { alg: string, key_id: string, public_key: string, value: string, canon: string, registry_id: string }
19
+ bad: { alg: string, key_id: string, public_key: string, value: string, canon: string, registry_id: string }
20
+ unknown: { alg: string, key_id: string, public_key: string, value: string, canon: string, registry_id: string }
20
21
  }
21
22
  };
@@ -30,13 +30,15 @@ const FIXTURE_DIR = path.dirname(fileURLToPath(import.meta.url));
30
30
 
31
31
  export const FIXTURE_NAME = "demo-solution";
32
32
  export const FIXTURE_VERSION = "0.2.0";
33
- /** 规范串版本标记(契约 §2.11:canon 字段恒 "v1") */
34
- export const CANON = "v1";
33
+ /** 规范串版本标记(REG-CS3 v2:canon 字段恒 "v2") */
34
+ export const CANON = "v2";
35
+ /** 夹具签发方 registry 身份(REG-CS3 v2 规范串首段,跨仓重放判定真源) */
36
+ export const FIXTURE_REGISTRY_ID = "test.registry";
35
37
  const SEED_PREFIX = "ojm-lifecycle-fixture-v1:";
36
38
 
37
- /** 契约 v1.3 §2.11 规范串格式(逐字,P2-1 照抄实现,禁止自造分隔符) */
38
- export function canonicalString(name, version, sha256) {
39
- return `${name}@${version}:${sha256}`;
39
+ /** REG-CS3 v2 规范串格式(逐字:`<registry_id>:<slug>@<version>:<sha256>`) */
40
+ export function canonicalString(registryId, slug, version, sha256) {
41
+ return `${registryId}:${slug}@${version}:${sha256}`;
40
42
  }
41
43
 
42
44
  /** 指纹 = sha256Hex(publicKey) 全 64 hex(契约 §2.11,UI 分组展示用) */
@@ -155,7 +157,7 @@ function tamperFirstChar(valueB64) {
155
157
  export function generate(outDir = FIXTURE_DIR) {
156
158
  const tgz = buildTgz(fixtureMembers());
157
159
  const sha256 = createHash("sha256").update(tgz).digest("hex");
158
- const canonical = canonicalString(FIXTURE_NAME, FIXTURE_VERSION, sha256);
160
+ const canonical = canonicalString(FIXTURE_REGISTRY_ID, FIXTURE_NAME, FIXTURE_VERSION, sha256);
159
161
 
160
162
  const sigOf = (keyId, publicKeyHex, privateKey) => ({
161
163
  alg: "Ed25519",
@@ -163,6 +165,7 @@ export function generate(outDir = FIXTURE_DIR) {
163
165
  public_key: publicKeyHex,
164
166
  value: signCanonicalB64(privateKey, canonical),
165
167
  canon: CANON,
168
+ registry_id: FIXTURE_REGISTRY_ID,
166
169
  });
167
170
 
168
171
  const good = sigOf(TRUSTED_KEY.key_id, TRUSTED_KEY.public_key_hex, trusted.privateKey);
@@ -26,13 +26,15 @@ export function nodeVerifyEd25519(publicKeyHex: string, msg: string, sigB64: str
26
26
  }
27
27
  }
28
28
 
29
- /** detached 侧车 / Form B archive.signature 的字段形状(契约 §2.11 / C2-35) */
29
+ /** detached 侧车 / Form B archive.signature 的字段形状(契约 §2.11 / C2-35,REG-CS3 增 registry_id) */
30
30
  export interface SigFile {
31
31
  alg: string
32
32
  key_id: string
33
33
  public_key: string
34
34
  value: string
35
35
  canon: string
36
+ /** 签发方 registry 身份(REG-CS3 v2 规范串首段;跨仓重放判定真源) */
37
+ registry_id: string
36
38
  }
37
39
 
38
40
  export interface SignedArchive {
@@ -54,15 +56,17 @@ export function defaultTestKey(): KeyPair {
54
56
  }
55
57
 
56
58
  /**
57
- * 对 tgz 字节签名:sha256(bytes) → 规范串 → Ed25519 → .sig 五字段。
58
- * `keyPair` 缺省 = defaultTestKey();传第二个 keyPair 即「未知发布方」场景。
59
+ * 对 tgz 字节签名:sha256(bytes) → 规范串(REG-CS3 v2:`<registry_id>:<slug>@<version>:<sha256>`)
60
+ * → Ed25519 → .sig 五字段 + registry_id。
61
+ * `keyPair` 缺省 = defaultTestKey();传第二个 keyPair 即「未知发布方」场景;`registryId` 缺省 = "test.registry"。
59
62
  */
60
- export function signedArchiveFor(tgz: Uint8Array, name: string, version: string, opts?: { keyPair?: KeyPair, keyId?: string }): SignedArchive {
63
+ export function signedArchiveFor(tgz: Uint8Array, name: string, version: string, opts?: { keyPair?: KeyPair, keyId?: string, registryId?: string }): SignedArchive {
61
64
  const keyPair = opts?.keyPair ?? defaultTestKey();
65
+ const registryId = opts?.registryId ?? "test.registry";
62
66
  const sha256 = createHash("sha256").update(tgz).digest("hex");
63
- const value = signCanonical(keyPair.privateKeyPem, canonicalString(name, version, sha256));
67
+ const value = signCanonical(keyPair.privateKeyPem, canonicalString(registryId, name, version, sha256));
64
68
  return {
65
- sig: { alg: SIG_ALG, key_id: opts?.keyId ?? "test.publisher", public_key: keyPair.publicKeyHex, value, canon: CANON },
69
+ sig: { alg: SIG_ALG, key_id: opts?.keyId ?? "test.publisher", public_key: keyPair.publicKeyHex, value, canon: CANON, registry_id: registryId },
66
70
  keyId: opts?.keyId ?? "test.publisher",
67
71
  publicKeyHex: keyPair.publicKeyHex,
68
72
  fingerprint: fingerprintOfPublicKey(keyPair.publicKeyHex),
@@ -7,7 +7,7 @@ import { tmpdir } from "node:os";
7
7
  import { join } from "node:path";
8
8
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9
9
  import { setEnabledCommand } from "../../enable";
10
- import { installCommand, parseTarget, renderOutcome } from "../../install";
10
+ import { installCommand, isSiteApiAtLeast, parseRegistryTarget, parseTarget, renderOutcome } from "../../install";
11
11
  import { pollTask, resolveSite } from "../../site";
12
12
 
13
13
  /** 终态记录工厂(§10.2 形状,测试只需关键字段) */
@@ -71,12 +71,12 @@ describe("resolveSite · 站点寻址(flag > 环境变量 > 缺省模板 serve
71
71
  expect(resolveSite({ user: "u", pass: "p" })).toEqual({ base: "http://127.0.0.1:9778", user: "u", pass: "p" });
72
72
  });
73
73
 
74
- it("环境变量 OJM_URL/OJM_USER/OJM_PASS 兜底;flag 优先;尾斜杠归一", () => {
75
- process.env.OJM_URL = "http://env:9/";
74
+ it("环境变量 OJM_SITE_URL/OJM_USER/OJM_PASS 兜底;flag 优先;尾斜杠归一", () => {
75
+ process.env.OJM_SITE_URL = "http://env:9/";
76
76
  process.env.OJM_USER = "eu";
77
77
  process.env.OJM_PASS = "ep";
78
78
  expect(resolveSite({})).toEqual({ base: "http://env:9", user: "eu", pass: "ep" });
79
- expect(resolveSite({ url: "http://flag:8/", user: "fu", pass: "fp" }).base).toBe("http://flag:8");
79
+ expect(resolveSite({ siteUrl: "http://flag:8/", user: "fu", pass: "fp" }).base).toBe("http://flag:8");
80
80
  });
81
81
 
82
82
  it("缺凭据即人话报错(凭据只走内存不落盘)", () => {
@@ -251,7 +251,7 @@ describe("schemaVersion 旧记录兼容(P2-10 升版纪律读方侧:高版
251
251
  const exitBackup = process.exitCode;
252
252
  process.exitCode = 0;
253
253
  try {
254
- await installCommand(tgz, { url: "http://x", user: "u", pass: "p", fetchFn, sleep: async () => {} });
254
+ await installCommand(tgz, { siteUrl: "http://x", user: "u", pass: "p", fetchFn, sleep: async () => {} });
255
255
  expect(process.exitCode).toBe(1);
256
256
  }
257
257
  finally {
@@ -354,7 +354,7 @@ describe("installCommand · file 源端到端(stub fetch:登录 → multipar
354
354
  "/api/_platform/market/install": () => ok({ task_id: "t1" }),
355
355
  "/api/_platform/market/task": () => ok(rec()),
356
356
  });
357
- await installCommand(tgz, { url: "http://x", user: "u", pass: "p", fetchFn, sleep: async () => {} });
357
+ await installCommand(tgz, { siteUrl: "http://x", user: "u", pass: "p", fetchFn, sleep: async () => {} });
358
358
  // 登录体
359
359
  const loginBody = JSON.parse(String(calls[0]?.init?.body));
360
360
  expect(loginBody).toEqual({ username: "u", password: "p" });
@@ -388,7 +388,7 @@ describe("installCommand · file 源端到端(stub fetch:登录 → multipar
388
388
  const exitBackup = process.exitCode;
389
389
  process.exitCode = 0;
390
390
  try {
391
- await installCommand(tgz, { url: "http://x", user: "u", pass: "p", fetchFn, sleep: async () => {} });
391
+ await installCommand(tgz, { siteUrl: "http://x", user: "u", pass: "p", fetchFn, sleep: async () => {} });
392
392
  expect(process.exitCode).toBe(1);
393
393
  }
394
394
  finally {
@@ -400,13 +400,13 @@ describe("installCommand · file 源端到端(stub fetch:登录 → multipar
400
400
  it("detached 侧车自动探测(P2-3,G4):同目录 .sig 随 multipart 第二文件上传", async () => {
401
401
  const tgz = join(dir, "demo-solution-0.1.0.tgz");
402
402
  writeFileSync(tgz, "fake-tgz-bytes");
403
- writeFileSync(`${tgz}.sig`, JSON.stringify({ alg: "Ed25519", key_id: "test.publisher", public_key: "a".repeat(64), value: "AAAA", canon: "v1" }));
403
+ writeFileSync(`${tgz}.sig`, JSON.stringify({ alg: "Ed25519", key_id: "test.publisher", public_key: "a".repeat(64), value: "AAAA", canon: "v2", registry_id: "test.registry" }));
404
404
  const { fetchFn, calls } = fetchStub({
405
405
  "/api/auth/login": () => ok({ access_token: "tok", refresh_token: "r", expires_in: 1 }),
406
406
  "/api/_platform/market/install": () => ok({ task_id: "t1" }),
407
407
  "/api/_platform/market/task": () => ok(rec()),
408
408
  });
409
- await installCommand(tgz, { url: "http://x", user: "u", pass: "p", fetchFn, sleep: async () => {} });
409
+ await installCommand(tgz, { siteUrl: "http://x", user: "u", pass: "p", fetchFn, sleep: async () => {} });
410
410
  const form = calls[1]?.init?.body as FormData;
411
411
  expect(form.get("sig")).toBeInstanceOf(File);
412
412
  expect((form.get("sig") as File).name).toBe("demo-solution-0.1.0.tgz.sig");
@@ -421,7 +421,7 @@ describe("installCommand · file 源端到端(stub fetch:登录 → multipar
421
421
  "/api/_platform/market/install": () => ok({ task_id: "t1" }),
422
422
  "/api/_platform/market/task": () => ok(rec()),
423
423
  });
424
- await installCommand(tgz, { url: "http://x", user: "u", pass: "p", fetchFn, sleep: async () => {} });
424
+ await installCommand(tgz, { siteUrl: "http://x", user: "u", pass: "p", fetchFn, sleep: async () => {} });
425
425
  const form = calls[1]?.init?.body as FormData;
426
426
  expect(form.get("sig")).toBeNull();
427
427
  expect(logs.join("\n")).toContain("未发现签名侧车");
@@ -433,7 +433,7 @@ describe("installCommand · file 源端到端(stub fetch:登录 → multipar
433
433
  const { fetchFn } = fetchStub({
434
434
  "/api/auth/login": () => ({ ok: true, status: 200, text: async () => JSON.stringify({ code: 401, msg: "invalid credentials" }) }),
435
435
  });
436
- await expect(installCommand(tgz, { url: "http://x", user: "u", pass: "bad", fetchFn })).rejects.toThrow(/invalid credentials/);
436
+ await expect(installCommand(tgz, { siteUrl: "http://x", user: "u", pass: "bad", fetchFn })).rejects.toThrow(/invalid credentials/);
437
437
  });
438
438
  });
439
439
 
@@ -444,7 +444,7 @@ describe("installCommand · cdn 源(JSON 体 name@version)", () => {
444
444
  "/api/_platform/market/install": () => ok({ task_id: "t1" }),
445
445
  "/api/_platform/market/task": () => ok(rec()),
446
446
  });
447
- await installCommand("demo-solution", { url: "http://x", user: "u", pass: "p", fetchFn, sleep: async () => {} });
447
+ await installCommand("demo-solution", { siteUrl: "http://x", user: "u", pass: "p", fetchFn, sleep: async () => {} });
448
448
  const installCall = calls[1]!;
449
449
  expect((installCall.init?.headers as Record<string, string>)["content-type"]).toBe("application/json");
450
450
  expect(JSON.parse(String(installCall.init?.body))).toEqual({ name: "demo-solution", version: null, source: "cdn" });
@@ -456,7 +456,7 @@ describe("installCommand · cdn 源(JSON 体 name@version)", () => {
456
456
  "/api/_platform/market/install": () => ok({ task_id: "t1" }),
457
457
  "/api/_platform/market/task": () => ok(rec()),
458
458
  });
459
- await installCommand("demo-solution@0.2.0", { url: "http://x", user: "u", pass: "p", fetchFn, sleep: async () => {} });
459
+ await installCommand("demo-solution@0.2.0", { siteUrl: "http://x", user: "u", pass: "p", fetchFn, sleep: async () => {} });
460
460
  expect(JSON.parse(String(calls[1]?.init?.body)).version).toBe("0.2.0");
461
461
  });
462
462
  });
@@ -467,7 +467,7 @@ describe("setEnabledCommand · enable/disable 瘦客户端(D8)", () => {
467
467
  "/api/auth/login": () => ok({ access_token: "tok", refresh_token: "r", expires_in: 1 }),
468
468
  "/api/_platform/market/enable": () => ok({ ok: true, message: "启用完成:四处同写" }),
469
469
  });
470
- await setEnabledCommand("demo-solution", true, { url: "http://x", user: "u", pass: "p", fetchFn });
470
+ await setEnabledCommand("demo-solution", true, { siteUrl: "http://x", user: "u", pass: "p", fetchFn });
471
471
  const call = calls[1]!;
472
472
  expect(call.url).toContain("/api/_platform/market/enable");
473
473
  expect(JSON.parse(String(call.init?.body))).toEqual({ name: "demo-solution", enabled: true });
@@ -480,7 +480,7 @@ describe("setEnabledCommand · enable/disable 瘦客户端(D8)", () => {
480
480
  "/api/auth/login": () => ok({ access_token: "tok", refresh_token: "r", expires_in: 1 }),
481
481
  "/api/_platform/market/enable": () => ok({ ok: false, message: "禁用失败:「x」未安装" }),
482
482
  });
483
- await setEnabledCommand("x", false, { url: "http://x", user: "u", pass: "p", fetchFn: fetchFn2 });
483
+ await setEnabledCommand("x", false, { siteUrl: "http://x", user: "u", pass: "p", fetchFn: fetchFn2 });
484
484
  expect(process.exitCode).toBe(1);
485
485
  }
486
486
  finally {
@@ -489,3 +489,60 @@ describe("setEnabledCommand · enable/disable 瘦客户端(D8)", () => {
489
489
  expect(logs.join("\n")).toContain("禁用失败:「x」未安装");
490
490
  });
491
491
  });
492
+
493
+ describe("s5-2② REG-R9 install 源切换:registry(CLI 归一 + 旧站点拒)", () => {
494
+ it("parseRegistryTarget:@scope/name → slug=scope-name、version=null", () => {
495
+ expect(parseRegistryTarget("@acme/demo")).toEqual({ slug: "acme-demo", version: null });
496
+ });
497
+
498
+ it("parseRegistryTarget:@scope/name@version 拆出版本;acme/demo 等价", () => {
499
+ expect(parseRegistryTarget("@acme/demo@1.2.3")).toEqual({ slug: "acme-demo", version: "1.2.3" });
500
+ expect(parseRegistryTarget("acme/demo@1.2.3")).toEqual({ slug: "acme-demo", version: "1.2.3" });
501
+ });
502
+
503
+ it("parseRegistryTarget:裸 slug 原样透传(含 @version)", () => {
504
+ expect(parseRegistryTarget("acme-demo")).toEqual({ slug: "acme-demo", version: null });
505
+ expect(parseRegistryTarget("acme-demo@2.0.0")).toEqual({ slug: "acme-demo", version: "2.0.0" });
506
+ });
507
+
508
+ it("parseRegistryTarget:大写/空段/空串即抛 UsageError(REG-R5 字符集)", () => {
509
+ expect(() => parseRegistryTarget("@ACME/demo")).toThrow(/REG-R5/);
510
+ expect(() => parseRegistryTarget("@acme/")).toThrow();
511
+ expect(() => parseRegistryTarget("")).toThrow(/registry 源用法/);
512
+ });
513
+
514
+ it("isSiteApiAtLeast:0.1.0 < 0.2.0 → false;0.2.0/0.2.1 → true;undefined → false", () => {
515
+ expect(isSiteApiAtLeast("0.1.0", "0.2.0")).toBe(false);
516
+ expect(isSiteApiAtLeast("0.2.0", "0.2.0")).toBe(true);
517
+ expect(isSiteApiAtLeast("0.2.1", "0.2.0")).toBe(true);
518
+ expect(isSiteApiAtLeast(undefined, "0.2.0")).toBe(false);
519
+ });
520
+
521
+ it("installCommand registry:新站点(apiVersion 0.2.0)POST source=registry + registry=origin(slug 归一)", async () => {
522
+ const { fetchFn, calls } = fetchStub({
523
+ "/api/auth/login": () => ok({ access_token: "tok" }),
524
+ "/api/_platform/market/meta": () => ok({ apiVersion: "0.2.0" }),
525
+ "/api/_platform/market/install": () => ok({ task_id: "t1" }),
526
+ "/api/_platform/market/task": () => ok(rec()),
527
+ });
528
+ const r = await installCommand("@acme/demo", { siteUrl: "http://site", user: "u", pass: "p", registry: "https://r.example.com/", fetchFn });
529
+ expect(r.status).toBe("done");
530
+ const call = calls.find(c => c.url.includes("/api/_platform/market/install"));
531
+ expect(call).toBeDefined();
532
+ const body = JSON.parse(String(call!.init!.body));
533
+ expect(body).toMatchObject({ name: "acme-demo", source: "registry", registry: "https://r.example.com" });
534
+ expect(body.version).toBeNull();
535
+ });
536
+
537
+ it("installCommand registry:旧站点(apiVersion 0.1.0)→ 抛「站点版本过低」且不 POST install", async () => {
538
+ const { fetchFn, calls } = fetchStub({
539
+ "/api/auth/login": () => ok({ access_token: "tok" }),
540
+ "/api/_platform/market/meta": () => ok({ apiVersion: "0.1.0" }),
541
+ "/api/_platform/market/install": () => ok({ task_id: "t1" }),
542
+ });
543
+ await expect(installCommand("@acme/demo", { siteUrl: "http://site", user: "u", pass: "p", registry: "https://r.example.com", fetchFn }))
544
+ .rejects
545
+ .toThrow(/版本过低/);
546
+ expect(calls.some(c => c.url.includes("/api/_platform/market/install"))).toBe(false);
547
+ });
548
+ });