@oj-module/cli 0.2.30 → 0.2.43
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 +237 -13
- 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 +220 -53
- 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 +46 -96
- 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
|
@@ -14,7 +14,7 @@ import { parse } from "yaml";
|
|
|
14
14
|
// 跨端引用(双端一致钉):被测实现同仓分居两运行时(计划 §1「两运行时取舍」),
|
|
15
15
|
// 测试直接互调,比「两套件各读同一夹具」更强——漂移即本文件红。
|
|
16
16
|
import { preflight } from "../../../templates/src/_platform/api/_lifecycle/steps/preflight";
|
|
17
|
-
import { checkLocalesComplete, checkTablesAgainstSchema, parseManifest, validateContract } from "../../manifest-contract";
|
|
17
|
+
import { checkLocalesComplete, checkTablesAgainstSchema, parseManifest, RESERVED_NAMES, reservedNameSuggestions, validateContract } from "../../manifest-contract";
|
|
18
18
|
|
|
19
19
|
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "fixtures");
|
|
20
20
|
const readText = (p: string) => readFileSync(join(FIXTURES, p), "utf8");
|
|
@@ -62,6 +62,11 @@ interface RuleCase {
|
|
|
62
62
|
const RULES: RuleCase[] = [
|
|
63
63
|
{ rule: "name 白名单(大写拒绝)", clause: "C2-01", keyword: "name", mutate: (m) => { m.name = "Demo"; } },
|
|
64
64
|
{ rule: "name 保留名(market,R9)", clause: "C2-01", keyword: "保留名", mutate: (m) => { m.name = "market"; } },
|
|
65
|
+
{ rule: "name 保留名(oj,REG-CS7)", clause: "C2-01", keyword: "保留名", mutate: (m) => { m.name = "oj"; } },
|
|
66
|
+
{ rule: "name 保留名(ojm,REG-CS7)", clause: "C2-01", keyword: "保留名", mutate: (m) => { m.name = "ojm"; } },
|
|
67
|
+
{ rule: "name 保留名(registry,REG-CS7)", clause: "C2-01", keyword: "保留名", mutate: (m) => { m.name = "registry"; } },
|
|
68
|
+
{ rule: "api.prefix 保留段(registry,REG-CS7)", clause: "C2-13", keyword: "保留名", mutate: (m) => { m.api.prefix = "/api/registry"; } },
|
|
69
|
+
{ rule: "web.route_prefix 保留段(ojm,REG-CS7)", clause: "C2-14", keyword: "保留名", mutate: (m) => { m.web.route_prefix = "/ojm"; } },
|
|
65
70
|
{ rule: "version 禁 +(oj 相容排除,附录 B-1)", clause: "C2-02", keyword: "version", mutate: (m) => { m.version = "1.0.0+build.5"; } },
|
|
66
71
|
{ rule: "version 禁下划线(契约收紧)", clause: "C2-02", keyword: "version", mutate: (m) => { m.version = "1.0.0_beta"; } },
|
|
67
72
|
{ rule: "title ≤100", clause: "C2-03", keyword: "title", mutate: (m) => { m.title = "x".repeat(101); } },
|
|
@@ -170,6 +175,40 @@ describe("parseManifest", () => {
|
|
|
170
175
|
});
|
|
171
176
|
});
|
|
172
177
|
|
|
178
|
+
describe("rEG-CS7 保留名扩项(红队 F5)", () => {
|
|
179
|
+
it("oj/ojm/registry 命中保留名 → C2-01 违规且含替代名建议(两候选)", () => {
|
|
180
|
+
for (const bad of ["oj", "ojm", "registry"]) {
|
|
181
|
+
const m = structuredClone(VALID);
|
|
182
|
+
m.name = bad;
|
|
183
|
+
const v = validateContract(m);
|
|
184
|
+
const hit = v.find(x => x.includes("C2-01") && x.includes(bad));
|
|
185
|
+
expect(hit, `期望 ${bad} 命中 C2-01 保留名,实际:\n${v.join("\n")}`).toBeTruthy();
|
|
186
|
+
expect(hit).toContain("为保留名");
|
|
187
|
+
expect(hit).toContain("替代名建议");
|
|
188
|
+
const [a1, a2] = reservedNameSuggestions(bad);
|
|
189
|
+
expect(hit).toContain(a1);
|
|
190
|
+
expect(hit).toContain(a2);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("真实第一方方案名不被误伤(oj-registry / demo-solution 等)", () => {
|
|
195
|
+
for (const okName of ["oj-registry", "demo-solution", "oj-module", "ojm-cli"]) {
|
|
196
|
+
expect(RESERVED_NAMES.has(okName)).toBe(false);
|
|
197
|
+
const m = structuredClone(VALID);
|
|
198
|
+
m.name = okName;
|
|
199
|
+
const v = validateContract(m);
|
|
200
|
+
expect(v.find(x => x.includes("C2-01") && x.includes(okName))).toBeUndefined();
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("rEG-CS7 扩项后保留名清单含 oj/ojm/registry 且数量正确", () => {
|
|
205
|
+
expect(RESERVED_NAMES.has("oj")).toBe(true);
|
|
206
|
+
expect(RESERVED_NAMES.has("ojm")).toBe(true);
|
|
207
|
+
expect(RESERVED_NAMES.has("registry")).toBe(true);
|
|
208
|
+
expect(RESERVED_NAMES.size).toBe(11);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
173
212
|
// ── 文件面契约(构建期专属执行点;纯内容入参,fs 在 bundle.ts)──
|
|
174
213
|
|
|
175
214
|
describe("checkTablesAgainstSchema(C2-12a:构建 ✅ 对照 schema.yaml)", () => {
|
|
@@ -1,33 +1,46 @@
|
|
|
1
|
-
// publish
|
|
2
|
-
//
|
|
3
|
-
|
|
1
|
+
// ojm publish spec(P3-5/6/7,REG-CS5 三级化后):对发布目录的 catalog.yaml + shards/<n>.yaml
|
|
2
|
+
// 重算 → 盖章代次时效 → detached .sig 签名写回 → 离线自检。纯 Node 侧(与 ojm sign 同签名
|
|
3
|
+
// 原语 lifecycle/sign.ts),验证侧用 verifyCanonical 复核。
|
|
4
|
+
//
|
|
5
|
+
// 本文件钉死的不变量:
|
|
6
|
+
// ① catalog / shard 两种 kind 的签名各自覆盖 (kind, seq)——换 kind 或换代即失配(REG-CS1);
|
|
7
|
+
// ② 分片代次由 catalog 的 `shards` 钉值承载,逐片与盘上 .sig 交叉核对,对不上不替它签字;
|
|
8
|
+
// ③ 落盘顺序固定「先分片 → 再 catalog」(catalog 是提交点,红队 F2 半写防线);
|
|
9
|
+
// ④ 索引写入全仓只有 writeIndexFile 一条路径(源码级不变量,grep 可证)。
|
|
10
|
+
import fs, { existsSync, mkdirSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
11
|
import { tmpdir } from "node:os";
|
|
5
12
|
import { join } from "node:path";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
15
|
+
import { parse as yamlParse } from "yaml";
|
|
16
|
+
import { INDEX_TTL_SECONDS, indexCanonical, publishCommand, recomputeIndex, signIndex } from "../../publish";
|
|
17
|
+
import { CANON, verifyCanonical } from "../sign";
|
|
9
18
|
import { defaultTestKey } from "./helpers";
|
|
10
19
|
|
|
11
|
-
|
|
20
|
+
/** 首代索引夹具:catalog 钉 1 号片(代次 1),分片持一个方案的一个版本 */
|
|
21
|
+
const CATALOG = `shards:
|
|
22
|
+
"1": 1
|
|
23
|
+
solutions:
|
|
24
|
+
- name: demo-solution
|
|
25
|
+
shards:
|
|
26
|
+
- 1
|
|
27
|
+
`;
|
|
28
|
+
const SHARD1 = `solutions:
|
|
12
29
|
- name: demo-solution
|
|
13
30
|
latest: 0.1.0
|
|
14
31
|
versions:
|
|
15
32
|
- version: 0.1.0
|
|
16
33
|
file: demo-solution-0.1.0.tgz
|
|
17
34
|
`;
|
|
18
|
-
const MANIFESTS = `- name: demo-solution
|
|
19
|
-
version: 0.1.0
|
|
20
|
-
author: acme
|
|
21
|
-
changelog: initial
|
|
22
|
-
`;
|
|
23
35
|
|
|
24
36
|
let dir: string;
|
|
25
37
|
let keyPath: string;
|
|
26
38
|
|
|
27
39
|
beforeEach(() => {
|
|
28
40
|
dir = mkdtempSync(join(tmpdir(), "ojm-publish-"));
|
|
29
|
-
writeFileSync(join(dir, "
|
|
30
|
-
|
|
41
|
+
writeFileSync(join(dir, "catalog.yaml"), CATALOG);
|
|
42
|
+
mkdirSync(join(dir, "shards"), { recursive: true });
|
|
43
|
+
writeFileSync(join(dir, "shards", "1.yaml"), SHARD1);
|
|
31
44
|
keyPath = join(dir, "key.pem");
|
|
32
45
|
writeFileSync(keyPath, defaultTestKey().privateKeyPem);
|
|
33
46
|
});
|
|
@@ -36,23 +49,57 @@ afterEach(() => {
|
|
|
36
49
|
// 临时目录由 OS 回收(测试用 tmpdir,无需手动 rm)
|
|
37
50
|
});
|
|
38
51
|
|
|
39
|
-
|
|
40
|
-
|
|
52
|
+
/** 读回 catalog 正文(解析后的文档) */
|
|
53
|
+
function catalogDoc(): Record<string, unknown> {
|
|
54
|
+
return yamlParse(readFileSync(join(dir, "catalog.yaml"), "utf8")) as Record<string, unknown>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 读回某个分片的 .sig(JSON) */
|
|
58
|
+
function shardSig(no: number): { seq?: unknown, value: string } {
|
|
59
|
+
return JSON.parse(readFileSync(join(dir, "shards", `${no}.yaml.sig`), "utf8"));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe("ojm publish 索引重算 + 签名(P3-5/6/7,REG-CS5 三级化)", () => {
|
|
63
|
+
it("重算 catalog 与分片并产出 detached .sig,两种 kind 各自可被 verifyCanonical 复核通过", () => {
|
|
41
64
|
const res = publishCommand(dir, { key: keyPath, id: "test.publisher" });
|
|
42
|
-
expect(existsSync(res.
|
|
43
|
-
expect(
|
|
65
|
+
expect(existsSync(res.catalogSigPath)).toBe(true);
|
|
66
|
+
expect(res.shardPaths).toEqual([join(dir, "shards", "1.yaml")]);
|
|
67
|
+
expect(res.shardSigPaths).toEqual([join(dir, "shards", "1.yaml.sig")]);
|
|
44
68
|
expect(res.keyId).toBe("test.publisher");
|
|
45
69
|
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
const
|
|
70
|
+
const catalogText = readFileSync(res.catalogPath, "utf8");
|
|
71
|
+
const shard1Text = readFileSync(res.shardPaths[0]!, "utf8");
|
|
72
|
+
const catalogSig = JSON.parse(readFileSync(res.catalogSigPath, "utf8"));
|
|
73
|
+
const sig1 = shardSig(1);
|
|
74
|
+
|
|
75
|
+
expect(catalogSig.alg).toBe("Ed25519");
|
|
76
|
+
expect(catalogSig.key_id).toBe("test.publisher");
|
|
77
|
+
expect(catalogSig.canon).toBe(CANON);
|
|
78
|
+
// REG-CS1/CS2:签名覆盖代次——`.sig` 与 catalog 正文各带 seq 且相等;分片代次 = catalog 钉值
|
|
79
|
+
expect(catalogSig.seq).toBe(1);
|
|
80
|
+
expect(sig1.seq).toBe(1);
|
|
81
|
+
expect(catalogDoc().seq).toBe(1);
|
|
82
|
+
// 时效盖进 catalog 正文并由签名锁定(REG-CS2):有效期 = 全仓唯一的 INDEX_TTL_SECONDS
|
|
83
|
+
const times = catalogDoc() as { issued_at?: unknown, expires_at?: unknown };
|
|
84
|
+
expect(typeof times.issued_at).toBe("number");
|
|
85
|
+
expect(times.expires_at as number - (times.issued_at as number)).toBe(INDEX_TTL_SECONDS);
|
|
86
|
+
// 两种 kind 分别验过(规范串首段就是 kind)
|
|
87
|
+
expect(verifyCanonical(res.publicKeyHex, indexCanonical("catalog", 1, catalogText), catalogSig.value)).toBe(true);
|
|
88
|
+
expect(verifyCanonical(res.publicKeyHex, indexCanonical("shard", 1, shard1Text), sig1.value)).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("换代即失效、换 kind 即失效(REG-CS1:签名锁定 (kind, seq),两代拼不起来)", () => {
|
|
92
|
+
const res = publishCommand(dir, { key: keyPath, id: "test.publisher" });
|
|
93
|
+
const catalogText = readFileSync(res.catalogPath, "utf8");
|
|
94
|
+
const shard1Text = readFileSync(res.shardPaths[0]!, "utf8");
|
|
95
|
+
const catalogSig = JSON.parse(readFileSync(res.catalogSigPath, "utf8"));
|
|
96
|
+
const sig1 = shardSig(1);
|
|
50
97
|
|
|
51
|
-
|
|
52
|
-
expect(
|
|
53
|
-
|
|
54
|
-
expect(verifyCanonical(res.publicKeyHex, indexCanonical("
|
|
55
|
-
expect(verifyCanonical(res.publicKeyHex, indexCanonical("
|
|
98
|
+
// 换代:同一份正文谎称第 2 代 → 验不过(防「新总目录 + 旧分册」被拼起来用,红队 F2)
|
|
99
|
+
expect(verifyCanonical(res.publicKeyHex, indexCanonical("catalog", 2, catalogText), catalogSig.value)).toBe(false);
|
|
100
|
+
// 换 kind:分片签名不能当 catalog 签名用,反之亦然
|
|
101
|
+
expect(verifyCanonical(res.publicKeyHex, indexCanonical("catalog", 1, shard1Text), sig1.value)).toBe(false);
|
|
102
|
+
expect(verifyCanonical(res.publicKeyHex, indexCanonical("shard", 1, catalogText), catalogSig.value)).toBe(false);
|
|
56
103
|
});
|
|
57
104
|
|
|
58
105
|
it("--check 离线自检通过(checkPassed === true)", () => {
|
|
@@ -64,17 +111,111 @@ describe("ojm publish 索引重算 + 签名(P3-5/6/7)", () => {
|
|
|
64
111
|
const a = recomputeIndex("x: 1\ny: 2\n");
|
|
65
112
|
const b = recomputeIndex("y: 2\nx: 1\n");
|
|
66
113
|
expect(a).toBe(b);
|
|
67
|
-
// 重算文本可被 signIndex
|
|
68
|
-
const sig = signIndex("
|
|
69
|
-
expect(
|
|
114
|
+
// 重算文本可被 signIndex 正常签出六字段(含 seq)
|
|
115
|
+
const sig = signIndex("catalog", 7, a, defaultTestKey().privateKeyPem, "test.publisher");
|
|
116
|
+
expect(sig.seq).toBe(7);
|
|
117
|
+
expect(verifyCanonical(defaultTestKey().publicKeyHex, indexCanonical("catalog", 7, a), sig.value)).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("补签沿用既有的代次与时效(catalog seq 2 + 分片钉 5:重签不改时效语义)", () => {
|
|
121
|
+
// 第二代索引:catalog 顶层 seq=2、时效是一代签发时刻盖的;1 号片钉第 5 代且盘上 .sig 就是第 5 代
|
|
122
|
+
writeFileSync(join(dir, "catalog.yaml"), `seq: 2\nissued_at: 1000\nexpires_at: ${1000 + INDEX_TTL_SECONDS}\n${CATALOG.replace(/shards:\n {2}"1": 1/, "shards:\n \"1\": 5")}`);
|
|
123
|
+
writeFileSync(join(dir, "shards", "1.yaml.sig"), `${JSON.stringify(signIndex("shard", 5, recomputeIndex(SHARD1), defaultTestKey().privateKeyPem, "test.publisher"), null, 2)}\n`);
|
|
124
|
+
|
|
125
|
+
const res = publishCommand(dir, { key: keyPath, id: "test.publisher" });
|
|
126
|
+
const catalogSig = JSON.parse(readFileSync(res.catalogSigPath, "utf8"));
|
|
127
|
+
expect(catalogSig.seq).toBe(2); // 代次沿用,不重置
|
|
128
|
+
expect(shardSig(1).seq).toBe(5); // 分片代次跟 catalog 钉值,不跟 catalog seq
|
|
129
|
+
const times = catalogDoc() as { issued_at?: unknown, expires_at?: unknown };
|
|
130
|
+
expect(times.issued_at).toBe(1000); // 重签不改时效
|
|
131
|
+
expect(times.expires_at).toBe(1000 + INDEX_TTL_SECONDS);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("同内容重复 publish 字节一致(Ed25519 确定性签名 + 键序归一化)", () => {
|
|
135
|
+
const first = publishCommand(dir, { key: keyPath, id: "test.publisher" });
|
|
136
|
+
const catalogA = readFileSync(first.catalogPath);
|
|
137
|
+
const sigA = readFileSync(first.catalogSigPath);
|
|
138
|
+
const shardA = readFileSync(first.shardPaths[0]!);
|
|
139
|
+
const shardSigA = readFileSync(first.shardSigPaths[0]!);
|
|
140
|
+
|
|
141
|
+
const second = publishCommand(dir, { key: keyPath, id: "test.publisher" });
|
|
142
|
+
expect(readFileSync(second.catalogPath)).toEqual(catalogA);
|
|
143
|
+
expect(readFileSync(second.catalogSigPath)).toEqual(sigA);
|
|
144
|
+
expect(readFileSync(second.shardPaths[0]!)).toEqual(shardA);
|
|
145
|
+
expect(readFileSync(second.shardSigPaths[0]!)).toEqual(shardSigA);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("缺 catalog.yaml → 抛错(不发半成品 .sig)", () => {
|
|
149
|
+
unlinkSync(join(dir, "catalog.yaml"));
|
|
150
|
+
expect(() => publishCommand(dir, { key: keyPath, id: "test.publisher" })).toThrow(/须含 catalog\.yaml/);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("catalog 钉了 shards/2.yaml 但文件不存在 → 抛错(钉引用与盘上内容对不上就不签)", () => {
|
|
154
|
+
writeFileSync(join(dir, "catalog.yaml"), "shards:\n \"1\": 1\n \"2\": 1\nsolutions:\n - name: demo-solution\n shards:\n - 1\n - 2\n");
|
|
155
|
+
expect(() => publishCommand(dir, { key: keyPath, id: "test.publisher" })).toThrow(/catalog 钉了 shards\/2\.yaml 但文件不存在/);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("分片 .sig 代次与 catalog 钉值不符 → index_generation_mismatch,且什么都不写", () => {
|
|
159
|
+
// 盘上 1 号片签的是第 1 代,catalog 却钉第 2 代——中间态(源正在重建 / 索引被手改)
|
|
160
|
+
writeFileSync(join(dir, "shards", "1.yaml.sig"), `${JSON.stringify(signIndex("shard", 1, recomputeIndex(SHARD1), defaultTestKey().privateKeyPem, "test.publisher"), null, 2)}\n`);
|
|
161
|
+
writeFileSync(join(dir, "catalog.yaml"), CATALOG.replace("\"1\": 1", "\"1\": 2"));
|
|
162
|
+
const catalogBefore = readFileSync(join(dir, "catalog.yaml"));
|
|
163
|
+
const shardBefore = readFileSync(join(dir, "shards", "1.yaml"));
|
|
164
|
+
|
|
165
|
+
expect(() => publishCommand(dir, { key: keyPath, id: "test.publisher" })).toThrow(/index_generation_mismatch/);
|
|
166
|
+
// fail-closed 的完整性:拒签时盘上一个字节都没动(没有半代索引落盘)
|
|
167
|
+
expect(readFileSync(join(dir, "catalog.yaml"))).toEqual(catalogBefore);
|
|
168
|
+
expect(readFileSync(join(dir, "shards", "1.yaml"))).toEqual(shardBefore);
|
|
169
|
+
expect(existsSync(join(dir, "catalog.yaml.sig"))).toBe(false);
|
|
170
|
+
expect(existsSync(join(dir, "shards", "1.yaml.sig"))).toBe(true); // 旧签名原样保留(本来就对不上,不擦除证据)
|
|
70
171
|
});
|
|
71
172
|
|
|
72
|
-
it("
|
|
73
|
-
|
|
74
|
-
expect(() => publishCommand(dir, { key: keyPath, id: "test.publisher" })).toThrow(
|
|
173
|
+
it("分片 .sig 不可解析 → 抛错(无法核对代次,拒签)", () => {
|
|
174
|
+
writeFileSync(join(dir, "shards", "1.yaml.sig"), "{ not json");
|
|
175
|
+
expect(() => publishCommand(dir, { key: keyPath, id: "test.publisher" })).toThrow(/不可解析/);
|
|
75
176
|
});
|
|
76
177
|
|
|
77
178
|
it("私钥缺失 → 抛错(fail-closed,不静默)", () => {
|
|
78
179
|
expect(() => publishCommand(dir, { key: join(dir, "nope.pem"), id: "test.publisher" })).toThrow(/私钥文件不存在/);
|
|
79
180
|
});
|
|
181
|
+
|
|
182
|
+
it("原子落盘顺序固定「先分片 → 再 catalog」(catalog 是提交点,红队 F2 半写防线)", () => {
|
|
183
|
+
// 两片夹具:1 号片未签名(首签)、2 号片已有旧签名(补签)
|
|
184
|
+
writeFileSync(join(dir, "shards", "2.yaml"), SHARD1.replace("0.1.0", "0.2.0"));
|
|
185
|
+
writeFileSync(join(dir, "catalog.yaml"), "shards:\n \"1\": 1\n \"2\": 4\nsolutions:\n - name: demo-solution\n shards:\n - 1\n - 2\n");
|
|
186
|
+
writeFileSync(join(dir, "shards", "2.yaml.sig"), `${JSON.stringify(signIndex("shard", 4, recomputeIndex(readFileSync(join(dir, "shards", "2.yaml"), "utf8")), defaultTestKey().privateKeyPem, "test.publisher"), null, 2)}\n`);
|
|
187
|
+
|
|
188
|
+
const landed: string[] = [];
|
|
189
|
+
const origRename = fs.renameSync.bind(fs);
|
|
190
|
+
const spy = vi.spyOn(fs, "renameSync").mockImplementation(((...args: Parameters<typeof fs.renameSync>) => {
|
|
191
|
+
landed.push(join(String(args[0])).split("/").pop()!.replace(/\.tmp\..*$/, "")); // 记录落盘目标名(剥掉 tmp 后缀)
|
|
192
|
+
return origRename(...args);
|
|
193
|
+
}) as typeof fs.renameSync);
|
|
194
|
+
try {
|
|
195
|
+
publishCommand(dir, { key: keyPath, id: "test.publisher" });
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
spy.mockRestore();
|
|
199
|
+
}
|
|
200
|
+
// 全部分片(正文 + 签名)落完后才落 catalog(正文 + 签名)——catalog 一变即新代生效,
|
|
201
|
+
// 它钉的片必须早已在盘上;顺序反了就会开出「catalog 钉了不存在的片」的窗口
|
|
202
|
+
expect(landed).toEqual(["1.yaml", "1.yaml.sig", "2.yaml", "2.yaml.sig", "catalog.yaml", "catalog.yaml.sig"]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("索引写入只有 writeIndexFile 一条路径(源码级不变量,grep 可证)", () => {
|
|
206
|
+
const srcOf = (rel: string) => readFileSync(fileURLToPath(new URL(rel, import.meta.url)), "utf8");
|
|
207
|
+
const countOf = (text: string, re: RegExp) => text.split("\n").filter(line => re.test(line)).length;
|
|
208
|
+
// 生成侧:publish.ts 里唯一的 fs.writeFileSync 就在 writeIndexFile 体内(tmp+rename);
|
|
209
|
+
// build-index.ts 零直写——索引文件全部经唯一写入口
|
|
210
|
+
expect(countOf(srcOf("../../publish.ts"), /fs\.writeFileSync/)).toBe(1);
|
|
211
|
+
expect(countOf(srcOf("../../publish.ts"), /appendFileSync/)).toBe(0);
|
|
212
|
+
expect(countOf(srcOf("../../market/build-index.ts"), /fs\.writeFileSync|writeFileSync\(/)).toBe(0);
|
|
213
|
+
expect(countOf(srcOf("../../market/build-index.ts"), /appendFileSync/)).toBe(0);
|
|
214
|
+
// 全仓其余直写只剩 walkthrough 的负向演练:往 catalog 追加重码标记(drill 本身),
|
|
215
|
+
// 且回抄恢复走 writeIndexFile——除这一行 deliberate tamper 外没有第二个索引直写点
|
|
216
|
+
const walkthrough = srcOf("../../market/walkthrough.ts");
|
|
217
|
+
expect(countOf(walkthrough, /appendFileSync/)).toBe(1);
|
|
218
|
+
expect(countOf(walkthrough, /# tampered/)).toBe(1);
|
|
219
|
+
expect(countOf(walkthrough, /writeIndexFile\(catalog/)).toBe(1);
|
|
220
|
+
});
|
|
80
221
|
});
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
canonicalString,
|
|
16
16
|
fingerprintOf,
|
|
17
17
|
FIXTURE_NAME,
|
|
18
|
+
FIXTURE_REGISTRY_ID,
|
|
18
19
|
FIXTURE_VERSION,
|
|
19
20
|
generate,
|
|
20
21
|
TRUSTED_KEY,
|
|
@@ -31,6 +32,7 @@ interface Sig {
|
|
|
31
32
|
public_key: string
|
|
32
33
|
value: string
|
|
33
34
|
canon: string
|
|
35
|
+
registry_id: string
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
const readSig = (suffix: string): Sig => JSON.parse(readFileSync(`${tgzPath}${suffix}`, "utf8")) as Sig;
|
|
@@ -43,20 +45,21 @@ const KEY_ID = /^[a-z][a-z0-9-]*\.[a-z0-9-]+$/;
|
|
|
43
45
|
describe("签名链夹具(P2-0)", () => {
|
|
44
46
|
const tgz = readFileSync(tgzPath);
|
|
45
47
|
const sha256 = createHash("sha256").update(tgz).digest("hex");
|
|
46
|
-
const canonical = canonicalString(FIXTURE_NAME, FIXTURE_VERSION, sha256);
|
|
48
|
+
const canonical = canonicalString(FIXTURE_REGISTRY_ID, FIXTURE_NAME, FIXTURE_VERSION, sha256);
|
|
47
49
|
const good = readSig(".sig");
|
|
48
50
|
const bad = readSig(".bad.sig");
|
|
49
51
|
const unknownSig = readSig(".unknown.sig");
|
|
50
52
|
|
|
51
|
-
it("规范串逐字 = <name>@<version>:<sha256
|
|
52
|
-
expect(canonical).toBe(`${FIXTURE_NAME}@${FIXTURE_VERSION}:${sha256}`);
|
|
53
|
-
expect(canonical).toMatch(/^demo-solution@0\.2\.0:[0-9a-f]{64}$/);
|
|
53
|
+
it("规范串逐字 = <registry_id>:<name>@<version>:<sha256>(REG-CS3 v2 格式表)", () => {
|
|
54
|
+
expect(canonical).toBe(`${FIXTURE_REGISTRY_ID}:${FIXTURE_NAME}@${FIXTURE_VERSION}:${sha256}`);
|
|
55
|
+
expect(canonical).toMatch(/^test\.registry:demo-solution@0\.2\.0:[0-9a-f]{64}$/);
|
|
54
56
|
});
|
|
55
57
|
|
|
56
|
-
it(".sig
|
|
58
|
+
it(".sig 字段齐备且形态合法(alg 恒 Ed25519 / canon 恒 v2 / registry_id 存在)", () => {
|
|
57
59
|
for (const sig of [good, bad, unknownSig]) {
|
|
58
60
|
expect(sig.alg).toBe("Ed25519");
|
|
59
61
|
expect(sig.canon).toBe(CANON);
|
|
62
|
+
expect(sig.registry_id).toBe(FIXTURE_REGISTRY_ID);
|
|
60
63
|
expect(sig.public_key).toMatch(HEX64);
|
|
61
64
|
expect(sig.value).toMatch(STD_B64);
|
|
62
65
|
expect(sig.value).not.toMatch(/[-_]/); // 禁 base64url(§2.11 编码约束)
|
|
@@ -77,7 +80,7 @@ describe("签名链夹具(P2-0)", () => {
|
|
|
77
80
|
const tampered = Buffer.from(tgz);
|
|
78
81
|
tampered[100] ^= 0xFF;
|
|
79
82
|
const sha2 = createHash("sha256").update(tampered).digest("hex");
|
|
80
|
-
expect(verifyCanonicalB64(good.public_key, canonicalString(FIXTURE_NAME, FIXTURE_VERSION, sha2), good.value)).toBe(false);
|
|
83
|
+
expect(verifyCanonicalB64(good.public_key, canonicalString(FIXTURE_REGISTRY_ID, FIXTURE_NAME, FIXTURE_VERSION, sha2), good.value)).toBe(false);
|
|
81
84
|
});
|
|
82
85
|
|
|
83
86
|
it("未知 key 夹具是真实签名但 key_id 不在信任库(TOFU 场景锚点)", () => {
|
|
@@ -7,25 +7,26 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
import { describe, expect, it } from "vitest";
|
|
8
8
|
import { generateKeyPair } from "../keygen";
|
|
9
9
|
import { CANON, canonicalString, exportRawPublicKeyHex, fingerprintOfPublicKey, SIG_ALG, signCanonical, verifyCanonical } from "../sign";
|
|
10
|
-
import { FIXTURE_NAME, FIXTURE_VERSION, TRUSTED_KEY, UNKNOWN_KEY } from "./fixtures/sig/gen-signed-fixtures.mjs";
|
|
10
|
+
import { FIXTURE_NAME, FIXTURE_REGISTRY_ID, FIXTURE_VERSION, TRUSTED_KEY, UNKNOWN_KEY } from "./fixtures/sig/gen-signed-fixtures.mjs";
|
|
11
11
|
import { defaultTestKey, signedArchiveFor } from "./helpers";
|
|
12
12
|
|
|
13
13
|
const SHA_A = "a".repeat(64);
|
|
14
|
+
const REGISTRY = "test.registry";
|
|
14
15
|
|
|
15
|
-
describe("规范串(C2-34)", () => {
|
|
16
|
-
it("拼接式逐字 <name>@<version>:<sha256
|
|
17
|
-
expect(canonicalString("demo-solution", "0.2.0", SHA_A)).toBe(`demo-solution@0.2.0:${SHA_A}`);
|
|
16
|
+
describe("规范串(C2-34 / REG-CS3 v2)", () => {
|
|
17
|
+
it("拼接式逐字 <registry_id>:<name>@<version>:<sha256>(REG-CS3 v2 格式表)", () => {
|
|
18
|
+
expect(canonicalString(REGISTRY, "demo-solution", "0.2.0", SHA_A)).toBe(`test.registry:demo-solution@0.2.0:${SHA_A}`);
|
|
18
19
|
});
|
|
19
20
|
|
|
20
21
|
it("sha256 形态非法不拦截(规范化属调用方结构校验序,P2-3)", () => {
|
|
21
22
|
// 纯函数只负责拼接;sha256 合法性由 verify 段比对时保证(C2-29 fail-closed)
|
|
22
|
-
expect(canonicalString("a", "1", "short")).toBe("a@1:short");
|
|
23
|
+
expect(canonicalString(REGISTRY, "a", "1", "short")).toBe("test.registry:a@1:short");
|
|
23
24
|
});
|
|
24
25
|
});
|
|
25
26
|
|
|
26
27
|
describe("签验往返与对拍", () => {
|
|
27
28
|
const key = generateKeyPair();
|
|
28
|
-
const canonical = canonicalString("demo-solution", "0.1.0", SHA_A);
|
|
29
|
+
const canonical = canonicalString(REGISTRY, "demo-solution", "0.1.0", SHA_A);
|
|
29
30
|
|
|
30
31
|
it("sign → verifyCanonical 往返 true;篡改 canonical 一字符 → false", () => {
|
|
31
32
|
const sig = signCanonical(key.privateKeyPem, canonical);
|
|
@@ -59,7 +60,7 @@ describe("签验往返与对拍", () => {
|
|
|
59
60
|
|
|
60
61
|
describe("编码面严格拒(C2-36,禁归一)", () => {
|
|
61
62
|
const key = generateKeyPair();
|
|
62
|
-
const canonical = canonicalString("demo-solution", "0.1.0", SHA_A);
|
|
63
|
+
const canonical = canonicalString(REGISTRY, "demo-solution", "0.1.0", SHA_A);
|
|
63
64
|
const good = signCanonical(key.privateKeyPem, canonical);
|
|
64
65
|
|
|
65
66
|
it("value 含 -/_(base64url 变体)→ false", () => {
|
|
@@ -101,7 +102,7 @@ describe("p2-0 夹具对拍(双端三同一锚点)", () => {
|
|
|
101
102
|
const SIG_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "sig");
|
|
102
103
|
const tgz = readFileSync(path.join(SIG_DIR, `${FIXTURE_NAME}-${FIXTURE_VERSION}.tgz`));
|
|
103
104
|
const sha256 = createHash("sha256").update(tgz).digest("hex");
|
|
104
|
-
const canonical = canonicalString(FIXTURE_NAME, FIXTURE_VERSION, sha256);
|
|
105
|
+
const canonical = canonicalString(FIXTURE_REGISTRY_ID, FIXTURE_NAME, FIXTURE_VERSION, sha256);
|
|
105
106
|
const readSig = (suffix: string) => JSON.parse(readFileSync(path.join(SIG_DIR, `${FIXTURE_NAME}-${FIXTURE_VERSION}.tgz${suffix}`), "utf8")) as { key_id: string, public_key: string, value: string, canon: string, alg: string };
|
|
106
107
|
|
|
107
108
|
it("已签/篡改/未知 key 三态经 sign.ts 验签结果与生成器一致", () => {
|
|
@@ -52,7 +52,7 @@ describe.skipIf(!smokeReady)("真机冒烟(需 OJM_SMOKE_URL/OJM_SMOKE_USER/OJ
|
|
|
52
52
|
const [built] = await buildBundle(project, { out: join(project, "dist") });
|
|
53
53
|
const { tgz } = built!;
|
|
54
54
|
|
|
55
|
-
const record = await installCommand(tgz, {
|
|
55
|
+
const record = await installCommand(tgz, { siteUrl: SMOKE_URL ?? DEFAULT_SITE_URL, user: SMOKE_USER, pass: SMOKE_PASS });
|
|
56
56
|
expect(record.status).toBe("done");
|
|
57
57
|
expect(record.name).toBe("demo-solution");
|
|
58
58
|
expect(record.version).toBe("0.1.0");
|
|
@@ -44,7 +44,7 @@ describe("uninstallCommand · 卸载瘦客户端(P1-15a 缺口 B)", () => {
|
|
|
44
44
|
"/api/auth/login": () => ok({ access_token: "tok", refresh_token: "r", expires_in: 1 }),
|
|
45
45
|
"/api/_platform/market/uninstall": () => ok({ ok: true, message: "卸载完成:四处同删(demo-solution@0.1.0)" }),
|
|
46
46
|
});
|
|
47
|
-
await uninstallCommand("demo-solution", {
|
|
47
|
+
await uninstallCommand("demo-solution", { siteUrl: "http://x", user: "u", pass: "p", fetchFn });
|
|
48
48
|
// 登录体 + uninstall JSON 体 + Bearer
|
|
49
49
|
expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ username: "u", password: "p" });
|
|
50
50
|
const call = calls[1]!;
|
|
@@ -63,7 +63,7 @@ describe("uninstallCommand · 卸载瘦客户端(P1-15a 缺口 B)", () => {
|
|
|
63
63
|
const exitBackup = process.exitCode;
|
|
64
64
|
process.exitCode = 0;
|
|
65
65
|
try {
|
|
66
|
-
await uninstallCommand("x", {
|
|
66
|
+
await uninstallCommand("x", { siteUrl: "http://x", user: "u", pass: "p", fetchFn });
|
|
67
67
|
expect(process.exitCode).toBe(1);
|
|
68
68
|
}
|
|
69
69
|
finally {
|
|
@@ -74,7 +74,7 @@ describe("uninstallCommand · 卸载瘦客户端(P1-15a 缺口 B)", () => {
|
|
|
74
74
|
|
|
75
75
|
it("空名 → 人话用法报错(不发网络)", async () => {
|
|
76
76
|
const { fetchFn, calls } = fetchStub({});
|
|
77
|
-
await expect(uninstallCommand("", {
|
|
77
|
+
await expect(uninstallCommand("", { siteUrl: "http://x", user: "u", pass: "p", fetchFn })).rejects.toThrow(/用法/);
|
|
78
78
|
expect(calls).toHaveLength(0);
|
|
79
79
|
});
|
|
80
80
|
|
|
@@ -82,6 +82,6 @@ describe("uninstallCommand · 卸载瘦客户端(P1-15a 缺口 B)", () => {
|
|
|
82
82
|
const { fetchFn } = fetchStub({
|
|
83
83
|
"/api/auth/login": () => ({ ok: true, status: 200, text: async () => JSON.stringify({ code: 403, msg: "forbidden" }) }),
|
|
84
84
|
});
|
|
85
|
-
await expect(uninstallCommand("x", {
|
|
85
|
+
await expect(uninstallCommand("x", { siteUrl: "http://x", user: "u", pass: "p", fetchFn })).rejects.toThrow(/forbidden/);
|
|
86
86
|
});
|
|
87
87
|
});
|
package/src/lifecycle/sign.ts
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
import { Buffer } from "node:buffer";
|
|
7
7
|
import { createHash, createPrivateKey, createPublicKey, sign as nodeSign, verify as nodeVerify } from "node:crypto";
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
export const CANON = "
|
|
9
|
+
/** 规范串版本标记(REG-CS3 v2:canon 字段恒 "v2";v1 一律按 canon_stale 拒) */
|
|
10
|
+
export const CANON = "v2";
|
|
11
11
|
/** 签名算法标识(大小写敏感,契约 §2.11 / C2-35) */
|
|
12
12
|
export const SIG_ALG = "Ed25519";
|
|
13
13
|
|
|
@@ -18,12 +18,13 @@ const STD_B64 = /^[A-Z0-9+/]+={0,2}$/i;
|
|
|
18
18
|
const SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* 规范串(
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* 规范串(REG-CS3 v2,拼接式逐字):`<registry_id>:<slug>@<version>:<sha256>`。
|
|
22
|
+
* registry_id = 签发方 registry 身份(防跨仓库重放:A 签的包不能被 B 的 CDN 安装);
|
|
23
|
+
* slug/version 取 bundle manifest 值,sha256 = tgz 字节 sha256(hex 小写);
|
|
24
|
+
* 禁止自造分隔符/归一化/大小写变换——CLI 签发与站点验签两端逐字一致的前提。
|
|
24
25
|
*/
|
|
25
|
-
export function canonicalString(
|
|
26
|
-
return `${
|
|
26
|
+
export function canonicalString(registryId: string, slug: string, version: string, sha256: string): string {
|
|
27
|
+
return `${registryId}:${slug}@${version}:${sha256}`;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
/** 指纹 = sha256Hex(publicKey) 全 64 hex(契约 §2.11,TOFU 展示用) */
|
package/src/list.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// `ojm list`(站点已装方案的可查面):登录 → GET /api/_platform/market/index → 只挑已装的打表。
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要它:到 0.3.2 为止,CLI **没有任何一条命令能回答「这个站点上装了什么」**——
|
|
4
|
+
// 装完只有一条终态文案,之后要核对版本/启用态得登站点翻市场页。运维与 CI 都需要一个
|
|
5
|
+
// 可脚本化的清单(升级前看谁落后、禁用后确认状态、卸载前确认名字)。
|
|
6
|
+
//
|
|
7
|
+
// 为什么复用 `GET /_platform/market/index` 而不是新增端点:这一条已经把三件事聚合好了——
|
|
8
|
+
// sidecar 里的 installed/enabled、索引里的 latest/available(算 upgradable)、信任库判定
|
|
9
|
+
// 出的 trust_state。新增一个「只列已装」的端点等于让两个真相源各自漂移,而站点侧那边
|
|
10
|
+
// 本来就要维护全量行(市场页在用)。
|
|
11
|
+
//
|
|
12
|
+
// 缺省只列**已装**(installed != null):`ojm list` 的语义是「我这台机器上有什么」,
|
|
13
|
+
// 不是「市场里有什么」;要后者用 `--all`(未装的行 installed 显示 `-`)。
|
|
14
|
+
import { emit } from "./cli-output";
|
|
15
|
+
import { UsageError } from "./exit-codes";
|
|
16
|
+
import { callApi, login, resolveSite } from "./site";
|
|
17
|
+
|
|
18
|
+
/** 市场索引行里本命令消费的字段(其余字段站点可能扩,不读即不依赖) */
|
|
19
|
+
interface MarketIndexRow {
|
|
20
|
+
name?: unknown
|
|
21
|
+
installed?: unknown
|
|
22
|
+
enabled?: unknown
|
|
23
|
+
latest?: unknown
|
|
24
|
+
upgradable?: unknown
|
|
25
|
+
trust_state?: unknown
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type TrustState = "trusted" | "untrusted" | "absent" | null;
|
|
29
|
+
|
|
30
|
+
export interface SolutionRow {
|
|
31
|
+
/** 物理名(= 磁盘目录/路由前缀/包名,站点侧 identity) */
|
|
32
|
+
name: string
|
|
33
|
+
/** 已装版本;null = 未装 */
|
|
34
|
+
installed: string | null
|
|
35
|
+
/** 启用态;null = 未装或站点未给 */
|
|
36
|
+
enabled: boolean | null
|
|
37
|
+
/** 索引里的最新版;null = 该方案只在本地存在(源自建包) */
|
|
38
|
+
latest: string | null
|
|
39
|
+
/** 已装且落后于最新版 */
|
|
40
|
+
upgradable: boolean
|
|
41
|
+
/** 站点信任库对已装包签名 key 的判定 */
|
|
42
|
+
trustState: TrustState
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ListOptions {
|
|
46
|
+
siteUrl?: string
|
|
47
|
+
user?: string
|
|
48
|
+
pass?: string
|
|
49
|
+
/** 连未安装的索引行一起列(缺省只列已装) */
|
|
50
|
+
all?: boolean
|
|
51
|
+
json?: boolean
|
|
52
|
+
/** 测试注入;缺省 globalThis.fetch */
|
|
53
|
+
fetchFn?: typeof fetch
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function asTrustState(raw: unknown): TrustState {
|
|
57
|
+
return raw === "trusted" || raw === "untrusted" || raw === "absent" ? raw : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 取一份已装清单(`--all` 时含未装)。不打印、不 exit 的纯函数层,便于 CI 直接在进程内用。 */
|
|
61
|
+
export async function fetchInstalled(fetchFn: typeof fetch, base: string, token: string, all: boolean): Promise<SolutionRow[]> {
|
|
62
|
+
const rows = await callApi<MarketIndexRow[]>(fetchFn, base, token, "GET", "/api/_platform/market/index");
|
|
63
|
+
const out: SolutionRow[] = [];
|
|
64
|
+
for (const raw of rows) {
|
|
65
|
+
const name = String(raw.name ?? "").trim();
|
|
66
|
+
if (name === "")
|
|
67
|
+
continue;
|
|
68
|
+
const installed = typeof raw.installed === "string" && raw.installed !== "" ? raw.installed : null;
|
|
69
|
+
if (installed === null && !all)
|
|
70
|
+
continue;
|
|
71
|
+
out.push({
|
|
72
|
+
name,
|
|
73
|
+
installed,
|
|
74
|
+
enabled: typeof raw.enabled === "boolean" ? raw.enabled : null,
|
|
75
|
+
latest: typeof raw.latest === "string" && raw.latest !== "" ? raw.latest : null,
|
|
76
|
+
upgradable: raw.upgradable === true,
|
|
77
|
+
trustState: asTrustState(raw.trust_state),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 按单元格宽度补齐(ASCII slug + 数字,不做 CJK 宽度换算) */
|
|
84
|
+
function pad(text: string, width: number): string {
|
|
85
|
+
return text.length >= width ? text : text + " ".repeat(width - text.length);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function renderTable(rows: SolutionRow[]): string[] {
|
|
89
|
+
const header = ["NAME", "INSTALLED", "ENABLED", "LATEST", "UPGRADABLE", "TRUST"];
|
|
90
|
+
const cells = rows.map(r => [
|
|
91
|
+
r.name,
|
|
92
|
+
r.installed ?? "-",
|
|
93
|
+
r.enabled === null ? "-" : r.enabled ? "yes" : "no",
|
|
94
|
+
r.latest ?? "-",
|
|
95
|
+
r.installed === null ? "-" : r.upgradable ? "yes" : "no",
|
|
96
|
+
r.trustState ?? "-",
|
|
97
|
+
]);
|
|
98
|
+
const widths = header.map((h, i) => Math.max(h.length, ...cells.map(c => c[i]!.length)));
|
|
99
|
+
const line = (cols: string[]): string => cols.map((c, i) => pad(c, widths[i]!)).join(" ").trimEnd();
|
|
100
|
+
return [line(header), ...cells.map(line)];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 列表命令体:登录 → 取市场索引 → 过滤 → 打印(`--json` 走统一信封)。
|
|
105
|
+
* 返回行数组(测试/CI 断言用)。
|
|
106
|
+
*/
|
|
107
|
+
export async function listCommand(opts: ListOptions = {}): Promise<SolutionRow[]> {
|
|
108
|
+
let site: { base: string, user: string, pass: string };
|
|
109
|
+
try {
|
|
110
|
+
site = resolveSite(opts);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
// 缺凭据是用法错误(2):补参数就能解决,不该和「站点报错」混成 1
|
|
114
|
+
throw new UsageError((error as Error).message);
|
|
115
|
+
}
|
|
116
|
+
const fetchFn = opts.fetchFn ?? globalThis.fetch;
|
|
117
|
+
const token = await login(fetchFn, site.base, site.user, site.pass);
|
|
118
|
+
const solutions = await fetchInstalled(fetchFn, site.base, token, opts.all === true);
|
|
119
|
+
|
|
120
|
+
const human: string[] = [];
|
|
121
|
+
if (solutions.length === 0) {
|
|
122
|
+
human.push(opts.all === true
|
|
123
|
+
? `[ojm] 站点 ${site.base} 的市场索引是空的(既没有已装方案,也没有可装方案)`
|
|
124
|
+
: `[ojm] 站点 ${site.base} 上还没有已装方案(要连可装的一起看:ojm list --all)`);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
const installed = solutions.filter(r => r.installed !== null).length;
|
|
128
|
+
human.push(opts.all === true
|
|
129
|
+
? `[ojm] 站点 ${site.base}:${solutions.length} 个方案(已装 ${installed} 个)`
|
|
130
|
+
: `[ojm] 站点 ${site.base}:已装 ${installed} 个方案`);
|
|
131
|
+
human.push(...renderTable(solutions));
|
|
132
|
+
const upgradable = solutions.filter(r => r.upgradable).map(r => r.name);
|
|
133
|
+
if (upgradable.length > 0)
|
|
134
|
+
human.push(`[ojm] 可升级:${upgradable.join(", ")}(ojm install <name> 装最新版)`);
|
|
135
|
+
const untrusted = solutions.filter(r => r.trustState === "untrusted").map(r => r.name);
|
|
136
|
+
if (untrusted.length > 0)
|
|
137
|
+
human.push(`[ojm] 签名不受信:${untrusted.join(", ")}(先 ojm trust add 确认发布方公钥)`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
emit({
|
|
141
|
+
command: "list",
|
|
142
|
+
data: { site: site.base, all: opts.all === true, solutions },
|
|
143
|
+
human,
|
|
144
|
+
}, { json: !!opts.json });
|
|
145
|
+
return solutions;
|
|
146
|
+
}
|