@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,322 @@
1
+ // ojm registry sign(REG-R7 外置签名):把 registry 导出的未签索引签成**可直接回传**的请求体。
2
+ //
3
+ // 为什么这条命令必须存在:签名私钥**不在服务端**(S4-3 的结构性约束——registry 的运行时
4
+ // 只有 verifyEd25519)。所以「渲染」与「签名」被切成两半:服务端渲染并导出(POST /index/unsigned),
5
+ // CI/运维在离线侧签名(本命令),再回传(POST /index/signature)。
6
+ //
7
+ // 输入就是导出响应体(`gen` + `pending[]` + `keys[]`),输出就是回传请求体(`gen` + `signatures[]`):
8
+ // 两端形状由命令本身守住,CI 里不必再写胶水去拼六字段,也不会把 `seq` 拼错(拼错 = 服务端
9
+ // `index_generation_mismatch`,一代索引白签)。
10
+ //
11
+ // 三条 fail-closed(都在**上传之前**把错误挡住):
12
+ // ① 导出里没有这把公钥(key_id 缺席或指纹不符)→ `index_key_mismatch`:
13
+ // 私钥与登记的公钥不是一对,回传必被拒,早报比晚报省一次流水线;
14
+ // ② 正文与导出声明的 sha256 不符 → `index_export_tampered`:导出文件被改过(或版本错配),
15
+ // 签出来的东西验不过;
16
+ // ③ 签完立刻本地验一遍 → `index_sig_mismatch`:私钥/公钥推导口径漂移(比如 PEM 不是 Ed25519)
17
+ // 当场暴露,而不是等到回传被服务端打回。
18
+ //
19
+ // 指纹口径:sha256(公钥 **32 字节**),与 `ojm keygen`、站点信任库、registry 的 reg_key.fingerprint
20
+ // 同源。按 hex 文本算会让同一把钥匙在三处显示三个值。
21
+ import { Buffer } from "node:buffer";
22
+ import { createHash } from "node:crypto";
23
+ import fs from "node:fs";
24
+ import os from "node:os";
25
+ import path from "node:path";
26
+ import process from "node:process";
27
+ import { parse as yamlParse } from "yaml";
28
+
29
+ import { UsageError } from "./exit-codes";
30
+ import { isValidKeyId } from "./keygen";
31
+ import { readPrivateKeyPem } from "./lifecycle/keygen";
32
+ import { exportRawPublicKeyHex, fingerprintOfPublicKey, SIG_ALG, signCanonical, verifyCanonical } from "./lifecycle/sign";
33
+ import { indexCanonical, revocationCanonical, signIndex } from "./publish";
34
+
35
+ export type RegistryIndexKind = "catalog" | "shard";
36
+
37
+ /** 回传体里的一条索引签名(服务端 `POST /index/signature` 的 signatures[] 元素) */
38
+ export interface RegistryIndexSignature {
39
+ kind: RegistryIndexKind
40
+ /** 片号(catalog = 0) */
41
+ no: number
42
+ /** 这条签名覆盖的代次;服务端要求与本文件应签代次逐字相等 */
43
+ seq: number
44
+ alg: string
45
+ key_id: string
46
+ public_key: string
47
+ value: string
48
+ canon: string
49
+ }
50
+
51
+ export interface RegistrySignOptions {
52
+ /** 导出 JSON 路径;`-` = stdin */
53
+ file: string
54
+ /** 索引签名 key_id(导出里只有一把 active key 时可省略) */
55
+ id?: string
56
+ /** 私钥 PEM(缺省 ~/.ojm/keys/<id>/private.pem) */
57
+ key?: string
58
+ /** 回传请求体落盘路径(与 --json 至少给一个,否则签名结果无处可去) */
59
+ out?: string
60
+ /** `--json`:签名结果走 stdout 信封(与 --out 二选一即可) */
61
+ json?: boolean
62
+ /** `--revocations`:输入按吊销清单正文解释(走 `registryRevocationSignCommand`) */
63
+ revocations?: boolean
64
+ /** 测试注入;缺省 process.cwd() */
65
+ cwd?: string
66
+ /** 测试注入 stdin(file === "-" 时) */
67
+ stdin?: () => string
68
+ }
69
+
70
+ /** 回传体里的一条吊销清单签名(`POST /revocations/signature` 的请求体,六字段) */
71
+ export interface RegistryRevocationSignature {
72
+ seq: number
73
+ alg: string
74
+ key_id: string
75
+ public_key: string
76
+ /**
77
+ * **规范串原文** `revocations:<seq>:<sha256(正文)>`。
78
+ * 注意与索引签名里的 `canon` 同名不同义——那边是规范化版本号(`v2`),服务端拿它比常量;
79
+ * 这边服务端拿它与自己重算的规范串**逐字比对**,所以必须原样上传。
80
+ */
81
+ canon: string
82
+ value: string
83
+ }
84
+
85
+ export interface RegistryRevocationSignResult {
86
+ /** 吊销清单代次(取自正文顶层 seq) */
87
+ seq: number
88
+ keyId: string
89
+ fingerprint: string
90
+ signature: RegistryRevocationSignature
91
+ /** 回传体落盘位置(未给 --out = null) */
92
+ outPath: string | null
93
+ }
94
+
95
+ export interface RegistrySignResult {
96
+ gen: number
97
+ keyId: string | null
98
+ fingerprint: string | null
99
+ signatures: RegistryIndexSignature[]
100
+ /** 回传体落盘位置(未给 --out = null) */
101
+ outPath: string | null
102
+ /** 本代待签文件数(= signatures.length;0 表示这一代已经签全,命令是幂等的) */
103
+ signed: number
104
+ }
105
+
106
+ /**
107
+ * 业务失败(退出码 1)且带稳定机器码:CI 靠 `code` 分支,不解析人话。
108
+ * 与 `UsageError`(参数写错 = 2)分开,因为「公钥不对」补参数解决不了。
109
+ */
110
+ export class RegistrySignError extends Error {
111
+ constructor(readonly code: string, message: string) {
112
+ super(message);
113
+ this.name = "RegistrySignError";
114
+ }
115
+ }
116
+
117
+ const KIND_SET = new Set<string>(["catalog", "shard"]);
118
+ const HEX64 = /^[0-9a-f]{64}$/;
119
+
120
+ interface PendingFile {
121
+ kind: RegistryIndexKind
122
+ no: number
123
+ seq: number
124
+ text: string
125
+ sha256: string
126
+ }
127
+
128
+ interface DeclaredKey {
129
+ keyId: string
130
+ fingerprint: string
131
+ }
132
+
133
+ function sha256Hex(text: string): string {
134
+ return createHash("sha256").update(Buffer.from(text, "utf8")).digest("hex");
135
+ }
136
+
137
+ function asRecord(v: unknown, what: string): Record<string, unknown> {
138
+ if (v === null || typeof v !== "object" || Array.isArray(v))
139
+ throw new UsageError(`[ojm] ${what} 必须是对象`);
140
+ return v as Record<string, unknown>;
141
+ }
142
+
143
+ /** 读入并校验导出体。形状错 = 用法问题(2);内容与声明不符 = 业务失败(1,带码)。 */
144
+ function readExport(opts: RegistrySignOptions, cwd: string): { gen: number, pending: PendingFile[], keys: DeclaredKey[] } {
145
+ const raw = opts.file === "-"
146
+ ? (opts.stdin?.() ?? fs.readFileSync(0, "utf8"))
147
+ : fs.readFileSync(path.resolve(cwd, opts.file), "utf8");
148
+ let doc: Record<string, unknown>;
149
+ try {
150
+ doc = asRecord(JSON.parse(raw), `导出文件 ${opts.file}`);
151
+ }
152
+ catch (error) {
153
+ if (error instanceof UsageError)
154
+ throw error;
155
+ throw new UsageError(`[ojm] 输入不是合法 JSON:${opts.file}(取自 POST /api/oj-registry/index/unsigned 的响应体)`);
156
+ }
157
+
158
+ const gen = Number(doc.gen);
159
+ if (!Number.isInteger(gen) || gen < 1)
160
+ throw new UsageError(`[ojm] 导出里的 gen 不合法:${String(doc.gen)}(应为正整数,取自导出响应)`);
161
+ if (!Array.isArray(doc.pending))
162
+ throw new UsageError(`[ojm] 导出里缺 pending 数组:${opts.file}(是不是把 /index/signature 的响应体喂进来了?)`);
163
+ const keys: DeclaredKey[] = Array.isArray(doc.keys)
164
+ ? doc.keys.map((raw, i) => {
165
+ const k = asRecord(raw, `keys[${i}]`);
166
+ return { keyId: String(k.keyId ?? ""), fingerprint: String(k.fingerprint ?? "") };
167
+ })
168
+ : [];
169
+
170
+ const pending = doc.pending.map((raw, i) => {
171
+ const file = asRecord(raw, `pending[${i}]`);
172
+ const kind = String(file.kind);
173
+ if (!KIND_SET.has(kind))
174
+ throw new UsageError(`[ojm] pending[${i}].kind 只能是 catalog | shard(收到「${kind}」)`);
175
+ const no = Number(file.no);
176
+ const seq = Number(file.seq);
177
+ if (!Number.isInteger(no) || no < 0)
178
+ throw new UsageError(`[ojm] pending[${i}].no 不合法:${String(file.no)}`);
179
+ if (!Number.isInteger(seq) || seq < 1)
180
+ throw new UsageError(`[ojm] pending[${i}].seq 不合法:${String(file.seq)}(应签代次必须由导出给出)`);
181
+ if (typeof file.text !== "string")
182
+ throw new UsageError(`[ojm] pending[${i}].text 必须是字符串(索引正文原样,不做归一)`);
183
+ if (typeof file.sha256 !== "string" || !HEX64.test(file.sha256))
184
+ throw new UsageError(`[ojm] pending[${i}].sha256 不是 64 位 hex:${String(file.sha256)}`);
185
+ return { kind: kind as RegistryIndexKind, no, seq, text: file.text, sha256: file.sha256 };
186
+ });
187
+
188
+ return { gen, pending, keys };
189
+ }
190
+
191
+ /**
192
+ * 签名命令体。返回结果(测试/CI 断言用);不打印、不 exit——输出与退出码归 cli.ts。
193
+ * `--out` 只写回传请求体这一个文件(`{ gen, signatures }`,可直接 `-d @` 回传)。
194
+ */
195
+ export function registrySignCommand(opts: RegistrySignOptions): RegistrySignResult {
196
+ const cwd = opts.cwd ?? process.cwd();
197
+ if (!opts.file)
198
+ throw new UsageError("[ojm] 用法:ojm registry sign <export.json> --id <publisher.id> [--key <pem>] [--out <file>] [--json]\n 输入 = POST /api/oj-registry/index/unsigned 的响应体(`-` 表示从 stdin 读)");
199
+ if (opts.out === undefined && opts.json !== true)
200
+ throw new UsageError("[ojm] 签名结果必须有去处:--out <file>(回传请求体落盘)或 --json(打到 stdout)——两者都没有等于白签一场");
201
+
202
+ const { gen, pending, keys } = readExport(opts, cwd);
203
+
204
+ if (pending.length === 0) {
205
+ // 幂等:这一代没有待签文件(要么已经签全上读面,要么全部沿用旧片)。
206
+ // 连私钥都不必碰——CI 重跑时不该因为机器上没 key 就变红。
207
+ return writeOut({ gen, keyId: null, fingerprint: null, signatures: [], signed: 0 }, opts, cwd);
208
+ }
209
+
210
+ const keyId = opts.id ?? (keys.length === 1 ? keys[0]!.keyId : undefined);
211
+ if (keyId === undefined)
212
+ throw new UsageError(`[ojm] 导出里有 ${keys.length} 把 active 索引 key,请用 --id 指定签名者(可选:${keys.map(k => k.keyId).join(", ") || "无"})`);
213
+ if (!isValidKeyId(keyId))
214
+ throw new UsageError(`[ojm] key_id 命名空间非法(C2-37:<publisher>.<id> 全小写):「${keyId}」`);
215
+
216
+ const pem = readPrivateKeyPem(opts.key === undefined
217
+ ? path.join(os.homedir(), ".ojm", "keys", keyId, "private.pem")
218
+ : path.resolve(cwd, opts.key));
219
+ const publicHex = exportRawPublicKeyHex(pem);
220
+ const fingerprint = fingerprintOfPublicKey(publicHex);
221
+
222
+ const declared = keys.find(k => k.keyId === keyId) ?? null;
223
+ if (declared === null)
224
+ throw new RegistrySignError("index_key_mismatch", `registry 没有登记 key_id「${keyId}」的 active 索引公钥(导出里现有:${keys.map(k => k.keyId).join(", ") || "无"})——先 POST /api/oj-registry/index/keys 登记公钥,再签`);
225
+ if (declared.fingerprint !== fingerprint)
226
+ throw new RegistrySignError("index_key_mismatch", `私钥与登记的公钥不是一对:导出里 key_id「${keyId}」指纹 ${declared.fingerprint.slice(0, 16)}… ≠ 本地 ${fingerprint.slice(0, 16)}…`);
227
+
228
+ const signatures = pending.map((file) => {
229
+ const actual = sha256Hex(file.text);
230
+ if (actual !== file.sha256)
231
+ throw new RegistrySignError("index_export_tampered", `${file.kind}:${file.no} 的正文与导出声明的 sha256 不符(声明 ${file.sha256.slice(0, 16)}… / 实际 ${actual.slice(0, 16)}…)——导出文件被改过或版本错配,签了也验不过`);
232
+ const sig = signIndex(file.kind, file.seq, file.text, pem, keyId);
233
+ if (!verifyCanonical(publicHex, indexCanonical(file.kind, file.seq, file.text), sig.value))
234
+ throw new RegistrySignError("index_sig_mismatch", `${file.kind}:${file.no} 签完自检不过——私钥导出的公钥与登记口径不一致(检查 PEM 是不是 Ed25519)`);
235
+ return { kind: file.kind, no: file.no, ...sig };
236
+ });
237
+
238
+ return writeOut({ gen, keyId, fingerprint, signatures, signed: signatures.length }, opts, cwd);
239
+ }
240
+
241
+ function writeOut(result: Omit<RegistrySignResult, "outPath">, opts: RegistrySignOptions, cwd: string): RegistrySignResult {
242
+ if (opts.out === undefined)
243
+ return { ...result, outPath: null };
244
+ const outPath = path.resolve(cwd, opts.out);
245
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
246
+ fs.writeFileSync(outPath, `${JSON.stringify({ gen: result.gen, signatures: result.signatures }, null, 2)}\n`);
247
+ return { ...result, outPath };
248
+ }
249
+
250
+ /** 吊销清单正文的顶层 seq(空清单是 0;形态不对 → null,由调用方给码) */
251
+ function revocationsSeqOf(text: string): number | null {
252
+ let doc: unknown;
253
+ try {
254
+ doc = yamlParse(text);
255
+ }
256
+ catch {
257
+ return null;
258
+ }
259
+ if (doc === null || typeof doc !== "object" || Array.isArray(doc))
260
+ return null;
261
+ const seq = (doc as Record<string, unknown>).seq;
262
+ return typeof seq === "number" && Number.isInteger(seq) && seq >= 0 ? seq : null;
263
+ }
264
+
265
+ /**
266
+ * 吊销清单签名(REG-R10 的另一半):输入是**清单正文原文**,输出是
267
+ * `POST /api/oj-registry/revocations/signature` 的请求体。
268
+ *
269
+ * 为什么输入是整份正文而不是「seq + 条目」:服务端会把收到的 `canon` 与自己**重算**的
270
+ * `revocations:<seq>:<sha256(正文)>` 逐字比对。所以客户端必须对**同一串字节**签名——
271
+ * 中间任何重渲染(换行、键序、缩进)都会让 sha 变、canon 对不上,服务端 `revocation_sig_format` 拒收。
272
+ * 因此本命令不自作主张解析再序列化,原样吃文件;正文从哪里来由使用者决定
273
+ * (`ojm registry export` 产出的 `registry/revocations.yaml`、或 `GET /api/oj-registry/cdn/revocations` 的响应体)。
274
+ *
275
+ * `--id` 在这一处没有默认值可推断(不像索引签名有导出体的 keys[]),必须显式给。
276
+ */
277
+ export function registryRevocationSignCommand(opts: RegistrySignOptions): RegistryRevocationSignResult {
278
+ const cwd = opts.cwd ?? process.cwd();
279
+ if (!opts.file)
280
+ throw new UsageError("[ojm] 用法:ojm registry sign <revocations.yaml> --revocations --id <publisher.index> [--key <pem>] [--out <file>] [--json]\n 输入 = 吊销清单正文原文(GET /api/oj-registry/cdn/revocations 的响应体,或 ojm registry export 产出的 registry/revocations.yaml;`-` 表示从 stdin 读)");
281
+ if (opts.out === undefined && opts.json !== true)
282
+ throw new UsageError("[ojm] 签名结果必须有去处:--out <file>(回传请求体落盘)或 --json(打到 stdout)——两者都没有等于白签一场");
283
+
284
+ const file = opts.file === "-" ? opts.file : path.resolve(cwd, opts.file);
285
+ const text = opts.file === "-"
286
+ ? (opts.stdin?.() ?? fs.readFileSync(0, "utf8"))
287
+ : fs.readFileSync(file, "utf8");
288
+ if (text.trim() === "")
289
+ throw new UsageError(`[ojm] 吊销清单正文是空的:${opts.file}——空清单也必须签(seq=0),但空文件不是清单;用 GET /api/oj-registry/cdn/revocations 取原文`);
290
+ // JSON 也是合法 YAML,靠解析拦不住;直接按首字符判:服务端渲染的正文永远以 `seq:` 开头,
291
+ // 以 `{` 开头的几乎一定是 /revocations 的管理端点导出——签它的正文,服务端重算的 sha 必然不同。
292
+ if (text.trimStart().startsWith("{"))
293
+ throw new RegistrySignError("revocation_sig_format", `输入看起来是 /revocations 的 JSON 导出(首字符 {),不是 /cdn/revocations 的 YAML 正文:${opts.file}——服务端按正文重算规范串,喂 JSON 必被拒收;改用 GET /api/oj-registry/cdn/revocations 的响应体`);
294
+ const seq = revocationsSeqOf(text);
295
+ if (seq === null)
296
+ throw new RegistrySignError("revocation_sig_format", `吊销清单正文里没有合法的 seq(顶层应为非负整数):${opts.file}——确认喂进来的是 /cdn/revocations 的 YAML 正文,不是 /revocations 的 JSON 导出`);
297
+
298
+ const keyId = opts.id?.trim();
299
+ if (keyId === undefined || keyId === "")
300
+ throw new UsageError("[ojm] 吊销清单签名必须用 --id 指定索引签名 key(这一处没有导出体可以推断):--id <publisher.index>");
301
+ if (!isValidKeyId(keyId))
302
+ throw new UsageError(`[ojm] key_id 命名空间非法(C2-37:<publisher>.<id> 全小写):「${keyId}」`);
303
+
304
+ const pem = readPrivateKeyPem(opts.key === undefined
305
+ ? path.join(os.homedir(), ".ojm", "keys", keyId, "private.pem")
306
+ : path.resolve(cwd, opts.key));
307
+ const publicHex = exportRawPublicKeyHex(pem);
308
+ const fingerprint = fingerprintOfPublicKey(publicHex);
309
+
310
+ const canon = revocationCanonical(seq, text);
311
+ const value = signCanonical(pem, canon);
312
+ if (!verifyCanonical(publicHex, canon, value))
313
+ throw new RegistrySignError("revocation_sig_mismatch", "吊销清单签完自检不过——私钥导出的公钥与签名口径不一致(检查 PEM 是不是 Ed25519)");
314
+
315
+ const signature: RegistryRevocationSignature = { seq, alg: SIG_ALG, key_id: keyId, public_key: publicHex, canon, value };
316
+ if (opts.out === undefined)
317
+ return { seq, keyId, fingerprint, signature, outPath: null };
318
+ const outPath = path.resolve(cwd, opts.out);
319
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
320
+ fs.writeFileSync(outPath, `${JSON.stringify(signature, null, 2)}\n`);
321
+ return { seq, keyId, fingerprint, signature, outPath };
322
+ }
@@ -0,0 +1,192 @@
1
+ // registry 源解析(REG-R12 / 需求 §10.3):作者侧命令的仓库地址与凭据从哪来。
2
+ //
3
+ // 解析顺序(**低层不得放宽高层**,逐级只在前一级缺失时才看下一级):
4
+ // ① `--registry <url>`(本次调用)
5
+ // ② 工程级 `.ojmrc`(可入库,只放地址)—— 团队共享同一个源
6
+ // ③ 用户级 `~/.ojm/config.json` 的 `registry`
7
+ // ④ 没有内置缺省源:以上都缺 → 用法错误(exit 2)
8
+ //
9
+ // **为什么不给缺省源**:需求 §11 开放问题 1(公共托管 vs 私有部署优先)未拍板,内置一个域名
10
+ // 等于替产品做决定,还会让「忘了配源」的 CI 静默发到某个仓。这里一律 fail-closed:
11
+ // 未配置就报错,并把三条配法写进消息。
12
+ //
13
+ // 凭据(**只管来源,不落盘**):
14
+ // ① `OJM_REGISTRY_TOKEN` 环境变量(CI 首选;需求 §10.3「CI 用 env token」)
15
+ // ② `~/.ojm/config.json` 的 `registries[<origin>].token`(按源分桶,换源不会串用)
16
+ // ③ 缺失 —— 由上层的发布流程给可操作报错,本模块不猜
17
+ //
18
+ // 工程级 `.ojmrc` **不收 token**:它要入库,token 入仓即泄露。这条不是风格问题,是硬边界——
19
+ // 所以读取逻辑只认 `registry` 一个键,其余键忽略且永不打印(防止有人把密钥塞进去再被回显)。
20
+
21
+ import fs from "node:fs";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+ import process from "node:process";
25
+
26
+ import { UsageError } from "./exit-codes";
27
+
28
+ /** 环境变量名(CI 约定,需求 §10.3) */
29
+ export const REGISTRY_TOKEN_ENV = "OJM_REGISTRY_TOKEN";
30
+
31
+ export type SourceLevel = "flag" | "project" | "user";
32
+
33
+ export interface RegistrySource {
34
+ /** 归一化后的仓库地址(去掉尾部 `/`) */
35
+ url: string
36
+ level: SourceLevel
37
+ /** 命中来源的文件(`flag` 没有文件) */
38
+ originFile: string | null
39
+ }
40
+
41
+ export interface ResolveInput {
42
+ /** `--registry` 的值(未给 = undefined) */
43
+ flag?: string
44
+ cwd?: string
45
+ home?: string
46
+ env?: NodeJS.ProcessEnv
47
+ }
48
+
49
+ function ctx(input: ResolveInput): Required<Omit<ResolveInput, "flag">> & { flag?: string } {
50
+ return {
51
+ flag: input.flag,
52
+ cwd: input.cwd ?? process.cwd(),
53
+ home: input.home ?? os.homedir(),
54
+ env: input.env ?? process.env,
55
+ };
56
+ }
57
+
58
+ /** 地址归一 + 形态校验:只收 http(s),去尾斜杠;空串视为未给 */
59
+ export function normalizeRegistryUrl(raw: unknown): string {
60
+ const url = String(raw ?? "").trim().replace(/\/+$/, "");
61
+ if (url === "")
62
+ throw new UsageError("[ojm] --registry 需要一个地址,例如 --registry https://registry.example.com");
63
+ if (!/^https?:\/\/\S+$/i.test(url))
64
+ throw new UsageError(`[ojm] --registry 只接受 http(s) 地址,收到「${url}」(形如 https://registry.example.com)`);
65
+ return url;
66
+ }
67
+
68
+ /** 读 JSON 配置文件;缺失 → null,解析失败 → 人话错误(坏文件不是用法错误,退出码 1) */
69
+ function readJsonFile(file: string): Record<string, unknown> | null {
70
+ if (!fs.existsSync(file))
71
+ return null;
72
+ let doc: unknown;
73
+ try {
74
+ doc = JSON.parse(fs.readFileSync(file, "utf-8"));
75
+ }
76
+ catch (e) {
77
+ throw new Error(`[ojm] 配置文件不可解析:${file}(${(e as Error).message})——修好它,或删掉后重跑`);
78
+ }
79
+ if (doc === null || typeof doc !== "object" || Array.isArray(doc))
80
+ throw new Error(`[ojm] 配置文件顶层须是对象:${file}`);
81
+ return doc as Record<string, unknown>;
82
+ }
83
+
84
+ export function projectConfigPath(cwd: string): string {
85
+ return path.join(cwd, ".ojmrc");
86
+ }
87
+
88
+ export function userConfigPath(home: string): string {
89
+ return path.join(home, ".ojm", "config.json");
90
+ }
91
+
92
+ /**
93
+ * 解析生效的仓库地址;**没配任何源** → null(`config ls` 要把「空着」显示出来,不能算错误)。
94
+ *
95
+ * 但「配了、配坏了」照样抛:非法地址 → UsageError,坏 JSON → Error。
96
+ * 这条分界是 null 与 throw 的全部区别——**绝不把坏配置降级成「未配置」**,
97
+ * 否则 CI 会以为「只是没配」,实际是 `.ojmrc` 被写坏。
98
+ */
99
+ export function resolveRegistrySourceOptional(input: ResolveInput = {}): RegistrySource | null {
100
+ const c = ctx(input);
101
+ if (c.flag !== undefined)
102
+ return { url: normalizeRegistryUrl(c.flag), level: "flag", originFile: null };
103
+
104
+ const projectFile = projectConfigPath(c.cwd);
105
+ const project = readJsonFile(projectFile);
106
+ if (project?.registry !== undefined)
107
+ return { url: normalizeRegistryUrl(project.registry), level: "project", originFile: projectFile };
108
+
109
+ const userFile = userConfigPath(c.home);
110
+ const user = readJsonFile(userFile);
111
+ if (user?.registry !== undefined)
112
+ return { url: normalizeRegistryUrl(user.registry), level: "user", originFile: userFile };
113
+
114
+ return null;
115
+ }
116
+
117
+ /**
118
+ * 解析生效的仓库地址。四条路径都不成立 → 用法错误(把三种配法一次说清,别让人试错)。
119
+ * 顺序即权威序:`--registry` 一给就不再读文件(避免「以为覆盖了其实没有」)。
120
+ */
121
+ export function resolveRegistrySource(input: ResolveInput = {}): RegistrySource {
122
+ const found = resolveRegistrySourceOptional(input);
123
+ if (found !== null)
124
+ return found;
125
+ throw new UsageError(
126
+ "[ojm] 未指定仓库源:用 --registry <url>,或在工程根写 .ojmrc({\"registry\":\"https://…\"}),"
127
+ + "或在 ~/.ojm/config.json 配 registry。本 CLI 不内置缺省源(需求 §10.3),不会替你猜一个仓。",
128
+ );
129
+ }
130
+
131
+ export type CredentialKind = "env" | "store" | "missing";
132
+
133
+ export interface RegistryCredential {
134
+ kind: CredentialKind
135
+ /** 明文 token(**只在内存里用**;打印一律走 describeCredential) */
136
+ token: string | null
137
+ /** 来源描述:`OJM_REGISTRY_TOKEN` / 用户配置文件路径 / null */
138
+ origin: string | null
139
+ }
140
+
141
+ /** 凭据解析:env > 用户配置(按 origin 分桶)> 缺失。工程级 `.ojmrc` 永不收 token。 */
142
+ export function resolveRegistryCredential(source: RegistrySource, input: ResolveInput = {}): RegistryCredential {
143
+ const c = ctx(input);
144
+ const fromEnv = c.env[REGISTRY_TOKEN_ENV]?.trim();
145
+ if (fromEnv)
146
+ return { kind: "env", token: fromEnv, origin: REGISTRY_TOKEN_ENV };
147
+
148
+ const userFile = userConfigPath(c.home);
149
+ const user = readJsonFile(userFile);
150
+ const bucket = originOf(source.url);
151
+ const registries = user?.registries;
152
+ if (bucket !== null && registries !== null && typeof registries === "object" && !Array.isArray(registries)) {
153
+ const entry = (registries as Record<string, unknown>)[bucket];
154
+ const token = entry !== null && typeof entry === "object" && !Array.isArray(entry)
155
+ ? (entry as Record<string, unknown>).token
156
+ : undefined;
157
+ if (typeof token === "string" && token.trim() !== "")
158
+ return { kind: "store", token: token.trim(), origin: `${userFile}(${bucket})` };
159
+ }
160
+ return { kind: "missing", token: null, origin: null };
161
+ }
162
+
163
+ /** URL → `origin`(协议+主机+端口):凭据按源分桶的键 */
164
+ export function originOf(url: string): string | null {
165
+ try {
166
+ return new URL(url).origin;
167
+ }
168
+ catch {
169
+ return null;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * 凭据的可打印描述:**永不回显 token**。
175
+ * `config ls` 与发布日志都用它——这条纪律是「日志不许泄露凭证」的唯一落点。
176
+ */
177
+ export function describeCredential(cred: RegistryCredential): string {
178
+ if (cred.kind === "env")
179
+ return `${REGISTRY_TOKEN_ENV}(已设置)`;
180
+ if (cred.kind === "store")
181
+ return `${cred.origin ?? "用户配置"}(已保存)`;
182
+ return `缺失(设 ${REGISTRY_TOKEN_ENV},或先 ojm login)`;
183
+ }
184
+
185
+ /** 源级别的可打印描述(`--show-origin` 用) */
186
+ export function describeSourceLevel(source: RegistrySource): string {
187
+ if (source.level === "flag")
188
+ return "--registry";
189
+ if (source.level === "project")
190
+ return `.ojmrc${source.originFile === null ? "" : `(${source.originFile})`}`;
191
+ return `~/.ojm/config.json${source.originFile === null ? "" : `(${source.originFile})`}`;
192
+ }
package/src/sign.ts CHANGED
@@ -23,6 +23,12 @@ export interface SignOptions {
23
23
  * 给了它就跳过文件存在性检查,也永远不读真 ~/.ojm/keys(计划 §3.3.1)。
24
24
  */
25
25
  privateKeyPem?: string
26
+ /**
27
+ * 签发方 registry 身份(REG-CS3 v2 防跨仓库重放):`.sig.registry_id` 与规范串首段。
28
+ * 缺省报错——旧版 ojm 未带此字段,新规范串要求显式声明归属 registry,否则无法区分
29
+ * 「A 签的包被 B 的 CDN 安装」这类重放。
30
+ */
31
+ registryId?: string
26
32
  /** 写完离线自检(verifyCanonical 复核) */
27
33
  check?: boolean
28
34
  /** 测试注入;缺省 process.cwd() */
@@ -76,6 +82,10 @@ export function signCommand(tgz: string, opts: SignOptions = {}): SignResult {
76
82
  }
77
83
  if (!isValidKeyId(keyId))
78
84
  throw new Error(`[ojm] key_id 命名空间非法(C2-37):「${keyId}」`);
85
+ // REG-CS3 v2:规范串首段 = 签发方 registry 身份,缺省即报错(不兼容旧 ojm 未带此字段的 .sig)
86
+ const registryId = opts.registryId ?? "";
87
+ if (registryId === "")
88
+ throw new Error("[ojm] 缺少签发方身份:--registry-id <registry.id>(REG-CS3 v2 规范串首段,防跨仓库重放)。例如 ojm sign --registry-id oj-registry");
79
89
  if (memoryPem === undefined) {
80
90
  if (!fs.existsSync(keyFile!))
81
91
  throw new Error(`[ojm] 私钥文件不存在:${keyFile}(用 ojm keygen --id ${keyId} 生成,或 --key 指定)`);
@@ -83,11 +93,12 @@ export function signCommand(tgz: string, opts: SignOptions = {}): SignResult {
83
93
 
84
94
  const bytes = new Uint8Array(fs.readFileSync(tgz));
85
95
  const sha256 = createHash("sha256").update(bytes).digest("hex");
86
- const canonical = canonicalString(deriveName(tgz), deriveVersion(tgz), sha256);
96
+ const identity = deriveIdentity(path.basename(tgz).replace(/\.tgz$/, ""));
97
+ const canonical = canonicalString(registryId, identity.name, identity.version, sha256);
87
98
  const privateKeyPem = memoryPem ?? readPrivateKeyPem(keyFile!);
88
99
  const value = signCanonical(privateKeyPem, canonical);
89
100
  const publicKeyHex = exportRawPublicKeyHex(privateKeyPem);
90
- const sig = { alg: SIG_ALG, key_id: keyId, public_key: publicKeyHex, value, canon: CANON };
101
+ const sig = { alg: SIG_ALG, key_id: keyId, public_key: publicKeyHex, value, canon: CANON, registry_id: registryId };
91
102
  const sigPath = `${tgz}.sig`;
92
103
  fs.writeFileSync(sigPath, `${JSON.stringify(sig, null, 2)}\n`);
93
104
  console.log(`[ojm] 签名完成 → ${sigPath}`);
@@ -111,11 +122,3 @@ function deriveIdentity(stem: string): { name: string, version: string } {
111
122
  throw new Error(`[ojm] 无法从文件名推导 <name>-<version>:${stem}.tgz——请按 ojm build --bundle 产物命名(或重命名后再签)`);
112
123
  return { name: m[1]!, version: m[2]! };
113
124
  }
114
-
115
- function deriveName(tgz: string): string {
116
- return deriveIdentity(path.basename(tgz).replace(/\.tgz$/, "")).name;
117
- }
118
-
119
- function deriveVersion(tgz: string): string {
120
- return deriveIdentity(path.basename(tgz).replace(/\.tgz$/, "")).version;
121
- }
package/src/site.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // 站点瘦客户端(P1-13,D6):ojm install/enable/disable 共用的站点 HTTP 面。
2
2
  // 登录(POST /api/auth/login)→ Bearer 注入 → oj 信封解包({code,msg,data},code=0 为成)。
3
- // 凭据只走内存:flag(--user/--pass)> 环境变量(OJM_URL/OJM_USER/OJM_PASS),不落盘不落配置。
3
+ // 凭据只走内存:flag(--user/--pass)> 环境变量(OJM_SITE_URL/OJM_USER/OJM_PASS),不落盘不落配置。
4
4
  // 轮询对齐服务端消费面(§10.2):GET task?id= 查询参数形态,800ms 起步指数退避、上限 5s,
5
5
  // 总超时对齐 TASK_TIMEOUT_MS(30min)——超时报错退出,不悬挂。
6
6
 
@@ -42,18 +42,18 @@ export function isFutureSchema(record: TaskRecord): boolean {
42
42
  }
43
43
 
44
44
  export interface SiteAddressing {
45
- url?: string
45
+ siteUrl?: string
46
46
  user?: string
47
47
  pass?: string
48
48
  }
49
49
 
50
50
  /** 站点寻址解析:flag > 环境变量 > 缺省;缺凭据即人话报错(不猜测不交互) */
51
51
  export function resolveSite(opts: SiteAddressing): { base: string, user: string, pass: string } {
52
- const base = (opts.url ?? process.env.OJM_URL ?? DEFAULT_SITE_URL).replace(/\/+$/, "");
52
+ const base = (opts.siteUrl ?? process.env.OJM_SITE_URL ?? DEFAULT_SITE_URL).replace(/\/+$/, "");
53
53
  const user = opts.user ?? process.env.OJM_USER ?? "";
54
54
  const pass = opts.pass ?? process.env.OJM_PASS ?? "";
55
55
  if (user === "" || pass === "")
56
- throw new Error(`[ojm] 缺少站点凭据:请用 --user/--pass 或环境变量 OJM_USER/OJM_PASS 提供目标站点超管账号(--url / OJM_URL 指定站点地址,缺省 ${DEFAULT_SITE_URL};凭据只走内存,不落盘)`);
56
+ throw new Error(`[ojm] 缺少站点凭据:请用 --user/--pass 或环境变量 OJM_USER/OJM_PASS 提供目标站点超管账号(--site-url / OJM_SITE_URL 指定站点地址,缺省 ${DEFAULT_SITE_URL};凭据只走内存,不落盘)`);
57
57
  return { base, user, pass };
58
58
  }
59
59
 
package/src/trust.ts CHANGED
@@ -11,7 +11,7 @@ import process from "node:process";
11
11
  import { callApi, login, resolveSite } from "./site";
12
12
 
13
13
  export interface TrustOptions {
14
- url?: string
14
+ siteUrl?: string
15
15
  user?: string
16
16
  pass?: string
17
17
  /** TOFU 确认目标任务 id(confirm 子命令;--task) */
package/src/uninstall.ts CHANGED
@@ -1,13 +1,21 @@
1
1
  // ojm uninstall(P1-15a,缺口 B):卸载瘦客户端——POST /api/_platform/market/uninstall
2
2
  // 触发站点侧 runUninstall 四处同删(锁/sidecar/sys_solution_route/modules.json + 权限级联)。
3
3
  // 本地零状态,纯转发(enable.ts 同款);operator 由站点侧按登录用户反查,CLI 不传。
4
+ //
5
+ // REG-R12 / Q7:**不接 `--registry`**。卸载是站点侧操作——包已经在站点上了,
6
+ // 「从哪个仓库来」既不影响删除范围也不影响权限级联;接一个读了不用的 flag,
7
+ // 只会让人以为「指定源就只卸那个源的包」。所以看到 `--registry` 一律报错(exit 2),
8
+ // 并顺带把「该包来自本站点」讲明白。
4
9
  import process from "node:process";
10
+ import { UsageError } from "./exit-codes";
5
11
  import { callApi, login, resolveSite } from "./site";
6
12
 
7
13
  export interface UninstallOptions {
8
- url?: string
14
+ siteUrl?: string
9
15
  user?: string
10
16
  pass?: string
17
+ /** 仅为**拒绝**而存在(Q7):给了即用法错误,不读、不传、不影响结果 */
18
+ registry?: string
11
19
  /** 测试注入;缺省 globalThis.fetch */
12
20
  fetchFn?: typeof fetch
13
21
  }
@@ -17,11 +25,15 @@ export interface UninstallOptions {
17
25
  * ok:false = 域逻辑拒绝(未装/空锁拒启),打印 message 并置 exitCode 1。
18
26
  */
19
27
  export async function uninstallCommand(name: string, opts: UninstallOptions = {}): Promise<void> {
28
+ // 先拦 --registry:还没联网就该失败,且要说清「为什么这个 flag 不存在」
29
+ if (opts.registry !== undefined)
30
+ throw new UsageError("[ojm] uninstall 不接 --registry:卸载不涉及仓库源——包已经在站点上,来源不影响删除范围(去掉 --registry 即可)");
20
31
  if (name.trim() === "")
21
- throw new Error("[ojm] 用法:ojm uninstall <name>——name 为已安装方案名");
32
+ throw new UsageError("[ojm] 用法:ojm uninstall <name>——name 为已安装方案名");
22
33
  const fetchFn = opts.fetchFn ?? globalThis.fetch;
23
34
  const { base, user, pass } = resolveSite(opts);
24
35
  const token = await login(fetchFn, base, user, pass);
36
+ console.log(`[ojm] 该包来自站点 ${base} 的已装清单(卸载不涉及仓库源)`);
25
37
  const result = await callApi<{ ok: boolean, message: string }>(fetchFn, base, token, "POST", "/api/_platform/market/uninstall", { name: name.trim() });
26
38
  console.log(`[ojm] ${result.message}`);
27
39
  if (!result.ok)