@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
@@ -0,0 +1,405 @@
1
+ // ojm registry export(REG-R11 离线包的**产出端**):从一个 registry 站点的读面把一个可直接
2
+ // `ojm registry import` 吃的离线包拉下来。
3
+ //
4
+ // 为什么需要它:离线包的消费端(import)早就有了,产出端一直靠手工拼——public 站点能拿
5
+ // `<cdn>/registry/` 整个拷,private 站点(REG-R8 的 `private` 载体)压根没有那个静态目录,
6
+ // 只能照着格式一个文件一个文件 curl。这条命令就是把「照着格式 curl」自动化,并把最容易拼错的
7
+ // 三件事(分片代数与钉值一致、包体 sha 与索引一致、吊销清单必须有签名)当场判掉。
8
+ //
9
+ // 走的是**读面**(`/api/oj-registry/cdn/...`),不是静态目录:读面在两种载体下行为一致,
10
+ // 而且正文与 `.sig` 侧车是分开取的(拆开取才保住「两种载体正文逐字节一致」,REG-R8)。
11
+ //
12
+ // 校验口径**只到自洽**,判三件事:
13
+ // ① catalog / 各分片 / revocations 的正文与各自 `.sig` 对得上(`registry-sidecar`,与 import 同一函数);
14
+ // ② 分片签名声明的代次 = catalog 钉值(`index_generation_mismatch`)——不一致说明源正在重建,抓到的是半代索引;
15
+ // ③ 每个包体的 sha256 / size 与索引条目声明的一致(`package_sha_mismatch`)——下载被截断/串包当场暴露。
16
+ // **信任不在这里判**:这把公钥是不是本站登记的 active 索引 key,只有 import(有本地信任库)能判。
17
+ // 导出侧若拿源自己给的公钥去验源自己给的签名,等于没验——所以本命令不产出 index-keys.json,
18
+ // 也不写本地信任库(那是 TOFU 的入口,不能由「导出」这种批量动作顺手完成)。
19
+ //
20
+ // 产物结构(与 `ojm registry import` 逐字对齐):
21
+ // <out>/source.json ← origin / 代次 / 导出时间(import 用它给 seq 账本分桶)
22
+ // <out>/registry/catalog.yaml(+.sig)
23
+ // <out>/registry/shards/<n>.yaml(+.sig)
24
+ // <out>/registry/revocations.yaml(+.sig)
25
+ // <out>/registry/<slug>-<version>.tgz(+.sig) ← 平铺,文件名按 slug 构造(不信 archive.file,防路径穿越)
26
+ import { Buffer } from "node:buffer";
27
+ import { createHash } from "node:crypto";
28
+ import fs from "node:fs";
29
+ import path from "node:path";
30
+ import process from "node:process";
31
+ import { parse as yamlParse } from "yaml";
32
+ import { UsageError } from "./exit-codes";
33
+ import { indexCanonical, revocationCanonical, shardPinsOf } from "./publish";
34
+ import { REGISTRY_SUBDIR } from "./registry-import";
35
+ import { inspectSidecar } from "./registry-sidecar";
36
+
37
+ /** registry 读面路由前缀(与 oj-registry 的模块路由逐字一致) */
38
+ export const CDN_ROUTE_PREFIX = "/api/oj-registry/cdn";
39
+
40
+ /** 默认并发下载数:串行在大仓上慢到不可用,太高又把小站点打满 */
41
+ const DEFAULT_CONCURRENCY = 4;
42
+
43
+ /** 文件名安全字符集:slug 与 version 都会进文件名与 URL,不允许路径分隔符 */
44
+ const SLUG_RE = /^[a-z0-9][\w.-]*$/i;
45
+ const VERSION_RE = /^[0-9A-Z][0-9A-Z.+-]*$/i;
46
+
47
+ export class RegistryExportError extends Error {
48
+ constructor(readonly code: string, message: string) {
49
+ super(message);
50
+ this.name = "RegistryExportError";
51
+ }
52
+ }
53
+
54
+ export interface RegistryExportOptions {
55
+ /** registry 站点根地址(该站的 canonical_origin),如 https://site.example.com */
56
+ siteUrl: string
57
+ /** 产物根目录;离线包落 `<out>/registry/`。缺省当前目录 */
58
+ out?: string
59
+ /** 只验索引 + 列出本应写入的文件(不下载包体),零落盘 */
60
+ dryRun?: boolean
61
+ /** 机器可读输出(真正的信封在 cli.ts,这里只影响调用方) */
62
+ json?: boolean
63
+ /** 测试注入;缺省 process.cwd() */
64
+ cwd?: string
65
+ /** 测试注入 fetch */
66
+ fetchFn?: typeof fetch
67
+ /** 并发下载上限(缺省 4) */
68
+ concurrency?: number
69
+ }
70
+
71
+ export interface RegistryExportResult {
72
+ /** 站点地址(归一化后,写进 source.json 的 origin) */
73
+ siteUrl: string
74
+ /** 产物根目录 */
75
+ outDir: string
76
+ /** 离线包的 registry 子目录(`<out>/registry`) */
77
+ target: string
78
+ /** 总目录代次(来自 catalog 的签名侧车) */
79
+ catalogSeq: number
80
+ /** 吊销清单代次 */
81
+ revocationSeq: number
82
+ /** 本次导出的分片号(升序) */
83
+ shards: number[]
84
+ /** 索引里声明的版本数(= 包体文件数) */
85
+ packages: number
86
+ /** 验签扫过的索引类文件数(catalog + 各分片 + revocations) */
87
+ verified: number
88
+ /** 实际写入 / 本应写入的文件(相对 <out>;`--dry-run` 下是计划) */
89
+ written: string[]
90
+ /** 包体总字节数(`--dry-run` 下为索引声明值之和) */
91
+ bytes: number
92
+ dryRun: boolean
93
+ }
94
+
95
+ /** 一个待下载的包体(从分片条目里抽出来的最小事实集) */
96
+ interface PackageRef {
97
+ slug: string
98
+ version: string
99
+ /** 平铺文件名:`<slug>-<version>.tgz` */
100
+ file: string
101
+ sha256: string
102
+ size: number
103
+ }
104
+
105
+ interface RawResponse {
106
+ status: number
107
+ bytes: Uint8Array
108
+ }
109
+
110
+ function sha256Hex(bytes: Uint8Array): string {
111
+ return createHash("sha256").update(Buffer.from(bytes)).digest("hex");
112
+ }
113
+
114
+ function textOf(bytes: Uint8Array): string {
115
+ return new TextDecoder().decode(bytes);
116
+ }
117
+
118
+ /** 站点地址归一:去尾斜杠、只收 http(s)(与 `--registry` 同口径,别让人传个裸域名) */
119
+ function normalizeSiteUrl(raw: unknown): string {
120
+ const url = String(raw ?? "").trim().replace(/\/+$/, "");
121
+ if (url === "")
122
+ throw new UsageError("[ojm] 用法:ojm registry export --site-url <registry 站点地址> [--out <目录>] [--dry-run] [--json]\n --site-url 形如 https://site.example.com(该站的 canonical_origin)");
123
+ if (!/^https?:\/\/\S+$/i.test(url))
124
+ throw new UsageError(`[ojm] --site-url 只接受 http(s) 地址,收到「${url}」(形如 https://site.example.com)`);
125
+ return url;
126
+ }
127
+
128
+ async function fetchRaw(fetchFn: typeof fetch, url: string, what: string): Promise<RawResponse> {
129
+ let res: Response;
130
+ try {
131
+ res = await fetchFn(url);
132
+ }
133
+ catch (error) {
134
+ throw new RegistryExportError("export_fetch_failed", `取${what}失败(${url}):${(error as Error).message}——检查站点地址、网络与证书`);
135
+ }
136
+ if (!res.ok && res.status !== 404)
137
+ throw new RegistryExportError("export_fetch_failed", `取${what}失败(${url}):HTTP ${res.status}`);
138
+ return { status: res.status, bytes: new Uint8Array(await res.arrayBuffer()) };
139
+ }
140
+
141
+ /** 取索引类正文(必须 200;404 也按失败报,错误消息里带上补的命令名) */
142
+ async function fetchBody(fetchFn: typeof fetch, url: string, what: string): Promise<string> {
143
+ const res = await fetchRaw(fetchFn, url, what);
144
+ if (res.status === 404)
145
+ throw new RegistryExportError("export_fetch_failed", `站点没有${what}(${url} 返回 404)——确认站点已启用 oj-registry 且索引已生成(POST /index/static 或 /index/signature)`);
146
+ return textOf(res.bytes);
147
+ }
148
+
149
+ /** 取 `index` / `shards/<n>` 的侧车;缺侧车是 fail-closed(REG-CS1),不降级成「没有签名也能用」 */
150
+ async function fetchIndexSig(fetchFn: typeof fetch, url: string, what: string): Promise<string> {
151
+ const res = await fetchRaw(fetchFn, url, what);
152
+ if (res.status === 404)
153
+ throw new RegistryExportError("index_sig_missing", `站点没有${what}的签名侧车(${url} 返回 404)——完整性无保障,已停止导出`);
154
+ const text = textOf(res.bytes);
155
+ if (text.trim() === "")
156
+ throw new RegistryExportError("index_sig_missing", `${what}的签名侧车是空正文(${url})——完整性无保障,已停止导出`);
157
+ return text;
158
+ }
159
+
160
+ /** 取吊销清单侧车;没有签名就不产离线包(空清单也必须签,REG-R10) */
161
+ async function fetchRevocationSig(fetchFn: typeof fetch, url: string): Promise<string> {
162
+ const res = await fetchRaw(fetchFn, url, "吊销清单签名");
163
+ if (res.status === 404)
164
+ throw new RegistryExportError("revocations_sig_missing", `站点还没给吊销清单签过名(${url} 返回 404)——装不上也导不出:先走 POST /revocations/signature 签一次`);
165
+ const text = textOf(res.bytes);
166
+ if (text.trim() === "")
167
+ throw new RegistryExportError("revocations_sig_missing", `吊销清单签名侧车是空正文(${url})——完整性无保障,已停止导出`);
168
+ return text;
169
+ }
170
+
171
+ /** 从分片正文抽出包体清单;形状不对按 `offline_format`(源的数据坏了,不是用法问题) */
172
+ function packagesOf(shardNo: number, text: string): PackageRef[] {
173
+ const where = `分片 shards/${shardNo}.yaml`;
174
+ let doc: unknown;
175
+ try {
176
+ doc = yamlParse(text);
177
+ }
178
+ catch {
179
+ throw new RegistryExportError("offline_format", `${where} 不是合法 YAML——源正在重建索引或读到半份文件,稍后重试`);
180
+ }
181
+ const solutions = (doc !== null && typeof doc === "object" && !Array.isArray(doc))
182
+ ? (doc as Record<string, unknown>).solutions
183
+ : undefined;
184
+ if (!Array.isArray(solutions))
185
+ throw new RegistryExportError("offline_format", `${where} 缺 solutions 数组——结构破损,无法确定要下载哪些包体`);
186
+
187
+ const out: PackageRef[] = [];
188
+ for (const entry of solutions as unknown[]) {
189
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry))
190
+ throw new RegistryExportError("offline_format", `${where} 的方案条目不是对象`);
191
+ const solution = entry as Record<string, unknown>;
192
+ const slug = String(solution.name ?? "");
193
+ if (!SLUG_RE.test(slug))
194
+ throw new RegistryExportError("offline_format", `${where} 的方案名「${slug}」不是合法 slug(只允许字母数字与 . _ -)——拒绝把它拼进文件路径`);
195
+ const versions = solution.versions;
196
+ if (!Array.isArray(versions))
197
+ throw new RegistryExportError("offline_format", `${where} 的方案 ${slug} 缺 versions 数组`);
198
+ for (const raw of versions as unknown[]) {
199
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
200
+ throw new RegistryExportError("offline_format", `${where} 的版本条目不是对象(方案 ${slug})`);
201
+ const v = raw as Record<string, unknown>;
202
+ const version = String(v.version ?? "");
203
+ if (!VERSION_RE.test(version))
204
+ throw new RegistryExportError("offline_format", `${where} 的版本号「${version}」不合法(方案 ${slug})——拒绝把它拼进文件路径`);
205
+ const archive = (v.archive !== null && typeof v.archive === "object" && !Array.isArray(v.archive))
206
+ ? v.archive as Record<string, unknown>
207
+ : null;
208
+ const sha256 = String(archive?.sha256 ?? "").toLowerCase();
209
+ if (!/^[0-9a-f]{64}$/.test(sha256))
210
+ throw new RegistryExportError("offline_format", `${where} 的 ${slug}@${version} 缺合法的 archive.sha256——没有摘要就无法证明下载完整`);
211
+ const size = Number(archive?.size);
212
+ if (!Number.isInteger(size) || size < 0)
213
+ throw new RegistryExportError("offline_format", `${where} 的 ${slug}@${version} 的 archive.size 不合法`);
214
+ // 文件名按 slug/version 构造而不是信 archive.file:那条字段来自源,指到 `../` 就能写出目录外
215
+ out.push({ slug, version, file: `${slug}-${version}.tgz`, sha256, size });
216
+ }
217
+ }
218
+ return out;
219
+ }
220
+
221
+ /** 限并发映射:任一任务失败即停(不再发起新的),并把第一个错误抛给调用方 */
222
+ async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]> {
223
+ const out = Array.from<R>({ length: items.length });
224
+ let cursor = 0;
225
+ let failure: unknown = null;
226
+ const width = Math.max(1, Math.min(limit, items.length));
227
+ const workers = Array.from({ length: width }, async () => {
228
+ while (failure === null) {
229
+ const index = cursor++;
230
+ if (index >= items.length)
231
+ return;
232
+ try {
233
+ out[index] = await fn(items[index]!, index);
234
+ }
235
+ catch (error) {
236
+ failure = failure ?? error;
237
+ return;
238
+ }
239
+ }
240
+ });
241
+ await Promise.all(workers);
242
+ if (failure !== null)
243
+ throw failure;
244
+ return out;
245
+ }
246
+
247
+ function writeFile(file: string, data: string | Uint8Array): void {
248
+ fs.mkdirSync(path.dirname(file), { recursive: true });
249
+ fs.writeFileSync(file, data);
250
+ }
251
+
252
+ /**
253
+ * 业务命令体。返回结果(测试/CI 断言用);不打印、不 exit——输出与退出码归 cli.ts。
254
+ *
255
+ * 落盘是「先全下到隐藏暂存目录,最后整体换位」:中途失败(包体 sha 不符、网络断)不会在
256
+ * `<out>/registry/` 留下半份离线包——那种半份包最坑,看着像能导,import 到一半才炸。
257
+ */
258
+ export async function registryExportCommand(opts: RegistryExportOptions): Promise<RegistryExportResult> {
259
+ const siteUrl = normalizeSiteUrl(opts.siteUrl);
260
+ const cwd = opts.cwd ?? process.cwd();
261
+ const fetchFn = opts.fetchFn ?? globalThis.fetch;
262
+ const outDir = path.resolve(cwd, opts.out ?? ".");
263
+ const target = path.join(outDir, REGISTRY_SUBDIR);
264
+ const api = `${siteUrl}${CDN_ROUTE_PREFIX}`;
265
+ const dryRun = opts.dryRun === true;
266
+
267
+ // ① 索引层:catalog → 钉值 → 各分片 → 吊销清单,逐份验自洽
268
+ const catalogText = await fetchBody(fetchFn, `${api}/index`, "索引总目录");
269
+ const catalogSig = await fetchIndexSig(fetchFn, `${api}/index/signature`, "索引总目录");
270
+ const catalogVerify = inspectSidecar({ rel: "catalog.yaml", body: catalogText, sigText: catalogSig, canon: (seq, t) => indexCanonical("catalog", seq, t) });
271
+ if (!catalogVerify.ok || catalogVerify.seq === null)
272
+ throw new RegistryExportError(catalogVerify.code ?? "index_sig_mismatch", catalogVerify.message ?? "索引总目录验签失败");
273
+ const catalogSeq = catalogVerify.seq;
274
+
275
+ const pins = shardPinsOf(catalogText);
276
+ if (pins.size === 0)
277
+ throw new RegistryExportError("offline_format", "索引总目录的分片钉值为空——结构破损,无法确定要导出哪些分片");
278
+ const shardNos = [...pins.keys()].sort((a, b) => a - b);
279
+
280
+ const shardTexts = new Map<number, { body: string, sig: string }>();
281
+ const refs: PackageRef[] = [];
282
+ const seen = new Set<string>();
283
+ for (const no of shardNos) {
284
+ const body = await fetchBody(fetchFn, `${api}/shard/${no}`, `分片 shards/${no}.yaml`);
285
+ const sig = await fetchIndexSig(fetchFn, `${api}/shard/${no}/signature`, `分片 shards/${no}.yaml`);
286
+ const verdict = inspectSidecar({ rel: `shards/${no}.yaml`, body, sigText: sig, canon: (seq, t) => indexCanonical("shard", seq, t) });
287
+ if (!verdict.ok)
288
+ throw new RegistryExportError(verdict.code ?? "index_sig_mismatch", verdict.message ?? `分片 shards/${no}.yaml 验签失败`);
289
+ if (verdict.seq !== pins.get(no))
290
+ throw new RegistryExportError("index_generation_mismatch", `分片 shards/${no}.yaml 签名是第 ${String(verdict.seq)} 代,总目录钉的是第 ${pins.get(no)} 代——源正在重建索引,导出会得到半代产物,先等索引稳定再导出`);
291
+ shardTexts.set(no, { body, sig });
292
+ for (const ref of packagesOf(no, body)) {
293
+ const key = `${ref.slug}@${ref.version}`;
294
+ if (seen.has(key))
295
+ throw new RegistryExportError("offline_format", `${key} 在分片里出现了两次——索引自相矛盾,导出会互相覆盖`);
296
+ seen.add(key);
297
+ refs.push(ref);
298
+ }
299
+ }
300
+
301
+ const revBody = await fetchBody(fetchFn, `${api}/revocations`, "吊销清单");
302
+ const revSig = await fetchRevocationSig(fetchFn, `${api}/revocations/signature`);
303
+ const revVerdict = inspectSidecar({ rel: "revocations.yaml", body: revBody, sigText: revSig, canon: (seq, t) => revocationCanonical(seq, t) });
304
+ if (!revVerdict.ok || revVerdict.seq === null)
305
+ throw new RegistryExportError(revVerdict.code ?? "revocations_sig_mismatch", revVerdict.message ?? "吊销清单验签失败");
306
+ const revocationSeq = revVerdict.seq;
307
+
308
+ const verified = 1 + shardNos.length + 1;
309
+ const indexFiles = [
310
+ "catalog.yaml",
311
+ "catalog.yaml.sig",
312
+ ...shardNos.flatMap(no => [`shards/${no}.yaml`, `shards/${no}.yaml.sig`]),
313
+ "revocations.yaml",
314
+ "revocations.yaml.sig",
315
+ ];
316
+ const packageFiles = refs.flatMap(ref => [ref.file, `${ref.file}.sig`]);
317
+ // 相对 <out> 列(与 import 的 written 相对落点列同一语义,但这里多一层 registry/)
318
+ const written = [...indexFiles, ...packageFiles].map(f => `${REGISTRY_SUBDIR}/${f}`).concat("source.json");
319
+ const declaredBytes = refs.reduce((sum, ref) => sum + ref.size, 0);
320
+
321
+ // ② dry-run:索引层已验完,包体不下载(那是全部流量),只报计划
322
+ if (dryRun) {
323
+ return {
324
+ siteUrl,
325
+ outDir,
326
+ target,
327
+ catalogSeq,
328
+ revocationSeq,
329
+ shards: shardNos,
330
+ packages: refs.length,
331
+ verified,
332
+ written,
333
+ bytes: declaredBytes,
334
+ dryRun: true,
335
+ };
336
+ }
337
+
338
+ // ③ 下载 + 落盘:先全下到暂存目录,最后整体换位
339
+ const staging = path.join(outDir, `.ojm-registry-export-${process.pid}`);
340
+ fs.rmSync(staging, { recursive: true, force: true });
341
+ fs.mkdirSync(staging, { recursive: true });
342
+ let bytes = 0;
343
+ try {
344
+ writeFile(path.join(staging, "catalog.yaml"), catalogText);
345
+ writeFile(path.join(staging, "catalog.yaml.sig"), catalogSig);
346
+ for (const no of shardNos) {
347
+ const shard = shardTexts.get(no)!;
348
+ writeFile(path.join(staging, "shards", `${no}.yaml`), shard.body);
349
+ writeFile(path.join(staging, "shards", `${no}.yaml.sig`), shard.sig);
350
+ }
351
+ writeFile(path.join(staging, "revocations.yaml"), revBody);
352
+ writeFile(path.join(staging, "revocations.yaml.sig"), revSig);
353
+
354
+ await mapLimit(refs, opts.concurrency ?? DEFAULT_CONCURRENCY, async (ref) => {
355
+ const url = `${api}/package/${encodeURIComponent(ref.slug)}/${encodeURIComponent(ref.version)}`;
356
+ const res = await fetchRaw(fetchFn, url, `包体 ${ref.file}`);
357
+ if (res.status !== 200)
358
+ throw new RegistryExportError("export_fetch_failed", `取包体 ${ref.file} 失败(${url}):HTTP ${res.status}`);
359
+ // ③a 长度与摘要都对一遍:长度不对是截断/串包(消息更好定位),摘要不对是内容被替换
360
+ if (res.bytes.byteLength !== ref.size)
361
+ throw new RegistryExportError("package_sha_mismatch", `包体 ${ref.file} 长度 ${res.bytes.byteLength} 字节,索引声明 ${ref.size} 字节——下载被截断或串包,已停止导出`);
362
+ const actual = sha256Hex(res.bytes);
363
+ if (actual !== ref.sha256)
364
+ throw new RegistryExportError("package_sha_mismatch", `包体 ${ref.file} 的 sha256 是 ${actual},索引声明 ${ref.sha256}——内容被替换或损坏,已停止导出`);
365
+ const sigRes = await fetchRaw(fetchFn, `${url}/signature`, `包体签名 ${ref.file}.sig`);
366
+ if (sigRes.status !== 200)
367
+ throw new RegistryExportError("export_fetch_failed", `取包体签名 ${ref.file}.sig 失败(${url}/signature):HTTP ${sigRes.status}——缺它安装侧无法验包,已停止导出`);
368
+ writeFile(path.join(staging, ref.file), res.bytes);
369
+ writeFile(path.join(staging, `${ref.file}.sig`), sigRes.bytes);
370
+ bytes += res.bytes.byteLength;
371
+ });
372
+
373
+ // ④ 换位:先清旧 registry/ 再整体改名,保证「落盘 = 本次导出的完整一代」
374
+ fs.rmSync(target, { recursive: true, force: true });
375
+ fs.renameSync(staging, target);
376
+ }
377
+ catch (error) {
378
+ fs.rmSync(staging, { recursive: true, force: true });
379
+ throw error;
380
+ }
381
+
382
+ // ⑤ source.json 最后写:它一出现就代表这一代导出成功(import 靠它给 seq 账本分桶)
383
+ writeFile(path.join(outDir, "source.json"), `${JSON.stringify({
384
+ origin: siteUrl,
385
+ exported_at: new Date().toISOString(),
386
+ catalog_seq: catalogSeq,
387
+ revocation_seq: revocationSeq,
388
+ shards: shardNos,
389
+ packages: refs.length,
390
+ }, null, 2)}\n`);
391
+
392
+ return {
393
+ siteUrl,
394
+ outDir,
395
+ target,
396
+ catalogSeq,
397
+ revocationSeq,
398
+ shards: shardNos,
399
+ packages: refs.length,
400
+ verified,
401
+ written,
402
+ bytes,
403
+ dryRun: false,
404
+ };
405
+ }