@oj-module/cli 0.2.30 → 0.2.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/devkit/manual.md +5 -5
- package/devkit/module-dev-and-cdn-playground.md +1 -1
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +2 -2
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/cli.test.ts +23 -14
- package/src/__tests__/list.test.ts +176 -0
- package/src/__tests__/registry-cli.test.ts +522 -0
- package/src/__tests__/registry-export.test.ts +319 -0
- package/src/__tests__/registry-import.test.ts +336 -0
- package/src/__tests__/registry-sign.test.ts +352 -0
- package/src/cli-output.ts +81 -0
- package/src/cli.ts +298 -39
- package/src/config-cmd.ts +100 -0
- package/src/contract/__tests__/handler-scan.test.ts +74 -2
- package/src/contract/handler-scan.ts +27 -2
- package/src/enable.ts +1 -1
- package/src/exit-codes.ts +33 -0
- package/src/index.ts +5 -9
- package/src/install.ts +120 -38
- package/src/lifecycle/__tests__/authoring.test.ts +6 -5
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.bad.sig +3 -2
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.sig +3 -2
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.unknown.sig +3 -2
- package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.d.mts +5 -4
- package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.mjs +9 -6
- package/src/lifecycle/__tests__/helpers.ts +10 -6
- package/src/lifecycle/__tests__/install.test.ts +72 -15
- package/src/lifecycle/__tests__/manifest-contract.test.ts +40 -1
- package/src/lifecycle/__tests__/publish.test.ts +174 -33
- package/src/lifecycle/__tests__/sig-fixtures.test.ts +9 -6
- package/src/lifecycle/__tests__/sign.test.ts +9 -8
- package/src/lifecycle/__tests__/smoke.test.ts +1 -1
- package/src/lifecycle/__tests__/uninstall.test.ts +4 -4
- package/src/lifecycle/sign.ts +8 -7
- package/src/list.ts +146 -0
- package/src/manifest-contract.ts +11 -5
- package/src/market/__tests__/bootstrap.test.ts +4 -1
- package/src/market/__tests__/build-index.test.ts +529 -42
- package/src/market/__tests__/fixture.test.ts +17 -1
- package/src/market/__tests__/publish.test.ts +8 -7
- package/src/market/__tests__/setup.test.ts +4 -4
- package/src/market/__tests__/sharding.test.ts +90 -0
- package/src/market/__tests__/verify.test.ts +205 -12
- package/src/market/__tests__/yank-ledger.test.ts +60 -0
- package/src/market/bootstrap.ts +8 -3
- package/src/market/build-index.ts +407 -40
- package/src/market/cdn-layout.ts +3 -2
- package/src/market/command.ts +40 -6
- package/src/market/failure-playbook.ts +1 -1
- package/src/market/fixture.ts +20 -3
- package/src/market/index-builder.ts +120 -47
- package/src/market/publish.ts +3 -1
- package/src/market/setup.ts +3 -3
- package/src/market/sharding.ts +94 -0
- package/src/market/trial.ts +1 -1
- package/src/market/verify.ts +213 -50
- package/src/market/walkthrough.ts +7 -6
- package/src/market/yank-ledger.ts +91 -0
- package/src/publish.ts +250 -38
- package/src/registry-export.ts +405 -0
- package/src/registry-import.ts +410 -0
- package/src/registry-sidecar.ts +84 -0
- package/src/registry-sign.ts +322 -0
- package/src/registry-source.ts +192 -0
- package/src/sign.ts +13 -10
- package/src/site.ts +4 -4
- package/src/trust.ts +1 -1
- package/src/uninstall.ts +14 -2
- package/src/usage.ts +130 -25
- package/src/view.ts +94 -0
- package/templates/cdn/README.md +35 -5
- package/templates/config.yaml +10 -0
- package/templates/init.include.yaml +1 -1
- package/templates/src/_platform/api/_lifecycle/atomic.ts +3 -0
- package/templates/src/_platform/api/_lifecycle/installer.ts +44 -7
- package/templates/src/_platform/api/_lifecycle/limits.ts +21 -0
- package/templates/src/_platform/api/_lifecycle/source-seq.ts +88 -0
- package/templates/src/_platform/api/_lifecycle/steps/download.ts +15 -0
- package/templates/src/_platform/api/_lifecycle/steps/extract.ts +61 -2
- package/templates/src/_platform/api/_lifecycle/steps/preflight.ts +55 -6
- package/templates/src/_platform/api/_lifecycle/steps/register.ts +2 -1
- package/templates/src/_platform/api/_lifecycle/steps/verify.ts +22 -8
- package/templates/src/_platform/api/_lifecycle/tgz-guard.ts +96 -0
- package/templates/src/_platform/api/_lifecycle/types.ts +6 -3
- package/templates/src/_platform/api/_lifecycle/verify-checks.ts +8 -0
- package/templates/src/_platform/api/_shared/service/domain/solution-perms.ts +9 -1
- package/templates/src/_platform/api/_shared/site-facade.ts +62 -11
- package/templates/src/_platform/api/manifest.yaml +1 -0
- package/templates/src/_platform/api/market/api.ts +362 -99
- package/templates/src/_platform/api/market/index-source.ts +112 -0
- package/templates/src/_platform/api/migrations/0014__reg_source_seq.mysql.sql +9 -0
- package/templates/src/_platform/api/migrations/0014__reg_source_seq.pg.sql +7 -0
- package/templates/src/_platform/api/migrations/0014__reg_source_seq.sql +8 -0
- package/templates/src/_platform/api/schema.yaml +8 -0
- package/templates/src/_platform/api/solutions/api.ts +8 -3
- package/templates/src/oj-market/web/__tests__/CardGrid.test.tsx +13 -0
- package/templates/src/oj-market/web/__tests__/InstallDialog.test.tsx +38 -0
- package/templates/src/oj-market/web/components/CardItem.tsx +10 -0
- package/templates/src/oj-market/web/components/InstallDialog.tsx +18 -0
- package/templates/src/oj-market/web/locales/en-US.json +1 -0
- package/templates/src/oj-market/web/locales/zh-CN.json +1 -0
- package/templates/src/oj-market/web/types.ts +3 -1
- package/templates/tests/lifecycle/installer.spec.ts +24 -0
- package/templates/tests/lifecycle/market-api.spec.ts +641 -85
- package/templates/tests/lifecycle/preflight.spec.ts +52 -0
- package/templates/tests/lifecycle/sig-fixtures.spec.ts +7 -4
- package/templates/tests/lifecycle/source-seq.spec.ts +55 -0
- package/templates/tests/lifecycle/steps-fs.spec.ts +166 -7
- package/templates/tests/lifecycle/tgz-guard.spec.ts +133 -1
- package/templates/tests/lifecycle/verify-checks.spec.ts +27 -13
- package/templates/tests/lifecycle/verify-report.spec.ts +3 -1
- package/templates/tests/rbac-solution-perms.spec.ts +41 -0
- package/vendor/host-versions.json +3 -3
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `ojm market build-index`(T4
|
|
2
|
+
* `ojm market build-index`(T4 + REG-CS5 三级化):扫描 `cdn/*.tgz` → 提取内嵌 manifest →
|
|
3
|
+
* 全量重建三级索引并签名。
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
+
* 三级形态(REG-CS5,需求 §5.1):
|
|
6
|
+
* - `catalog.yaml`——`{seq, issued_at, expires_at, shards: {<片号>: <该片代次>}, solutions: [{name, shards}]}`:
|
|
7
|
+
* 包名 → 片号,**无版本明细**;唯一带 seq+时效的正文,代次真源。
|
|
8
|
+
* - `shards/<n>.yaml`——`{solutions: [{name, latest, versions: [Form B 条目]}]}`:纯内容、无元字段;
|
|
9
|
+
* 版本条目即 Form B manifest(原 manifests.yaml 聚合清单并入分片),装一个包只拉 1 catalog + 1 shard。
|
|
10
|
+
* - 每片 ≤ `SHARD_MAX_VERSIONS`(1000)个版本,装箱见 `sharding.ts`。
|
|
11
|
+
*
|
|
12
|
+
* 为什么不增量合并:索引是**共享可变产物**,多人发布时读-改-写天然 TOCTOU,且索引签名会被
|
|
5
13
|
* 后发者覆盖。而 `market/api.ts verifyIndexSig` 注释已写明「索引签名只做完整性,信任由 TOFU/
|
|
6
14
|
* 信任库承载」——索引本就不是信任锚,那就把它降级为派生产物:谁都能重建,重建结果只取决于目录内容。
|
|
15
|
+
*
|
|
16
|
+
* 原子落盘(红队 F2 / 计划子任务 6):
|
|
17
|
+
* - 每个索引文件都走 `<路径>.tmp.<pid>.<rand>` → `rename`(全仓唯一写入口 `writeIndexFile`,
|
|
18
|
+
* `grep writeFileSync` 可证没有直写索引的路径);临时文件后缀非 `.yaml`,读取器永不将其当索引。
|
|
19
|
+
* - **内容匹配编号**:字节一致的片保留原片号与代次、连签名都不重写(缓存热、ETag 稳定);
|
|
20
|
+
* 内容变了的片取**全新片号**(maxExistingNo+1 起),旧代文件在 catalog 提交前原样留在盘上。
|
|
21
|
+
* - **顺序固定「先分片 → 再 catalog」**:catalog 是提交点。写完分片还没写 catalog 时进程被杀,
|
|
22
|
+
* 旧 catalog 仍指向旧代文件、安装照常;新片是全新号、旧 catalog 看不见,不构成半文件。
|
|
7
23
|
*/
|
|
8
24
|
|
|
9
25
|
import type { IndexedPackage } from "./index-builder";
|
|
@@ -13,33 +29,58 @@ import os from "node:os";
|
|
|
13
29
|
import path from "node:path";
|
|
14
30
|
import process from "node:process";
|
|
15
31
|
|
|
16
|
-
import { stringify as yamlStringify } from "yaml";
|
|
17
|
-
import {
|
|
32
|
+
import { parse as yamlParse, stringify as yamlStringify } from "yaml";
|
|
33
|
+
import { verifyCanonical } from "../lifecycle/sign";
|
|
34
|
+
import { indexCanonical, indexMetaOf, recomputeIndex, signIndex, stampIndexMeta, writeIndexFile } from "../publish";
|
|
18
35
|
import { resolveCdnLayout } from "./cdn-layout";
|
|
19
|
-
import { buildIndexData } from "./index-builder";
|
|
36
|
+
import { buildIndexData, compareSemver } from "./index-builder";
|
|
20
37
|
import { readEmbeddedManifest } from "./scan";
|
|
38
|
+
import { packShards } from "./sharding";
|
|
39
|
+
import { readYankLedger } from "./yank-ledger";
|
|
21
40
|
|
|
22
41
|
export interface BuildIndexOptions {
|
|
23
42
|
projectRoot?: string
|
|
24
43
|
id?: string
|
|
25
44
|
key?: string
|
|
26
45
|
privateKeyPem?: string
|
|
27
|
-
/**
|
|
46
|
+
/** 重建时刻(epoch 秒;REG-CS2 时效基准,测试注入用,缺省当前时刻) */
|
|
47
|
+
now?: number
|
|
48
|
+
/** 重建后离线自检(verifyCanonical 复核 catalog 与各分片签名) */
|
|
28
49
|
check?: boolean
|
|
50
|
+
/**
|
|
51
|
+
* 测试注入(半写回归,计划 BDD ③):分片写完、catalog 提交前触发。抛错即中止本次重建,
|
|
52
|
+
* 已写文件按「从未发生」回滚(删除本次写入的全部文件),旧代保持可读。
|
|
53
|
+
*/
|
|
54
|
+
afterShards?: () => void
|
|
29
55
|
}
|
|
30
56
|
|
|
31
57
|
export interface BuildIndexResult {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
58
|
+
catalogPath: string
|
|
59
|
+
catalogSigPath: string
|
|
60
|
+
/** 本次重建涉及的片文件(含未重写的沿用片;按片号升序) */
|
|
61
|
+
shardPaths: string[]
|
|
36
62
|
/** 入索引的方案数 */
|
|
37
63
|
solutions: number
|
|
64
|
+
/** 片总数(含未重写的沿用片) */
|
|
65
|
+
shards: number
|
|
38
66
|
/** 因缺内嵌 manifest 等原因被跳过的文件名 */
|
|
39
67
|
skipped: string[]
|
|
68
|
+
/** 本次重建的索引代次(REG-CS2 单调 seq) */
|
|
69
|
+
seq: number
|
|
70
|
+
/** 盖章进索引的时效(REG-CS2;读方 now > expiresAt 即拒) */
|
|
71
|
+
issuedAt: number
|
|
72
|
+
expiresAt: number
|
|
40
73
|
checkPassed?: boolean
|
|
41
74
|
}
|
|
42
75
|
|
|
76
|
+
/** 盘上既有代次的一片(内容 + 其被 catalog 钉住的代次) */
|
|
77
|
+
interface ExistingShard {
|
|
78
|
+
no: number
|
|
79
|
+
seq: number
|
|
80
|
+
/** 归一化后的正文(与新建片同口径比对) */
|
|
81
|
+
body: string
|
|
82
|
+
}
|
|
83
|
+
|
|
43
84
|
export async function buildIndex(opts: BuildIndexOptions = {}): Promise<BuildIndexResult> {
|
|
44
85
|
const projectRoot = opts.projectRoot ?? process.cwd();
|
|
45
86
|
const layout = resolveCdnLayout(projectRoot);
|
|
@@ -77,31 +118,349 @@ export async function buildIndex(opts: BuildIndexOptions = {}): Promise<BuildInd
|
|
|
77
118
|
});
|
|
78
119
|
}
|
|
79
120
|
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
121
|
+
const yanked = readYankLedger(projectRoot);
|
|
122
|
+
const data = buildIndexData(packages, yanked);
|
|
123
|
+
const { slots, assignment } = packShards(data.solutions);
|
|
124
|
+
|
|
125
|
+
const catalogPath = path.join(layout.dir, "catalog.yaml");
|
|
126
|
+
const shardsDir = path.join(layout.dir, "shards");
|
|
127
|
+
const existing = readExistingGeneration(catalogPath, shardsDir);
|
|
128
|
+
|
|
129
|
+
// 装箱槽位 → 片号:内容一致的片沿用旧号与旧代次(不重写),变了的片取全新号
|
|
130
|
+
const bodies = slots.map(slot => normalizeYaml({ solutions: slot.solutions }));
|
|
131
|
+
const usedNos = new Set<number>([...existing.pins.keys(), ...existing.onDiskNos]);
|
|
132
|
+
const byBody = new Map<string, ExistingShard>();
|
|
133
|
+
for (const shard of existing.shards.values())
|
|
134
|
+
byBody.set(shard.body, shard);
|
|
135
|
+
|
|
136
|
+
/** 每个槽位的编号归属:fresh = 新片(本次写),reuse = 沿用旧片(不写) */
|
|
137
|
+
type SlotRef = { kind: "fresh", no: number } | { kind: "reuse", shard: ExistingShard };
|
|
138
|
+
const refs: SlotRef[] = [];
|
|
139
|
+
let nextFresh = usedNos.size === 0 ? 1 : maxOf(usedNos) + 1;
|
|
140
|
+
const claimed = new Set<number>();
|
|
141
|
+
for (const body of bodies) {
|
|
142
|
+
const hit = byBody.get(body);
|
|
143
|
+
if (hit !== undefined && !claimed.has(hit.no)) {
|
|
144
|
+
// 内容未变:保留片号与代次,正文与签名都不重写(缓存/ETag 稳定)
|
|
145
|
+
claimed.add(hit.no);
|
|
146
|
+
refs.push({ kind: "reuse", shard: hit });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
refs.push({ kind: "fresh", no: nextFresh++ });
|
|
150
|
+
}
|
|
151
|
+
const nos = refs.map(ref => ref.kind === "reuse" ? ref.shard.no : ref.no);
|
|
152
|
+
const freshCount = refs.filter(ref => ref.kind === "fresh").length;
|
|
153
|
+
// 装箱槽位号 → 落盘片号:两者在「沿用旧号 / 新片让位」时并不相等(槽位 2 可能落到文件 3),
|
|
154
|
+
// catalog 的映射与同代判定都必须用文件号——记槽位号会让 catalog 指向被清扫的旧片。
|
|
155
|
+
const fileNosOf = (slotNos: number[]): number[] => slotNos.map(slot => nos[slot - 1]!);
|
|
156
|
+
|
|
157
|
+
// 代次:catalog 正文(片钉 + 映射)变了才 +1;无变化的重建幂等(重复跑不漂移)
|
|
158
|
+
const newMapping = new Map([...assignment.entries()].map(([name, list]) => [name, fileNosOf(list)]));
|
|
159
|
+
const sameMapping = sameMappingAs(existing.mapping, newMapping);
|
|
160
|
+
const seq = existing.seq === null || freshCount > 0 || !sameMapping ? (existing.seq ?? 0) + 1 : existing.seq;
|
|
161
|
+
|
|
162
|
+
// REG-CS2 `latest` 单调不降(同名方案的新 latest 不得低于盘上旧值)
|
|
163
|
+
assertLatestMonotonic(existing, bodies, yanked);
|
|
164
|
+
|
|
165
|
+
// catalog:片钉(未变片 = 旧代次,新片 = 本次代次)+ 映射;正文只此一处带 seq 与时效
|
|
166
|
+
const pins = new Map<number, number>();
|
|
167
|
+
refs.forEach((ref, i) => {
|
|
168
|
+
const no = nos[i]!;
|
|
169
|
+
pins.set(no, ref.kind === "reuse" ? ref.shard.seq : seq);
|
|
170
|
+
});
|
|
171
|
+
const catalogDoc = {
|
|
172
|
+
shards: Object.fromEntries([...pins.entries()].map(([no, s]) => [String(no), s])),
|
|
173
|
+
solutions: [...newMapping.entries()].map(([name, list]) => ({ name, shards: list })),
|
|
174
|
+
};
|
|
175
|
+
const meta = indexMetaOf(seq, opts.now ?? Math.floor(Date.now() / 1000));
|
|
176
|
+
const catalogText = stampIndexMeta(normalizeYaml(catalogDoc), meta);
|
|
177
|
+
|
|
178
|
+
const pem = opts.privateKeyPem ?? readPemFrom(opts.id, opts.key);
|
|
179
|
+
const keyId = opts.id ?? "";
|
|
180
|
+
if (keyId === "")
|
|
181
|
+
throw new Error("[ojm] 重建索引须给签名身份:--id <publisher.id>(.sig 的发布方标识;C2-37 命名空间)");
|
|
182
|
+
|
|
183
|
+
const catalogSig = signIndex("catalog", seq, catalogText, pem, keyId);
|
|
184
|
+
const shardSigs = new Map<number, ReturnType<typeof signIndex>>();
|
|
185
|
+
refs.forEach((ref, i) => {
|
|
186
|
+
if (ref.kind === "fresh")
|
|
187
|
+
shardSigs.set(ref.no, signIndex("shard", pins.get(ref.no)!, bodies[i]!, pem, keyId));
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
fs.mkdirSync(shardsDir, { recursive: true });
|
|
191
|
+
const written: string[] = [];
|
|
192
|
+
// catalog 是提交点:覆盖前先留旧代字节,万一两个 rename 之间出事可原样恢复(fail-closed 之外的
|
|
193
|
+
// 最后一道——正常路径永远走不到这里)
|
|
194
|
+
const prevCatalog = fs.existsSync(catalogPath) ? fs.readFileSync(catalogPath, "utf-8") : null;
|
|
195
|
+
const prevCatalogSig = fs.existsSync(`${catalogPath}.sig`) ? fs.readFileSync(`${catalogPath}.sig`, "utf-8") : null;
|
|
196
|
+
const catalogSigPath = `${catalogPath}.sig`;
|
|
197
|
+
try {
|
|
198
|
+
// 先分片(新片体 → 新片签),全部落地后再提交 catalog
|
|
199
|
+
refs.forEach((ref, i) => {
|
|
200
|
+
if (ref.kind !== "fresh")
|
|
201
|
+
return;
|
|
202
|
+
const shardPath = path.join(shardsDir, `${ref.no}.yaml`);
|
|
203
|
+
writeIndexFile(shardPath, bodies[i]!);
|
|
204
|
+
written.push(shardPath);
|
|
205
|
+
const sigPath = `${shardPath}.sig`;
|
|
206
|
+
writeIndexFile(sigPath, `${JSON.stringify(shardSigs.get(ref.no)!, null, 2)}\n`);
|
|
207
|
+
written.push(sigPath);
|
|
208
|
+
});
|
|
209
|
+
opts.afterShards?.();
|
|
210
|
+
// catalog 是提交点:正文先行、签名紧随(两个 rename 之间的微秒级窗口内新旧混搭,
|
|
211
|
+
// 读取器按签名不符拒载——fail-closed,不会把中间态当有效索引用)
|
|
212
|
+
writeIndexFile(catalogPath, catalogText);
|
|
213
|
+
written.push(catalogPath);
|
|
214
|
+
writeIndexFile(catalogSigPath, `${JSON.stringify(catalogSig, null, 2)}\n`);
|
|
215
|
+
written.push(catalogSigPath);
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
// 本次写入的全部文件按「从未发生」回滚;tmp 残片一并扫掉。旧代原样可读。
|
|
219
|
+
for (const file of written) {
|
|
220
|
+
try {
|
|
221
|
+
fs.rmSync(file, { force: true });
|
|
222
|
+
}
|
|
223
|
+
catch {}
|
|
224
|
+
}
|
|
225
|
+
// catalog 若已覆盖旧代:尽力恢复旧字节(无旧代 = 当时还没有索引,删掉即可)
|
|
226
|
+
if (written.includes(catalogPath))
|
|
227
|
+
restoreFile(catalogPath, prevCatalog);
|
|
228
|
+
if (written.includes(catalogSigPath))
|
|
229
|
+
restoreFile(catalogSigPath, prevCatalogSig);
|
|
230
|
+
sweepTmpFiles(shardsDir);
|
|
231
|
+
sweepTmpFiles(layout.dir);
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 提交后清场:不被新 catalog 引用的片(旧代残留 + 历史孤儿)与 tmp 残片
|
|
236
|
+
const referenced = new Set(pins.keys());
|
|
237
|
+
for (const no of fs.readdirSync(shardsDir)) {
|
|
238
|
+
if (/^\d+\.yaml(?:\.sig)?$/.test(no)) {
|
|
239
|
+
const fileNo = Number.parseInt(no, 10);
|
|
240
|
+
if (!referenced.has(fileNo)) {
|
|
241
|
+
try {
|
|
242
|
+
fs.rmSync(path.join(shardsDir, no), { force: true });
|
|
243
|
+
}
|
|
244
|
+
catch {}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
sweepTmpFiles(shardsDir);
|
|
249
|
+
sweepTmpFiles(layout.dir);
|
|
87
250
|
|
|
88
|
-
//
|
|
89
|
-
|
|
251
|
+
// 离线自检:catalog + 各片签名都能用公钥复核(含未重写的沿用片——它们也必须在盘上可验)
|
|
252
|
+
let checkPassed: boolean | undefined;
|
|
253
|
+
if (opts.check === true) {
|
|
254
|
+
const shardsOk = [...pins.entries()].every(([no]) => {
|
|
255
|
+
const text = fs.readFileSync(path.join(shardsDir, `${no}.yaml`), "utf-8");
|
|
256
|
+
const sig = JSON.parse(fs.readFileSync(path.join(shardsDir, `${no}.yaml.sig`), "utf-8")) as { public_key: string, value: string, seq: number };
|
|
257
|
+
return sig.seq === pins.get(no) && verifyCanonical(sig.public_key, indexCanonical("shard", sig.seq, text), sig.value);
|
|
258
|
+
});
|
|
259
|
+
const catalogOk = verifyCanonical(catalogSig.public_key, indexCanonical("catalog", seq, catalogText), catalogSig.value);
|
|
260
|
+
checkPassed = catalogOk && shardsOk;
|
|
261
|
+
console.log(checkPassed ? "[ojm] 自检通过:索引签名与内容匹配" : "[ojm] 自检失败:索引签名与内容不匹配——请检查私钥与索引后重试");
|
|
262
|
+
if (!checkPassed)
|
|
263
|
+
process.exitCode = 1;
|
|
264
|
+
}
|
|
90
265
|
|
|
91
|
-
|
|
266
|
+
const shardPaths = [...pins.keys()].sort((a, b) => a - b).map(no => path.join(shardsDir, `${no}.yaml`));
|
|
267
|
+
console.log(`[ojm] 索引已重建:${data.solutions.length} 个方案 / ${packages.length} 个版本 → catalog(第 ${seq} 代)+ ${pins.size} 片(来源 ${files.length} 个 tgz,${freshCount} 片重写)`);
|
|
92
268
|
if (skipped.length > 0)
|
|
93
269
|
console.warn(`[ojm] ${skipped.length} 个包被跳过:${skipped.join(", ")}`);
|
|
94
270
|
|
|
95
271
|
return {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
272
|
+
catalogPath,
|
|
273
|
+
catalogSigPath: `${catalogPath}.sig`,
|
|
274
|
+
shardPaths,
|
|
275
|
+
solutions: data.solutions.length,
|
|
276
|
+
shards: pins.size,
|
|
101
277
|
skipped,
|
|
278
|
+
seq,
|
|
279
|
+
issuedAt: meta.issued_at,
|
|
280
|
+
expiresAt: meta.expires_at,
|
|
281
|
+
checkPassed,
|
|
102
282
|
};
|
|
103
283
|
}
|
|
104
284
|
|
|
285
|
+
/**
|
|
286
|
+
* 读盘上既有代次:catalog(seq + 片钉 + 映射)与各被钉住片的正文。
|
|
287
|
+
*
|
|
288
|
+
* 只在「catalog 可解析、seq 合法、片文件与 .sig 俱在且 .sig.seq == 钉值」时认一片——
|
|
289
|
+
* 缺任一片都只少一次内容匹配机会(该槽位按新片重写),不影响其余片沿用。
|
|
290
|
+
* catalog 整体不可信 → 空代次(seq=1 全量重来,与旧版「索引丢了就重建」同语义)。
|
|
291
|
+
*/
|
|
292
|
+
function readExistingGeneration(catalogPath: string, shardsDir: string): {
|
|
293
|
+
seq: number | null
|
|
294
|
+
pins: Map<number, number>
|
|
295
|
+
shards: Map<number, ExistingShard>
|
|
296
|
+
mapping: Map<string, number[]> | null
|
|
297
|
+
onDiskNos: number[]
|
|
298
|
+
} {
|
|
299
|
+
const empty = { seq: null, pins: new Map<number, number>(), shards: new Map<number, ExistingShard>(), mapping: null, onDiskNos: [] as number[] };
|
|
300
|
+
if (!fs.existsSync(catalogPath))
|
|
301
|
+
return { ...empty, onDiskNos: onDiskShardNos(shardsDir) };
|
|
302
|
+
let doc: unknown;
|
|
303
|
+
try {
|
|
304
|
+
doc = yamlParse(fs.readFileSync(catalogPath, "utf-8"));
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
return { ...empty, onDiskNos: onDiskShardNos(shardsDir) };
|
|
308
|
+
}
|
|
309
|
+
if (!isObj(doc))
|
|
310
|
+
return { ...empty, onDiskNos: onDiskShardNos(shardsDir) };
|
|
311
|
+
const seq = (doc as Record<string, unknown>).seq;
|
|
312
|
+
if (typeof seq !== "number" || !Number.isInteger(seq) || seq < 1)
|
|
313
|
+
return { ...empty, onDiskNos: onDiskShardNos(shardsDir) };
|
|
314
|
+
|
|
315
|
+
const pins = new Map<number, number>();
|
|
316
|
+
const rawPins = (doc as Record<string, unknown>).shards;
|
|
317
|
+
if (isObj(rawPins)) {
|
|
318
|
+
for (const [k, v] of Object.entries(rawPins)) {
|
|
319
|
+
const no = Number.parseInt(k, 10);
|
|
320
|
+
if (Number.isInteger(no) && no >= 1 && typeof v === "number" && Number.isInteger(v) && v >= 1)
|
|
321
|
+
pins.set(no, v);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const mapping = new Map<string, number[]>();
|
|
325
|
+
const rawSolutions = (doc as Record<string, unknown>).solutions;
|
|
326
|
+
if (Array.isArray(rawSolutions)) {
|
|
327
|
+
for (const s of rawSolutions) {
|
|
328
|
+
if (!isObj(s))
|
|
329
|
+
continue;
|
|
330
|
+
const name = s.name;
|
|
331
|
+
const list = s.shards;
|
|
332
|
+
if (typeof name !== "string" || !Array.isArray(list))
|
|
333
|
+
continue;
|
|
334
|
+
mapping.set(name, list.filter((n): n is number => typeof n === "number" && Number.isInteger(n)));
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const shards = new Map<number, ExistingShard>();
|
|
339
|
+
for (const [no, pinSeq] of pins) {
|
|
340
|
+
const shardPath = path.join(shardsDir, `${no}.yaml`);
|
|
341
|
+
const sigPath = `${shardPath}.sig`;
|
|
342
|
+
if (!fs.existsSync(shardPath) || !fs.existsSync(sigPath))
|
|
343
|
+
continue;
|
|
344
|
+
let sig: { seq?: unknown };
|
|
345
|
+
try {
|
|
346
|
+
sig = JSON.parse(fs.readFileSync(sigPath, "utf-8")) as { seq?: unknown };
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
// .sig 代次与 catalog 钉值不一致 = 盘上已是中间态,该片不可沿用
|
|
352
|
+
if (sig?.seq !== pinSeq)
|
|
353
|
+
continue;
|
|
354
|
+
try {
|
|
355
|
+
shards.set(no, { no, seq: pinSeq, body: recomputeIndex(fs.readFileSync(shardPath, "utf-8")) });
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
// 正文不可解析:不可比对,按新片重写
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return { seq, pins, shards, mapping, onDiskNos: onDiskShardNos(shardsDir) };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** 盘上 `shards/` 里现存的片号(含历史孤儿——新片号必须避开它们,防 rename 踩掉正在被读的文件) */
|
|
365
|
+
function onDiskShardNos(shardsDir: string): number[] {
|
|
366
|
+
if (!fs.existsSync(shardsDir))
|
|
367
|
+
return [];
|
|
368
|
+
return fs.readdirSync(shardsDir)
|
|
369
|
+
.filter(f => /^\d+\.yaml$/.test(f))
|
|
370
|
+
.map(f => Number.parseInt(f, 10))
|
|
371
|
+
.filter(n => Number.isInteger(n) && n >= 1)
|
|
372
|
+
.sort((a, b) => a - b);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** 集合最大值(用 reduce 而非 Math.max(...set),避免分片数极大时展开参数撑爆调用栈) */
|
|
376
|
+
function maxOf(nums: Set<number>): number {
|
|
377
|
+
let m = 0;
|
|
378
|
+
for (const n of nums) {
|
|
379
|
+
if (n > m)
|
|
380
|
+
m = n;
|
|
381
|
+
}
|
|
382
|
+
return m;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* 新旧 catalog 映射是否逐字一致(键集合同、每键片号列表同序同值)——一致即「本次重建没有
|
|
387
|
+
* 改变 catalog 正文」,代次不 +1(重复跑不漂移)。布尔式保持函数式单行返回值:本仓
|
|
388
|
+
* indent-binary-ops 与 no-mixed-spaces-and-tabs 对续行缩进的要求互相打架,见 verify.ts 同款注释。
|
|
389
|
+
*/
|
|
390
|
+
function sameMappingAs(prev: Map<string, number[]> | null, next: Map<string, number[]>): boolean {
|
|
391
|
+
if (prev === null || prev.size !== next.size)
|
|
392
|
+
return false;
|
|
393
|
+
return [...next.entries()].every(([name, list]) => {
|
|
394
|
+
const before = prev.get(name);
|
|
395
|
+
return before !== undefined && before.length === list.length && before.every((no, i) => no === list[i]);
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* REG-CS2 `latest` 单调不降:同名方案的新 `latest` 不得低于盘上旧值。
|
|
401
|
+
*
|
|
402
|
+
* 丢包、误删版本目录、拿回旧快照重建都会让 latest 往回走——读者据此把「更旧」当「有更新」装上,
|
|
403
|
+
* 而已装实例还停在更高的版本上,回滚与升级判定全线错位。宁可拒建,也不产出一个往回走的索引。
|
|
404
|
+
* 旧值取自既有代次的分片正文(三级化后 latest 住在片里,不在 catalog)。
|
|
405
|
+
*/
|
|
406
|
+
function assertLatestMonotonic(existing: { shards: Map<number, ExistingShard>, mapping: Map<string, number[]> | null }, bodies: string[], yanked?: Map<string, { name: string, version: string, at: number, by: string, reason?: string }>): void {
|
|
407
|
+
if (existing.mapping === null)
|
|
408
|
+
return;
|
|
409
|
+
const before = latestMapOf(existing.shards, existing.mapping);
|
|
410
|
+
const after = new Map<string, string>();
|
|
411
|
+
for (const body of bodies) {
|
|
412
|
+
for (const [name, latest] of latestMapOfBody(body)) {
|
|
413
|
+
if (!after.has(name))
|
|
414
|
+
after.set(name, latest);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
for (const [name, version] of after) {
|
|
418
|
+
const prev = before.get(name);
|
|
419
|
+
// 例外:旧 latest 此刻已被 yank(REG-CS4)——合法的下线会让 latest 主动下降,不算回退。
|
|
420
|
+
// 只有「非 yank 驱动的下降」才判回退(误删版本 / 拿回旧索引)。
|
|
421
|
+
if (prev !== undefined && compareSemver(version, prev) < 0 && yanked?.has(`${name}@${prev}`) !== true)
|
|
422
|
+
throw new Error(`[ojm] ${name} 的 latest 回退(${prev} → ${version}):REG-CS2 要求 latest 单调不降——检查是否有版本被误删或拿回了旧索引`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** 既有代次的 `方案 → latest`(按 catalog 映射找到每个包所在片,取片内条目) */
|
|
427
|
+
function latestMapOf(shards: Map<number, ExistingShard>, mapping: Map<string, number[]>): Map<string, string> {
|
|
428
|
+
const out = new Map<string, string>();
|
|
429
|
+
for (const [name, list] of mapping) {
|
|
430
|
+
for (const no of list) {
|
|
431
|
+
const shard = shards.get(no);
|
|
432
|
+
if (shard === undefined)
|
|
433
|
+
continue;
|
|
434
|
+
for (const [n, latest] of latestMapOfBody(shard.body)) {
|
|
435
|
+
if (n === name && !out.has(name))
|
|
436
|
+
out.set(name, latest);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return out;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** 一片正文的 `方案 → latest`(解析失败 → 空表) */
|
|
444
|
+
function latestMapOfBody(body: string): Map<string, string> {
|
|
445
|
+
const out = new Map<string, string>();
|
|
446
|
+
let doc: unknown;
|
|
447
|
+
try {
|
|
448
|
+
doc = yamlParse(body);
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
return out;
|
|
452
|
+
}
|
|
453
|
+
const solutions = (isObj(doc) ? doc.solutions : undefined) as unknown;
|
|
454
|
+
for (const s of Array.isArray(solutions) ? solutions : []) {
|
|
455
|
+
if (!isObj(s))
|
|
456
|
+
continue;
|
|
457
|
+
const { name, latest } = s as { name?: unknown, latest?: unknown };
|
|
458
|
+
if (typeof name === "string" && typeof latest === "string")
|
|
459
|
+
out.set(name, latest);
|
|
460
|
+
}
|
|
461
|
+
return out;
|
|
462
|
+
}
|
|
463
|
+
|
|
105
464
|
/**
|
|
106
465
|
* 读包级 detached 签名侧车(`<tgz>.sig`),转成 Form B `archive.signature`。
|
|
107
466
|
*
|
|
@@ -131,21 +490,29 @@ function normalizeYaml(value: unknown): string {
|
|
|
131
490
|
return recomputeIndex(yamlStringify(value, { lineWidth: 0 }));
|
|
132
491
|
}
|
|
133
492
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
493
|
+
/** 扫掉目录里的 tmp 残片(历史崩溃遗留;不可读为索引,但没必要留在读面目录) */
|
|
494
|
+
function sweepTmpFiles(dir: string): void {
|
|
495
|
+
if (!fs.existsSync(dir))
|
|
496
|
+
return;
|
|
497
|
+
for (const f of fs.readdirSync(dir)) {
|
|
498
|
+
if (f.includes(".tmp.")) {
|
|
499
|
+
try {
|
|
500
|
+
fs.rmSync(path.join(dir, f), { force: true });
|
|
501
|
+
}
|
|
502
|
+
catch {}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** 回滚恢复:写回旧代字节(无旧代 = 删除)。只用于 catalog 提交窗口内的异常恢复 */
|
|
508
|
+
function restoreFile(file: string, prev: string | null): void {
|
|
509
|
+
try {
|
|
510
|
+
if (prev === null)
|
|
511
|
+
fs.rmSync(file, { force: true });
|
|
512
|
+
else
|
|
513
|
+
writeIndexFile(file, prev);
|
|
514
|
+
}
|
|
515
|
+
catch {}
|
|
149
516
|
}
|
|
150
517
|
|
|
151
518
|
/** 从身份目录或私钥文件读出 PEM(磁盘路径是常规场景;隔离场景应直接传内存 PEM) */
|
package/src/market/cdn-layout.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* (2026-10-23 起 dev 形态 ojm 已把 /cdn 反代给 oj 子进程,`framework.market_cdn_url`
|
|
7
7
|
* 允许写相对路径 `/cdn` = 本站点自供,由消费侧按请求源补全;本模块的绝对基址推导
|
|
8
8
|
* 只服务 `market setup` 写外部地址的场景。)
|
|
9
|
-
* 2. **基址无尾斜杠**——`market/api.ts` 按 `${base}/
|
|
9
|
+
* 2. **基址无尾斜杠**——`market/api.ts` 按 `${base}/catalog.yaml` 拼。
|
|
10
10
|
*
|
|
11
11
|
* 文本版(`resolveCdnLayoutFromConfig`)与工程版(`resolveCdnLayout`)分工:前者纯函数便于单测,
|
|
12
12
|
* 后者负责读盘。取值口径只有前者一份,后者不重算。
|
|
@@ -38,7 +38,8 @@ export function resolveCdnLayout(projectRoot: string): CdnLayout {
|
|
|
38
38
|
|
|
39
39
|
/** 从 config 文本解析(纯函数,不落 IO) */
|
|
40
40
|
export function resolveCdnLayoutFromConfig(configText: string, configPath: string): CdnLayout {
|
|
41
|
-
const
|
|
41
|
+
const parsed = yamlParse(configText);
|
|
42
|
+
const doc = isObj(parsed) ? parsed : {};
|
|
42
43
|
const mount = findCdnMount(mountsOf(doc), configPath);
|
|
43
44
|
const configDir = path.dirname(configPath);
|
|
44
45
|
|
package/src/market/command.ts
CHANGED
|
@@ -13,21 +13,27 @@ import { setupMarketUrl } from "./setup";
|
|
|
13
13
|
import { trialCommand } from "./trial";
|
|
14
14
|
import { verifyMarket } from "./verify";
|
|
15
15
|
import { walkthroughCommand } from "./walkthrough";
|
|
16
|
+
import { markYanked } from "./yank-ledger";
|
|
16
17
|
|
|
17
18
|
interface Flags {
|
|
18
19
|
id?: string
|
|
19
20
|
key?: string
|
|
20
21
|
fixture?: string
|
|
21
22
|
real?: string
|
|
22
|
-
|
|
23
|
+
siteUrl?: string
|
|
23
24
|
user?: string
|
|
24
25
|
pass?: string
|
|
25
26
|
newPass?: string
|
|
27
|
+
registryId?: string
|
|
26
28
|
rebuildIndex?: boolean
|
|
27
29
|
check?: boolean
|
|
30
|
+
/** yank:操作人(审计留痕;缺省 "local-cli") */
|
|
31
|
+
by?: string
|
|
32
|
+
/** yank:下线原因(审计留痕;可选) */
|
|
33
|
+
reason?: string
|
|
28
34
|
}
|
|
29
35
|
|
|
30
|
-
const VALUE_FLAGS = new Set(["--id", "--key", "--fixture", "--real", "--url", "--user", "--pass", "--new-pass"]);
|
|
36
|
+
const VALUE_FLAGS = new Set(["--id", "--key", "--fixture", "--real", "--site-url", "--user", "--pass", "--new-pass", "--registry-id", "--by", "--reason"]);
|
|
31
37
|
|
|
32
38
|
function parseFlags(argv: string[]): Flags {
|
|
33
39
|
const flags: Flags = {};
|
|
@@ -52,14 +58,20 @@ function parseFlags(argv: string[]): Flags {
|
|
|
52
58
|
flags.fixture = value;
|
|
53
59
|
else if (arg === "--real")
|
|
54
60
|
flags.real = value;
|
|
55
|
-
else if (arg === "--url")
|
|
56
|
-
flags.
|
|
61
|
+
else if (arg === "--site-url")
|
|
62
|
+
flags.siteUrl = value;
|
|
57
63
|
else if (arg === "--user")
|
|
58
64
|
flags.user = value;
|
|
59
65
|
else if (arg === "--pass")
|
|
60
66
|
flags.pass = value;
|
|
61
67
|
else if (arg === "--new-pass")
|
|
62
68
|
flags.newPass = value;
|
|
69
|
+
else if (arg === "--registry-id")
|
|
70
|
+
flags.registryId = value;
|
|
71
|
+
else if (arg === "--by")
|
|
72
|
+
flags.by = value;
|
|
73
|
+
else if (arg === "--reason")
|
|
74
|
+
flags.reason = value;
|
|
63
75
|
}
|
|
64
76
|
}
|
|
65
77
|
return flags;
|
|
@@ -68,6 +80,27 @@ function parseFlags(argv: string[]): Flags {
|
|
|
68
80
|
export async function marketCommand(sub: string, argv: string[]): Promise<void> {
|
|
69
81
|
const flags = parseFlags(argv);
|
|
70
82
|
switch (sub) {
|
|
83
|
+
case "yank": {
|
|
84
|
+
// 位置参数:<name>@<version>(非 -- 开头的首个 token)
|
|
85
|
+
const target = argv.find(a => !a.startsWith("--"));
|
|
86
|
+
if (target === undefined || !target.includes("@"))
|
|
87
|
+
throw new Error("[ojm] 用法:ojm market yank <name>@<version> [--by <操作人>] [--reason <原因>]");
|
|
88
|
+
const at = target.indexOf("@");
|
|
89
|
+
const name = target.slice(0, at);
|
|
90
|
+
const version = target.slice(at + 1);
|
|
91
|
+
const by = flags.by ?? "local-cli";
|
|
92
|
+
// 重建索引须给签名身份——先校验再落账,避免「账本已记 yank 但索引未重建」的半成品态
|
|
93
|
+
if (flags.id === undefined)
|
|
94
|
+
throw new Error("[ojm] yank 须给定 --id <签名身份> 才能重建索引生效——请补 --id 后重试,已下线条目将随即写进分片正文(REG-CS4)");
|
|
95
|
+
const r = markYanked(process.cwd(), name, version, { by, reason: flags.reason });
|
|
96
|
+
if (r.created)
|
|
97
|
+
console.log(`[ojm] 已下线 ${name}@${version}(操作人 ${by}${flags.reason !== undefined ? `,原因:${flags.reason}` : ""})——重建索引以生效`);
|
|
98
|
+
else
|
|
99
|
+
console.log(`[ojm] ${name}@${version} 此前已下线,幂等跳过`);
|
|
100
|
+
// 重建索引让 yanked 标记进分片正文(REG-CS4:latest 跳过 + 安装侧精确版本拒装)
|
|
101
|
+
await buildIndex({ id: flags.id, key: flags.key, check: flags.check });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
71
104
|
case "publish": {
|
|
72
105
|
await publishPackage({
|
|
73
106
|
fixture: flags.fixture,
|
|
@@ -75,6 +108,7 @@ export async function marketCommand(sub: string, argv: string[]): Promise<void>
|
|
|
75
108
|
id: flags.id,
|
|
76
109
|
key: flags.key,
|
|
77
110
|
check: flags.check,
|
|
111
|
+
registryId: flags.registryId,
|
|
78
112
|
});
|
|
79
113
|
if (flags.rebuildIndex === true)
|
|
80
114
|
await buildIndex({ id: flags.id, key: flags.key, check: flags.check });
|
|
@@ -106,11 +140,11 @@ export async function marketCommand(sub: string, argv: string[]): Promise<void>
|
|
|
106
140
|
return;
|
|
107
141
|
}
|
|
108
142
|
case "setup": {
|
|
109
|
-
const result = await setupMarketUrl({
|
|
143
|
+
const result = await setupMarketUrl({ siteUrl: flags.siteUrl, user: flags.user, pass: flags.pass, newPass: flags.newPass });
|
|
110
144
|
console.log(`[ojm] 站点 ${result.siteUrl} 的市场地址已指向 ${result.cdnBaseUrl}`);
|
|
111
145
|
return;
|
|
112
146
|
}
|
|
113
147
|
default:
|
|
114
|
-
throw new Error(`[ojm] 未知子命令:ojm market ${sub}\n可用:setup / publish / build-index`);
|
|
148
|
+
throw new Error(`[ojm] 未知子命令:ojm market ${sub}\n可用:setup / publish / yank / build-index / verify / trial / walkthrough`);
|
|
115
149
|
}
|
|
116
150
|
}
|
|
@@ -45,7 +45,7 @@ export const FAILURE_PLAYBOOK: FailurePlaybookEntry[] = [
|
|
|
45
45
|
{
|
|
46
46
|
code: "index_sig",
|
|
47
47
|
stage: "安装前(索引解析)",
|
|
48
|
-
cause: "市场索引签名校验失败:
|
|
48
|
+
cause: "市场索引签名校验失败:catalog.yaml / shards/*.yaml 被篡改或签名与字节不符",
|
|
49
49
|
next: "重建索引并签名 `ojm market build-index`;同时核对 CDN 目录写入来源",
|
|
50
50
|
},
|
|
51
51
|
{
|