@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.
Files changed (115) 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/runtime.js +2 -2
  5. package/shell-dist/index.html +2 -2
  6. package/shell-dist/versions.json +3 -3
  7. package/src/__tests__/cli.test.ts +23 -14
  8. package/src/__tests__/list.test.ts +176 -0
  9. package/src/__tests__/registry-cli.test.ts +522 -0
  10. package/src/__tests__/registry-export.test.ts +319 -0
  11. package/src/__tests__/registry-import.test.ts +336 -0
  12. package/src/__tests__/registry-sign.test.ts +352 -0
  13. package/src/cli-output.ts +81 -0
  14. package/src/cli.ts +298 -39
  15. package/src/config-cmd.ts +100 -0
  16. package/src/contract/__tests__/handler-scan.test.ts +74 -2
  17. package/src/contract/handler-scan.ts +27 -2
  18. package/src/enable.ts +1 -1
  19. package/src/exit-codes.ts +33 -0
  20. package/src/index.ts +5 -9
  21. package/src/install.ts +120 -38
  22. package/src/lifecycle/__tests__/authoring.test.ts +6 -5
  23. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.bad.sig +3 -2
  24. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.sig +3 -2
  25. package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.unknown.sig +3 -2
  26. package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.d.mts +5 -4
  27. package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.mjs +9 -6
  28. package/src/lifecycle/__tests__/helpers.ts +10 -6
  29. package/src/lifecycle/__tests__/install.test.ts +72 -15
  30. package/src/lifecycle/__tests__/manifest-contract.test.ts +40 -1
  31. package/src/lifecycle/__tests__/publish.test.ts +174 -33
  32. package/src/lifecycle/__tests__/sig-fixtures.test.ts +9 -6
  33. package/src/lifecycle/__tests__/sign.test.ts +9 -8
  34. package/src/lifecycle/__tests__/smoke.test.ts +1 -1
  35. package/src/lifecycle/__tests__/uninstall.test.ts +4 -4
  36. package/src/lifecycle/sign.ts +8 -7
  37. package/src/list.ts +146 -0
  38. package/src/manifest-contract.ts +11 -5
  39. package/src/market/__tests__/bootstrap.test.ts +4 -1
  40. package/src/market/__tests__/build-index.test.ts +529 -42
  41. package/src/market/__tests__/fixture.test.ts +17 -1
  42. package/src/market/__tests__/publish.test.ts +8 -7
  43. package/src/market/__tests__/setup.test.ts +4 -4
  44. package/src/market/__tests__/sharding.test.ts +90 -0
  45. package/src/market/__tests__/verify.test.ts +237 -13
  46. package/src/market/__tests__/yank-ledger.test.ts +60 -0
  47. package/src/market/bootstrap.ts +8 -3
  48. package/src/market/build-index.ts +407 -40
  49. package/src/market/cdn-layout.ts +3 -2
  50. package/src/market/command.ts +40 -6
  51. package/src/market/failure-playbook.ts +1 -1
  52. package/src/market/fixture.ts +20 -3
  53. package/src/market/index-builder.ts +120 -47
  54. package/src/market/publish.ts +3 -1
  55. package/src/market/setup.ts +3 -3
  56. package/src/market/sharding.ts +94 -0
  57. package/src/market/trial.ts +1 -1
  58. package/src/market/verify.ts +220 -53
  59. package/src/market/walkthrough.ts +7 -6
  60. package/src/market/yank-ledger.ts +91 -0
  61. package/src/publish.ts +250 -38
  62. package/src/registry-export.ts +405 -0
  63. package/src/registry-import.ts +410 -0
  64. package/src/registry-sidecar.ts +84 -0
  65. package/src/registry-sign.ts +322 -0
  66. package/src/registry-source.ts +192 -0
  67. package/src/sign.ts +13 -10
  68. package/src/site.ts +4 -4
  69. package/src/trust.ts +1 -1
  70. package/src/uninstall.ts +14 -2
  71. package/src/usage.ts +130 -25
  72. package/src/view.ts +94 -0
  73. package/templates/cdn/README.md +35 -5
  74. package/templates/config.yaml +46 -96
  75. package/templates/init.include.yaml +1 -1
  76. package/templates/src/_platform/api/_lifecycle/atomic.ts +3 -0
  77. package/templates/src/_platform/api/_lifecycle/installer.ts +44 -7
  78. package/templates/src/_platform/api/_lifecycle/limits.ts +21 -0
  79. package/templates/src/_platform/api/_lifecycle/source-seq.ts +88 -0
  80. package/templates/src/_platform/api/_lifecycle/steps/download.ts +15 -0
  81. package/templates/src/_platform/api/_lifecycle/steps/extract.ts +61 -2
  82. package/templates/src/_platform/api/_lifecycle/steps/preflight.ts +55 -6
  83. package/templates/src/_platform/api/_lifecycle/steps/register.ts +2 -1
  84. package/templates/src/_platform/api/_lifecycle/steps/verify.ts +22 -8
  85. package/templates/src/_platform/api/_lifecycle/tgz-guard.ts +96 -0
  86. package/templates/src/_platform/api/_lifecycle/types.ts +6 -3
  87. package/templates/src/_platform/api/_lifecycle/verify-checks.ts +8 -0
  88. package/templates/src/_platform/api/_shared/service/domain/solution-perms.ts +9 -1
  89. package/templates/src/_platform/api/_shared/site-facade.ts +62 -11
  90. package/templates/src/_platform/api/manifest.yaml +1 -0
  91. package/templates/src/_platform/api/market/api.ts +362 -99
  92. package/templates/src/_platform/api/market/index-source.ts +112 -0
  93. package/templates/src/_platform/api/migrations/0014__reg_source_seq.mysql.sql +9 -0
  94. package/templates/src/_platform/api/migrations/0014__reg_source_seq.pg.sql +7 -0
  95. package/templates/src/_platform/api/migrations/0014__reg_source_seq.sql +8 -0
  96. package/templates/src/_platform/api/schema.yaml +8 -0
  97. package/templates/src/_platform/api/solutions/api.ts +8 -3
  98. package/templates/src/oj-market/web/__tests__/CardGrid.test.tsx +13 -0
  99. package/templates/src/oj-market/web/__tests__/InstallDialog.test.tsx +38 -0
  100. package/templates/src/oj-market/web/components/CardItem.tsx +10 -0
  101. package/templates/src/oj-market/web/components/InstallDialog.tsx +18 -0
  102. package/templates/src/oj-market/web/locales/en-US.json +1 -0
  103. package/templates/src/oj-market/web/locales/zh-CN.json +1 -0
  104. package/templates/src/oj-market/web/types.ts +3 -1
  105. package/templates/tests/lifecycle/installer.spec.ts +24 -0
  106. package/templates/tests/lifecycle/market-api.spec.ts +641 -85
  107. package/templates/tests/lifecycle/preflight.spec.ts +52 -0
  108. package/templates/tests/lifecycle/sig-fixtures.spec.ts +7 -4
  109. package/templates/tests/lifecycle/source-seq.spec.ts +55 -0
  110. package/templates/tests/lifecycle/steps-fs.spec.ts +166 -7
  111. package/templates/tests/lifecycle/tgz-guard.spec.ts +133 -1
  112. package/templates/tests/lifecycle/verify-checks.spec.ts +27 -13
  113. package/templates/tests/lifecycle/verify-report.spec.ts +3 -1
  114. package/templates/tests/rbac-solution-perms.spec.ts +41 -0
  115. package/vendor/host-versions.json +3 -3
@@ -0,0 +1,352 @@
1
+ // BDD:`ojm registry sign`(REG-R7 外置签名,CI 侧)。
2
+ //
3
+ // 这条命令是「私钥不进服务端」的另一半:registry 只渲染并导出未签索引,签名发生在离线侧。
4
+ // 用例盯三件事:
5
+ // ① 产出的 signatures 能被**真验签**(不是「字段齐了就算过」)——规范串与 sha256 都由命令自己拼;
6
+ // ② 三条 fail-closed 各自有码:公钥不符 / 正文被改 / 参数缺去处;
7
+ // ③ CLI 接线:`--json` 下 stdout 只有一行信封,业务失败 exit 1 且信封 code 是稳定机器码。
8
+ import type { KeyPair } from "../lifecycle/keygen";
9
+ import { Buffer } from "node:buffer";
10
+ import { createHash } from "node:crypto";
11
+ import fs from "node:fs";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
15
+ import { buildCli, localizeCacError, reportCliError } from "../cli";
16
+ import { generateKeyPair } from "../lifecycle/keygen";
17
+ import { fingerprintOfPublicKey, verifyCanonical } from "../lifecycle/sign";
18
+ import { indexCanonical, revocationCanonical } from "../publish";
19
+ import { registryRevocationSignCommand, registrySignCommand, RegistrySignError } from "../registry-sign";
20
+
21
+ const CATALOG = `seq: 3
22
+ issued_at: 1700000000
23
+ expires_at: 1700604800
24
+ shards:
25
+ "1": 1
26
+ solutions:
27
+ - name: demo-solution
28
+ shards:
29
+ - 1
30
+ `;
31
+ const SHARD1 = `solutions:
32
+ - name: demo-solution
33
+ latest: 0.1.0
34
+ versions:
35
+ - name: demo-solution
36
+ version: 0.1.0
37
+ `;
38
+ const SHARD2 = SHARD1.replace("0.1.0", "0.1.1");
39
+
40
+ let tmpRoot: string;
41
+ let cwd: string;
42
+
43
+ beforeEach(() => {
44
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ojm-registry-sign-"));
45
+ cwd = fs.mkdtempSync(path.join(tmpRoot, "proj-"));
46
+ });
47
+
48
+ afterEach(() => {
49
+ vi.restoreAllMocks();
50
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
51
+ });
52
+
53
+ function sha256(text: string): string {
54
+ return createHash("sha256").update(Buffer.from(text, "utf8")).digest("hex");
55
+ }
56
+
57
+ interface ExportFile { kind: "catalog" | "shard", no: number, text: string, seq?: number }
58
+
59
+ /** 造一份 `POST /index/unsigned` 的响应体(形状与 registry 的 UNSIGNED_INDEX_SCHEMA 一致) */
60
+ function exportBody(opts: { gen: number, keys: Array<{ keyId: string, key: KeyPair }>, files?: ExportFile[] }): string {
61
+ const files = opts.files ?? [
62
+ { kind: "catalog" as const, no: 0, text: CATALOG, seq: opts.gen },
63
+ { kind: "shard" as const, no: 1, text: SHARD1 },
64
+ { kind: "shard" as const, no: 2, text: SHARD2 },
65
+ ];
66
+ const pending = files.map(f => ({ kind: f.kind, no: f.no, seq: f.seq ?? opts.gen, sha256: sha256(f.text), text: f.text, needsSignature: true, signature: null }));
67
+ return JSON.stringify({
68
+ gen: opts.gen,
69
+ state: "pending",
70
+ issuedAt: 1700000000,
71
+ expiresAt: 1700604800,
72
+ pending,
73
+ files: pending,
74
+ keys: opts.keys.map(k => ({ keyId: k.keyId, fingerprint: fingerprintOfPublicKey(k.key.publicKeyHex) })),
75
+ });
76
+ }
77
+
78
+ function writeExport(body: string): string {
79
+ const file = path.join(cwd, "unsigned.json");
80
+ fs.writeFileSync(file, body);
81
+ return file;
82
+ }
83
+
84
+ function keyFile(key: KeyPair, name = "key.pem"): string {
85
+ const file = path.join(cwd, name);
86
+ fs.writeFileSync(file, key.privateKeyPem, { mode: 0o600 });
87
+ return file;
88
+ }
89
+
90
+ describe("registry sign:产出可直接回传的六字段签名", () => {
91
+ it("scenario: 正常路径 → 每个待签文件一条签名,真验签通过,回传体落盘", () => {
92
+ const key = generateKeyPair();
93
+ const file = writeExport(exportBody({ gen: 3, keys: [{ keyId: "registry.index", key }] }));
94
+
95
+ const result = registrySignCommand({
96
+ file,
97
+ id: "registry.index",
98
+ key: keyFile(key),
99
+ out: "signatures.json",
100
+ cwd,
101
+ });
102
+
103
+ expect(result.gen).toBe(3);
104
+ expect(result.signed).toBe(3);
105
+ expect(result.keyId).toBe("registry.index");
106
+ expect(result.fingerprint).toBe(fingerprintOfPublicKey(key.publicKeyHex));
107
+ expect(result.signatures.map(s => `${s.kind}:${s.no}`)).toEqual(["catalog:0", "shard:1", "shard:2"]);
108
+ for (const sig of result.signatures) {
109
+ expect(sig).toMatchObject({ alg: "Ed25519", key_id: "registry.index", public_key: key.publicKeyHex, canon: "v2", seq: 3 });
110
+ const text = sig.no === 0 ? CATALOG : sig.no === 1 ? SHARD1 : SHARD2;
111
+ expect(verifyCanonical(key.publicKeyHex, indexCanonical(sig.kind, sig.seq, text), sig.value), `${sig.kind}:${sig.no} 必须真验得过`).toBe(true);
112
+ }
113
+
114
+ // 落盘的就是回传请求体:`{ gen, signatures }`,可直接 `-d @`
115
+ const body = JSON.parse(fs.readFileSync(result.outPath!, "utf8")) as { gen: number, signatures: Array<Record<string, unknown>> };
116
+ expect(body.gen).toBe(3);
117
+ expect(body.signatures).toEqual(result.signatures);
118
+ });
119
+
120
+ it("scenario: 沿用片按导出给的旧代次签(seq < gen),不会自作主张签成新代次", () => {
121
+ const key = generateKeyPair();
122
+ const file = writeExport(exportBody({
123
+ gen: 4,
124
+ keys: [{ keyId: "registry.index", key }],
125
+ files: [
126
+ { kind: "catalog", no: 0, text: CATALOG, seq: 4 },
127
+ { kind: "shard", no: 1, text: SHARD1, seq: 2 },
128
+ ],
129
+ }));
130
+ const result = registrySignCommand({ file, id: "registry.index", key: keyFile(key), out: "s.json", cwd });
131
+ expect(result.signatures.map(s => `${s.kind}:${s.no}@${s.seq}`)).toEqual(["catalog:0@4", "shard:1@2"]);
132
+ const shard = result.signatures.find(s => s.kind === "shard")!;
133
+ expect(verifyCanonical(key.publicKeyHex, indexCanonical("shard", 2, SHARD1), shard.value)).toBe(true);
134
+ });
135
+
136
+ it("scenario: 这一代没有待签文件 → 幂等成功,不碰私钥(CI 重跑不该因为没 key 变红)", () => {
137
+ const key = generateKeyPair();
138
+ const file = writeExport(exportBody({ gen: 2, keys: [{ keyId: "registry.index", key }], files: [] }));
139
+ const result = registrySignCommand({ file, id: "registry.index", key: path.join(cwd, "does-not-exist.pem"), out: "s.json", cwd });
140
+ expect(result.signed).toBe(0);
141
+ expect(result.signatures).toEqual([]);
142
+ expect(JSON.parse(fs.readFileSync(result.outPath!, "utf8"))).toEqual({ gen: 2, signatures: [] });
143
+ });
144
+ });
145
+
146
+ describe("registry sign:三条 fail-closed", () => {
147
+ it("scenario: 私钥与登记的公钥不是一对 → index_key_mismatch(上传前就挡,省一次流水线)", () => {
148
+ const registered = generateKeyPair();
149
+ const attacker = generateKeyPair();
150
+ const file = writeExport(exportBody({ gen: 1, keys: [{ keyId: "registry.index", key: registered }] }));
151
+ expect(() => registrySignCommand({ file, id: "registry.index", key: keyFile(attacker), out: "s.json", cwd }))
152
+ .toThrow(RegistrySignError);
153
+ try {
154
+ registrySignCommand({ file, id: "registry.index", key: keyFile(attacker), out: "s.json", cwd });
155
+ }
156
+ catch (error) {
157
+ expect((error as RegistrySignError).code).toBe("index_key_mismatch");
158
+ }
159
+ });
160
+
161
+ it("scenario: key_id 没在导出里登记 → index_key_mismatch", () => {
162
+ const key = generateKeyPair();
163
+ const file = writeExport(exportBody({ gen: 1, keys: [{ keyId: "registry.index", key }] }));
164
+ expect(() => registrySignCommand({ file, id: "other.index", key: keyFile(key), out: "s.json", cwd }))
165
+ .toThrowError(/没有登记 key_id「other\.index」/);
166
+ });
167
+
168
+ it("scenario: 正文被改过(与声明的 sha256 不符)→ index_export_tampered", () => {
169
+ const key = generateKeyPair();
170
+ const body = JSON.parse(exportBody({ gen: 1, keys: [{ keyId: "registry.index", key }] })) as { pending: Array<Record<string, unknown>> };
171
+ body.pending[1]!.text = `${String(body.pending[1]!.text)}#tampered`;
172
+ const file = writeExport(JSON.stringify(body));
173
+ try {
174
+ registrySignCommand({ file, id: "registry.index", key: keyFile(key), out: "s.json", cwd });
175
+ throw new Error("应当失败");
176
+ }
177
+ catch (error) {
178
+ expect(error).toBeInstanceOf(RegistrySignError);
179
+ expect((error as RegistrySignError).code).toBe("index_export_tampered");
180
+ }
181
+ });
182
+
183
+ it("scenario: 既没 --out 也没 --json → 用法错误(签名结果不能凭空消失)", () => {
184
+ const key = generateKeyPair();
185
+ const file = writeExport(exportBody({ gen: 1, keys: [{ keyId: "registry.index", key }] }));
186
+ expect(() => registrySignCommand({ file, id: "registry.index", key: keyFile(key), cwd }))
187
+ .toThrowError(/签名结果必须有去处/);
188
+ });
189
+ });
190
+
191
+ describe("registry sign:吊销清单(--revocations)", () => {
192
+ const REVOCATIONS = `seq: 7\nentries:\n - seq: 7\n keyId: publisher.key\n fingerprint: ${"0".repeat(64)}\n scope: acme\n package: demo\n version: 1.0.0\n severity: block\n reason: key compromised\n at: 1700000000\n`;
193
+
194
+ function writeRevocations(text = REVOCATIONS): string {
195
+ const file = path.join(cwd, "revocations.yaml");
196
+ fs.writeFileSync(file, text);
197
+ return file;
198
+ }
199
+
200
+ it("scenario: 正常路径 → 签的是 revocations:<seq>:<sha256(正文)>,真验签通过,回传体落盘", () => {
201
+ const key = generateKeyPair();
202
+ const file = writeRevocations();
203
+ const result = registryRevocationSignCommand({ file, revocations: true, id: "registry.index", key: keyFile(key), out: "rev-sig.json", cwd });
204
+
205
+ expect(result.seq).toBe(7);
206
+ expect(result.keyId).toBe("registry.index");
207
+ expect(result.signature).toMatchObject({
208
+ seq: 7,
209
+ alg: "Ed25519",
210
+ key_id: "registry.index",
211
+ public_key: key.publicKeyHex,
212
+ canon: revocationCanonical(7, REVOCATIONS),
213
+ });
214
+ // 规范串里带的是**正文**摘要:服务端会重算同一个串逐字比对
215
+ expect(result.signature.canon).toBe(`revocations:7:${sha256(REVOCATIONS)}`);
216
+ expect(verifyCanonical(key.publicKeyHex, result.signature.canon, result.signature.value)).toBe(true);
217
+
218
+ // 落盘的就是回传请求体六字段,可直接 `-d @`
219
+ const body = JSON.parse(fs.readFileSync(result.outPath!, "utf8")) as Record<string, unknown>;
220
+ expect(body).toEqual(result.signature);
221
+ });
222
+
223
+ it("scenario: 正文任何字节变化都换 canon(这正是「必须喂原文」的原因)", () => {
224
+ const key = generateKeyPair();
225
+ const bare = registryRevocationSignCommand({ file: writeRevocations(), revocations: true, id: "registry.index", key: keyFile(key), json: true, cwd });
226
+ const padded = registryRevocationSignCommand({ file: writeRevocations(`${REVOCATIONS}\n`), revocations: true, id: "registry.index", key: keyFile(key), json: true, cwd });
227
+ expect(padded.signature.canon).not.toBe(bare.signature.canon);
228
+ });
229
+
230
+ it("scenario: 喂了 /revocations 的 JSON 导出 → revocation_sig_format(服务端按正文重算,签了也白签)", () => {
231
+ const key = generateKeyPair();
232
+ const file = writeRevocations("{\"seq\":7,\"entries\":[]}\n");
233
+ try {
234
+ registryRevocationSignCommand({ file, revocations: true, id: "registry.index", key: keyFile(key), json: true, cwd });
235
+ throw new Error("应当失败");
236
+ }
237
+ catch (error) {
238
+ expect(error).toBeInstanceOf(RegistrySignError);
239
+ expect((error as RegistrySignError).code).toBe("revocation_sig_format");
240
+ expect((error as RegistrySignError).message).toContain("/cdn/revocations");
241
+ }
242
+ });
243
+
244
+ it("scenario: YAML 正文里没有 seq → revocation_sig_format", () => {
245
+ const key = generateKeyPair();
246
+ const file = writeRevocations("entries: []\n");
247
+ try {
248
+ registryRevocationSignCommand({ file, revocations: true, id: "registry.index", key: keyFile(key), json: true, cwd });
249
+ throw new Error("应当失败");
250
+ }
251
+ catch (error) {
252
+ expect(error).toBeInstanceOf(RegistrySignError);
253
+ expect((error as RegistrySignError).code).toBe("revocation_sig_format");
254
+ }
255
+ });
256
+
257
+ it("scenario: 空文件 → 用法错误(空清单要 seq=0 的正文,不是空文件)", () => {
258
+ const key = generateKeyPair();
259
+ expect(() => registryRevocationSignCommand({ file: writeRevocations(""), revocations: true, id: "registry.index", key: keyFile(key), json: true, cwd }))
260
+ .toThrowError(/空清单也必须签/);
261
+ });
262
+
263
+ it("scenario: 缺 --id → 用法错误(这一处没有导出体可推断 key)", () => {
264
+ const key = generateKeyPair();
265
+ expect(() => registryRevocationSignCommand({ file: writeRevocations(), revocations: true, key: keyFile(key), json: true, cwd }))
266
+ .toThrowError(/--id/);
267
+ });
268
+
269
+ it("scenario: 既没 --out 也没 --json → 用法错误(签名结果不能凭空消失)", () => {
270
+ const key = generateKeyPair();
271
+ expect(() => registryRevocationSignCommand({ file: writeRevocations(), revocations: true, id: "registry.index", key: keyFile(key), cwd }))
272
+ .toThrowError(/签名结果必须有去处/);
273
+ });
274
+
275
+ it("scenario: CLI --json → stdout 一行信封,data.kind = revocations 且带 signature", async () => {
276
+ const key = generateKeyPair();
277
+ const file = writeRevocations();
278
+ const r = await run(["registry", "sign", file, "--revocations", "--id", "registry.index", "--key", keyFile(key), "--json"]);
279
+ expect(r.exitCode).toBeUndefined();
280
+ expect(r.out).toHaveLength(1);
281
+ const envelope = JSON.parse(r.out[0]!) as { ok: boolean, command: string, data: { kind: string, seq: number, signature: { canon: string } } };
282
+ expect(envelope).toMatchObject({ ok: true, command: "registry sign" });
283
+ expect(envelope.data.kind).toBe("revocations");
284
+ expect(envelope.data.seq).toBe(7);
285
+ expect(envelope.data.signature.canon).toBe(`revocations:7:${sha256(REVOCATIONS)}`);
286
+ });
287
+ });
288
+
289
+ /* ────────────────────────────── CLI 接线(真分发) ────────────────────────────── */
290
+
291
+ interface RunResult { out: string[], err: string[], exitCode: number | undefined }
292
+
293
+ async function run(argv: string[]): Promise<RunResult> {
294
+ const out: string[] = [];
295
+ const err: string[] = [];
296
+ const outSpy = vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
297
+ out.push(args.map(String).join(" "));
298
+ });
299
+ const errSpy = vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
300
+ err.push(args.map(String).join(" "));
301
+ });
302
+ const exitSpy = vi.fn();
303
+ const exit = exitSpy as unknown as (code: number) => never;
304
+ const cli = buildCli(cwd, "zh-CN", { exit, argv });
305
+ try {
306
+ cli.parse(["node", "ojm", ...argv], { run: true });
307
+ }
308
+ catch (error) {
309
+ reportCliError(localizeCacError(error, "zh-CN"), exit, argv);
310
+ }
311
+ await vi.waitUntil(() => out.length > 0 || err.length > 0 || exitSpy.mock.calls.length > 0, { timeout: 3000, interval: 10 });
312
+ outSpy.mockRestore();
313
+ errSpy.mockRestore();
314
+ return { out, err, exitCode: exitSpy.mock.calls[0]?.[0] as number | undefined };
315
+ }
316
+
317
+ describe("registry sign:CLI 接线与信封", () => {
318
+ it("scenario: --json 成功 → stdout 只有一行信封,signatures 在 data 里", async () => {
319
+ const key = generateKeyPair();
320
+ const file = writeExport(exportBody({ gen: 1, keys: [{ keyId: "registry.index", key }] }));
321
+ const r = await run(["registry", "sign", file, "--id", "registry.index", "--key", keyFile(key), "--out", "s.json", "--json"]);
322
+ expect(r.exitCode).toBeUndefined();
323
+ expect(r.out).toHaveLength(1);
324
+ const envelope = JSON.parse(r.out[0]!) as { ok: boolean, command: string, code: string, data: { gen: number, signed: number, signatures: Array<Record<string, unknown>>, out: string } };
325
+ expect(envelope.ok).toBe(true);
326
+ expect(envelope.command).toBe("registry sign");
327
+ expect(envelope.code).toBe("ok");
328
+ expect(envelope.data.gen).toBe(1);
329
+ expect(envelope.data.signed).toBe(3);
330
+ expect(envelope.data.signatures).toHaveLength(3);
331
+ expect(envelope.data.out).toBe("s.json");
332
+ });
333
+
334
+ it("scenario: 公钥不符 → exit 1,信封 code = index_key_mismatch(CI 靠码分支,不解析人话)", async () => {
335
+ const registered = generateKeyPair();
336
+ const attacker = generateKeyPair();
337
+ const file = writeExport(exportBody({ gen: 1, keys: [{ keyId: "registry.index", key: registered }] }));
338
+ const r = await run(["registry", "sign", file, "--id", "registry.index", "--key", keyFile(attacker), "--out", "s.json", "--json"]);
339
+ expect(r.exitCode).toBe(1);
340
+ expect(r.out).toHaveLength(1);
341
+ const envelope = JSON.parse(r.out[0]!) as { ok: boolean, code: string, data: unknown };
342
+ expect(envelope).toMatchObject({ ok: false, code: "index_key_mismatch", data: null });
343
+ });
344
+
345
+ it("scenario: 未知子命令 / 裸 registry → 帮助或用法错误(拼错是 2,不是业务失败 1)", async () => {
346
+ const bare = await run(["registry"]);
347
+ expect(bare.exitCode).toBeUndefined();
348
+ expect(bare.out.join("\n")).toContain("registry sign");
349
+ const typo = await run(["registry", "sing"]);
350
+ expect(typo.exitCode).toBe(2);
351
+ });
352
+ });
@@ -0,0 +1,81 @@
1
+ // `--json` 输出契约(REG-R12):stdout 只放**一份**可解析的 JSON,人话改走 stderr。
2
+ //
3
+ // 信封字段固定(CI 靠字段分支,不解析文案):
4
+ // ok boolean 成功与否(与退出码 0/1 一一对应;用法错误=2 时 stdout 可能为空)
5
+ // command string 命令名(`publish` / `config ls`)——一条流水线里多个 ojm 调用靠它归因
6
+ // code string 稳定错误码(成功恒为 `"ok"`;失败是 `index_generation_mismatch` 这类机器码)
7
+ // message string 人话(可以改,不该被程序依赖)
8
+ // data object 命令专属载荷(成功时的结果;失败时为 null)
9
+ //
10
+ // 为什么不把「人话行」也塞进 JSON 数组:它们会随文案改动而变,混进机器通道等于让 CI 依赖文案。
11
+ // 所以 `--json` 下人类提示统一走 stderr——终端里照样看得见,管道里却不会污染 stdout。
12
+ //
13
+ // 稳定性纪律(验收「`--json` 输出稳定字段」):本文件导出的字段名即契约,
14
+ // 加字段可以、改名/删字段必须当成破坏性变更(下游有 schema 断言)。
15
+
16
+ export interface CliEnvelope<T> {
17
+ ok: boolean
18
+ command: string
19
+ code: string
20
+ message: string
21
+ data: T | null
22
+ }
23
+
24
+ export const OK_CODE = "ok";
25
+
26
+ export function okEnvelope<T>(command: string, data: T, message = OK_CODE): CliEnvelope<T> {
27
+ return { ok: true, command, code: OK_CODE, message, data };
28
+ }
29
+
30
+ export function failEnvelope(command: string, code: string, message: string): CliEnvelope<never> {
31
+ return { ok: false, command, code, message, data: null };
32
+ }
33
+
34
+ export interface EmitIo {
35
+ /** `--json`:stdout 只放信封,人话改走 stderr */
36
+ json?: boolean
37
+ }
38
+
39
+ /** 命令的一次输出计划:机器通道(信封)+ 人类通道(逐行) */
40
+ export interface EmitPlan<T> {
41
+ command: string
42
+ /** 机器码(成功 = `"ok"`) */
43
+ code?: string
44
+ message?: string
45
+ data: T | null
46
+ /** 人类可读行(不带 `[ojm]` 前缀的由调用方自己带;空数组 = 没有可读输出) */
47
+ human?: string[]
48
+ }
49
+
50
+ export interface EmitStreams {
51
+ out: (line: string) => void
52
+ err: (line: string) => void
53
+ }
54
+
55
+ const DEFAULT_STREAMS: EmitStreams = {
56
+ out: line => console.log(line),
57
+ err: line => console.error(line),
58
+ };
59
+
60
+ /**
61
+ * 按模式输出:人类通道恒发(json 下改 stderr),机器通道仅在 `--json` 下发。
62
+ * 信封**单行** JSON——多行也能解析,但单行让 `jq -r` 与逐行 grep 都省事。
63
+ */
64
+ export function emit<T>(plan: EmitPlan<T>, io: EmitIo = {}, streams: EmitStreams = DEFAULT_STREAMS): CliEnvelope<T | null> {
65
+ const code = plan.code ?? OK_CODE;
66
+ const message = plan.message ?? (code === OK_CODE ? "ok" : "");
67
+ // 失败分支的 data 恒为 null:命令没跑完,半份载荷比没有更危险
68
+ const envelope: CliEnvelope<T | null> = code === OK_CODE
69
+ ? okEnvelope<T | null>(plan.command, plan.data, message)
70
+ : failEnvelope(plan.command, code, message);
71
+ const human = plan.human ?? [];
72
+ if (io.json) {
73
+ for (const line of human)
74
+ streams.err(line);
75
+ streams.out(JSON.stringify(envelope));
76
+ return envelope;
77
+ }
78
+ for (const line of human)
79
+ streams.out(line);
80
+ return envelope;
81
+ }