@geoly-ai/skills-hub 0.3.6 → 0.3.8

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.
@@ -0,0 +1,813 @@
1
+ // `publish` 的远端一侧:取基线事实、核对 fork 身份、建树建 commit 建 ref、开 PR。
2
+ //
3
+ // ── 🔴 四条贯穿本模块的纪律 ───────────────────────────────────────────────
4
+ //
5
+ // ① **基线事实全部落在同一个 tree sha 上。** 上游的已有制品清单、
6
+ // `registry/reserved.json`、`registry/owners.json`、以及「这条投稿路径在不在
7
+ // base 里」—— 全部从**同一个** immutable tree sha 派生。分几次查会各自看到
8
+ // 不同时刻的仓库,而它们之间的不一致没有任何东西会发现。
9
+ //
10
+ // ② **绝不在可能被截断的结果上做判定。** GitHub 的 `?recursive=1` 有 `truncated`
11
+ // 标志,而 `compare` 的 `files` **最多 300 条且不分页** —— 载荷却最多允许
12
+ // 2000 个文件(`src/untar.mjs` 的 MAX_FILES)。一个在截断结果上得出的
13
+ // 「没查到 → 放行」,正是「看起来守住了」的教科书形状。
14
+ // 所以本模块**逐层 subtree 走**,每一层都查 `truncated`。
15
+ //
16
+ // ③ **POST 不是幂等的。** fork / 建 ref / 开 PR 三个 POST 超时之后,
17
+ // 服务端可能已经生效了。所以每一个都写成「先查 → 没有才发 → 发完读回」,
18
+ // 绝不在超时后盲目重发。
19
+ // ⚠️ **读回读不到 ≠ 那个 POST 没到 GitHub**。读不到时我们以**网络错误**退出、
20
+ // 请用户重跑(重跑会先查),而不是重发,也不是宣布"没成功"。
21
+ //
22
+ // ④ **写完必须用独立的读去证明。** 「我发了一个 POST 而且它返回了 201」不等于
23
+ // 「远端现在的状态是我要的那个」。见 `verifyBranch` 的长注释 ——
24
+ // 那里是本模块最容易被写成「看起来守住了」的地方。
25
+
26
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
27
+ import { tmpdir } from 'node:os';
28
+ import { join } from 'node:path';
29
+
30
+ import {
31
+ UPSTREAM_OWNER, UPSTREAM_REPO, UPSTREAM_FULL, BASE_BRANCH,
32
+ seg, getAllPages, ApiHttpError,
33
+ } from './github.mjs';
34
+ import { parseStrict } from '../canonical-json.mjs';
35
+ import { readReserved } from '../../scripts/submission/structural-gates.mjs';
36
+
37
+ export class RemoteError extends Error {
38
+ constructor(message, extra = {}) {
39
+ super(message); this.name = 'RemoteError'; this.exitCode = extra.exitCode ?? 1;
40
+ Object.assign(this, extra);
41
+ }
42
+ }
43
+
44
+ const UP = `/repos/${seg(UPSTREAM_OWNER)}/${seg(UPSTREAM_REPO)}`;
45
+ const forkPathFor = (login) => `/repos/${seg(login)}/${seg(UPSTREAM_REPO)}`;
46
+
47
+ // ── tree 的逐层走法 ─────────────────────────────────────────────────────────
48
+
49
+ /**
50
+ * 取一棵 tree。**每一次都查 `truncated`** —— 截断的结果上不做任何判定。
51
+ *
52
+ * 🔴 `recursive` 默认关。整棵仓库树的递归查询会随仓库增长而截断,
53
+ * 而"先拿到结果再过滤前缀"已经太晚:被截掉的那部分恰恰可能就是我们要找的。
54
+ * 只有对**已经缩到投稿目录**的 subtree 才用 recursive。
55
+ */
56
+ export async function getTree(client, repoPath, sha, { recursive = false } = {}) {
57
+ const q = recursive ? '?recursive=1' : '';
58
+ const r = await client.get(`${repoPath}/git/trees/${seg(sha)}${q}`);
59
+ const j = r.json ?? {};
60
+ if (j.truncated === true) {
61
+ throw new RemoteError(
62
+ `tree ${sha} 的查询结果被 GitHub **截断**(truncated=true)—— 拒绝据此判定。\n`
63
+ + ' 🔴 在截断的结果上,「这条路径不存在」与「我们没看到它」分不开,\n'
64
+ + ' 而前者是放行、后者是不知道。',
65
+ { exitCode: 6 },
66
+ );
67
+ }
68
+ // 🔴 200 但 `tree` 不是数组 → **抛**,不当成空树。
69
+ // 「空树」是一个会让下游放行的答案(路径不存在、版本没占用),
70
+ // 而「响应形状不对」是一个我们什么都不知道的状态。两者不能同格。
71
+ if (!Array.isArray(j.tree)) {
72
+ throw new RemoteError(`tree ${sha} 的响应里 tree 不是数组 —— 拒绝把它当成空树`, { exitCode: 6 });
73
+ }
74
+ return j.tree;
75
+ }
76
+
77
+ /**
78
+ * 从一棵 root tree 出发,**逐层非递归**地解析一条路径。
79
+ * 找不到返回 `null` —— 这是一个**确定的**"不存在",因为每一层都完整读过
80
+ * (`getTree` 保证没有截断)。
81
+ */
82
+ export async function resolvePath(client, repoPath, rootTreeSha, segments) {
83
+ let sha = rootTreeSha;
84
+ for (let i = 0; i < segments.length; i += 1) {
85
+ const entries = await getTree(client, repoPath, sha);
86
+ const hit = entries.find((e) => e.path === segments[i]);
87
+ if (hit === undefined) return null;
88
+ if (i === segments.length - 1) return hit;
89
+ if (hit.type !== 'tree') return null; // 中间一段不是目录 = 这条路径不存在
90
+ sha = hit.sha;
91
+ }
92
+ return null;
93
+ }
94
+
95
+ // ── 身份 ────────────────────────────────────────────────────────────────────
96
+
97
+ /**
98
+ * `GET /user` —— 认证用户是谁。
99
+ *
100
+ * 🔴 顺带读 `x-oauth-scopes`:classic PAT 会在这个头里回它的 scope。
101
+ * ⚠️ **空或缺席绝不代表「这个 token 权限小」** —— fine-grained token
102
+ * 根本不回这个头,而它可能被授予了任意仓库的写权限。所以下游把
103
+ * `null` 显示成「看不见」,不显示成「无」。
104
+ */
105
+ export async function getViewer(client) {
106
+ const r = await client.get('/user');
107
+ const u = r.json ?? {};
108
+ for (const k of ['login', 'id', 'node_id']) {
109
+ if (u[k] === undefined || u[k] === null) {
110
+ throw new RemoteError(`GET /user 的响应里没有 ${k} —— 拒绝在身份不明的情况下继续`, { exitCode: 7 });
111
+ }
112
+ }
113
+ const raw = r.headers?.get?.('x-oauth-scopes');
114
+ const scopes = typeof raw === 'string'
115
+ ? raw.split(',').map((s) => s.trim()).filter(Boolean)
116
+ : null; // ← null = **看不见**,不是「没有权限」
117
+ // 🔴 同 `repo.id`:`owner.id === viewer.id` 那道门的判据,缺席即退化成恒真。
118
+ if (typeof u.id !== 'number' || !Number.isInteger(u.id)) {
119
+ throw new RemoteError(
120
+ `GET /user 的 id 不是整数(得到 ${JSON.stringify(u.id)})—— 拒绝继续:`
121
+ + '它是 fork owner 与 PR 作者核对的判据。',
122
+ { exitCode: 7 },
123
+ );
124
+ }
125
+ return { login: String(u.login), id: u.id, nodeId: String(u.node_id), scopes };
126
+ }
127
+
128
+ // ── 基线 ────────────────────────────────────────────────────────────────────
129
+
130
+ /**
131
+ * 上游 base 的全部事实,全部落在同一个 `baseTreeSha` 上。
132
+ *
133
+ * 🔴 **一次整仓 `?recursive=1` 都不发。** 我们要的三样东西都是 subtree 局部的:
134
+ * · 已有制品清单 → `artifacts/` 这一棵
135
+ * · 保留清单 / owners → `registry/` 这一层
136
+ * · 这条投稿路径在不在 → 逐层解析那条路径
137
+ * 整仓递归会随仓库增长而截断,而截断之后上面三条**全都**会得到假的「没有」。
138
+ *
139
+ * @param {object} a
140
+ * @param {string[]} a.submissionSegments 形如 `['submissions','<ns>','<name>@<ver>']`
141
+ */
142
+ export async function getBaseline(client, { submissionSegments }) {
143
+ const repo = (await client.get(UP)).json ?? {};
144
+ if (repo.full_name !== UPSTREAM_FULL) {
145
+ throw new RemoteError(
146
+ `上游仓库的 full_name 是 ${JSON.stringify(repo.full_name)},应为 ${UPSTREAM_FULL} —— `
147
+ + '多半是仓库被改名或被转移了。拒绝继续。',
148
+ );
149
+ }
150
+ // 🔴 `repo.id` 必须是**真的数字**。它是后面核对 `fork.parent.id === upstreamId`
151
+ // 的右操作数 —— 两边都缺席时 `undefined === undefined` 为真,
152
+ // 于是那道 fork 身份门会静默放行**任何** fork。
153
+ // ⚠️ 这正是「看起来守住了」的形状:门写在那里,判据却退化成恒真。
154
+ const upstreamId = repo.id;
155
+ if (typeof upstreamId !== 'number' || !Number.isInteger(upstreamId)) {
156
+ throw new RemoteError(
157
+ `${UPSTREAM_FULL} 的 repo.id 不是整数(得到 ${JSON.stringify(upstreamId)})—— 拒绝继续。\n`
158
+ + ' 🔴 它是 fork 身份核对的判据;拿不到它,那道门就退化成恒真。',
159
+ );
160
+ }
161
+
162
+ const ref = (await client.get(`${UP}/git/ref/heads/${seg(BASE_BRANCH)}`)).json ?? {};
163
+ if (ref.object?.type !== 'commit' || typeof ref.object?.sha !== 'string') {
164
+ throw new RemoteError(`${UPSTREAM_FULL} 的 ${BASE_BRANCH} 不是一个 commit ref —— 拒绝继续`);
165
+ }
166
+ const baseCommitSha = ref.object.sha;
167
+
168
+ const commit = (await client.get(`${UP}/git/commits/${seg(baseCommitSha)}`)).json ?? {};
169
+ const baseTreeSha = commit.tree?.sha;
170
+ if (typeof baseTreeSha !== 'string') {
171
+ throw new RemoteError(`读不到 ${baseCommitSha} 的 tree sha —— 拒绝继续`);
172
+ }
173
+
174
+ // ① 已有制品:只递归 `artifacts/` 这一棵
175
+ const artifactsEntry = await resolvePath(client, UP, baseTreeSha, ['artifacts']);
176
+ let artifactPaths = [];
177
+ if (artifactsEntry !== null) {
178
+ if (artifactsEntry.type !== 'tree') throw new RemoteError('上游的 `artifacts` 不是目录 —— 拒绝继续');
179
+ artifactPaths = (await getTree(client, UP, artifactsEntry.sha, { recursive: true }))
180
+ .map((e) => `artifacts/${e.path}`);
181
+ }
182
+
183
+ // ② 注册表两份文件
184
+ const registryEntry = await resolvePath(client, UP, baseTreeSha, ['registry']);
185
+ // 🔴 `registry` 在但不是目录 → **抛**,不当成"里面什么都没有"。
186
+ // 当成空的话,保留清单与 owners 都会退化成空表 —— 于是保留 namespace
187
+ // 全部放行、每个 namespace 都被判成"首次注册"。
188
+ if (registryEntry !== null && registryEntry.type !== 'tree') {
189
+ throw new RemoteError(
190
+ `上游的 \`registry\` 不是目录(type=${registryEntry.type})—— 拒绝把保留清单当成空表`,
191
+ );
192
+ }
193
+ const registryFiles = registryEntry === null ? [] : await getTree(client, UP, registryEntry.sha);
194
+ const blobShaIn = (name) => {
195
+ const e = registryFiles.find((x) => x.path === name);
196
+ if (e === undefined) return null;
197
+ // 🔴 类型必须**恰好**是 blob:一个同名的 tree / commit(gitlink) / symlink
198
+ // 在这里被当成"文件读不出来"是不够的 —— 那会走到"文件不存在"那一支,
199
+ // 于是保留清单被当成空的。
200
+ if (e.type !== 'blob') throw new RemoteError(`registry/${name} 不是普通文件(type=${e.type})—— 拒绝解读`);
201
+ return e.sha;
202
+ };
203
+
204
+ // ③ 这条投稿路径在不在 base 里
205
+ const existingSubmission = await resolvePath(client, UP, baseTreeSha, submissionSegments);
206
+
207
+ return {
208
+ upstreamId, baseCommitSha, baseTreeSha,
209
+ existingSubmission,
210
+ ...derivedFromTree(artifactPaths),
211
+ ...(await readRegistryFiles(client, blobShaIn)),
212
+ };
213
+ }
214
+
215
+ /**
216
+ * 从 `artifacts/` 的路径清单派生「已有制品」。
217
+ *
218
+ * 布局:`artifacts/{skills,packs}/<ns>/<name>/<ver>/…`(01-artifacts.md §2)。
219
+ * ⚠️ 这是本模块唯一一处**复刻**了 `run-gates.mjs` `main()` 里的逻辑而不是调用它
220
+ * (那边从磁盘 `readdirSync` 数,这边只有路径字符串)。有测试喂同一组输入、
221
+ * 钉两者给出同一组 id。
222
+ */
223
+ export function derivedFromTree(artifactPaths) {
224
+ const existingIds = new Set();
225
+ const byNs = new Map();
226
+ for (const p of artifactPaths) {
227
+ const parts = p.split('/');
228
+ // artifacts / <kindDir> / <ns> / <name> / <ver> / …
229
+ if (parts.length < 6) continue;
230
+ const kind = parts[1] === 'skills' ? 'skill' : parts[1] === 'packs' ? 'pack' : null;
231
+ if (kind === null) continue;
232
+ const [, , ns, name, ver] = parts;
233
+ existingIds.add(`${kind}:${ns}/${name}@${ver}`);
234
+ if (!byNs.has(ns)) byNs.set(ns, new Set());
235
+ byNs.get(ns).add(name);
236
+ }
237
+ return {
238
+ existingIds: [...existingIds],
239
+ existingNamesByNs: new Map([...byNs].map(([k, v]) => [k, [...v]])),
240
+ };
241
+ }
242
+
243
+ /**
244
+ * `registry/reserved.json` 与 `registry/owners.json` —— 从**同一棵 base 树**取,
245
+ * 不从 npm 包里带。
246
+ *
247
+ * 🔴 为什么不打进 npm 包:它们是**仓库数据**,会随每次 promote 变。
248
+ * 一个跟着 CLI 版本走的副本必然过期,而过期的保留清单意味着
249
+ * 「本地放行、CI 拒绝」(新加的保留 namespace 本地看不见)。
250
+ */
251
+ async function readRegistryFiles(client, blobShaIn) {
252
+ const readText = async (name) => {
253
+ const sha = blobShaIn(name);
254
+ if (sha === null) return null; // 上游还没有这个文件
255
+ const b = (await client.get(`${UP}/git/blobs/${seg(sha)}`)).json ?? {};
256
+ if (b.encoding !== 'base64' || typeof b.content !== 'string') {
257
+ throw new RemoteError(
258
+ `registry/${name} 的 blob 不是 base64(encoding=${JSON.stringify(b.encoding)})—— 拒绝解读`,
259
+ );
260
+ }
261
+ // 🔴 `Buffer.from(…, 'base64')` 是**宽容**的:非法字符被静默跳过。
262
+ // 回编码比一遍,让一个被截断/改坏的 content 变成一次失败,
263
+ // 而不是"内容原来就是这样"。
264
+ const buf = Buffer.from(b.content, 'base64');
265
+ if (buf.toString('base64') !== b.content.replace(/\s/g, '')) {
266
+ throw new RemoteError(`registry/${name} 的 base64 内容不自洽 —— 拒绝解读`);
267
+ }
268
+ return buf.toString('utf8');
269
+ };
270
+
271
+ const reserved = reservedFromText(await readText('reserved.json'));
272
+
273
+ const ownersText = await readText('owners.json');
274
+ let registeredNamespaces;
275
+ if (ownersText === null) {
276
+ registeredNamespaces = new Set();
277
+ } else {
278
+ let doc;
279
+ try { doc = parseStrict(ownersText); } catch (e) {
280
+ // 🔴 读不出来时**不能当成空的**:那会让每个 namespace 都被判成「首次注册」,
281
+ // 于是任何人都能声明 claim_owner。与 `run-gates.readRegistered` 同一条判据。
282
+ throw new RemoteError(
283
+ `registry/owners.json 读不出来(${e.message})—— 拒绝在「不知道谁注册过」的情况下判首次注册`,
284
+ );
285
+ }
286
+ registeredNamespaces = new Set(Object.keys(doc.namespaces ?? {}));
287
+ }
288
+ return { reserved, registeredNamespaces };
289
+ }
290
+
291
+ /**
292
+ * 远端取来的 `reserved.json` 字节 → `structural-gates.readReserved` 的产物。
293
+ *
294
+ * 🔴 **不重写它的解析。** `readReserved` 做三件事:`parseStrict`(拒重复 key ——
295
+ * `{"namespaces":[…],"namespaces":[]}` 能把整张保留清单清空而文件看着正常)、
296
+ * schema 核对、以及「保留清单内部同形折叠自撞」检查。在这里照抄一遍,
297
+ * 就是又一处会与服务端分叉的实现,而分叉点正好是绕过点。
298
+ *
299
+ * ⚠️ 代价:它的入参是**文件路径**而我们手上是字节,所以要落一次隔离临时文件。
300
+ * 内容是公开的注册表(不是凭据),用完即删。比"为了不落盘而复制一份解析逻辑"划算。
301
+ */
302
+ function reservedFromText(text) {
303
+ if (text === null) return { schema: 'geoly.skills.reserved/1', namespaces: [] };
304
+ const d = mkdtempSync(join(tmpdir(), 'geoly-pub-'));
305
+ try {
306
+ const p = join(d, 'reserved.json');
307
+ writeFileSync(p, text, { mode: 0o600 });
308
+ return readReserved(p);
309
+ } finally {
310
+ rmSync(d, { recursive: true, force: true });
311
+ }
312
+ }
313
+
314
+ // ── fork ────────────────────────────────────────────────────────────────────
315
+
316
+ /**
317
+ * 保证认证用户名下有一个**上游的** fork,并核对它确实是。
318
+ *
319
+ * 🔴 核对的三条缺一不可:
320
+ * · `fork === true` —— 一个同名的**普通**仓库不是 fork;
321
+ * · `owner.id === me.id` —— 用不可变 id 比,不比 login(login 可以改名 + 被别人认领);
322
+ * · `parent.id === upstreamId` —— 「是个 fork」不等于「是**这个仓库**的 fork」。
323
+ * 三条里少任何一条,我们都可能把投稿推进一个不属于用户、或者根本不指向上游的仓库。
324
+ */
325
+ export async function ensureFork(client, {
326
+ viewer, upstreamId, baseCommitSha, sleep = defaultSleep, maxWaitMs = 60000, now = Date.now,
327
+ }) {
328
+ const path = forkPathFor(viewer.login);
329
+
330
+ // 🔴🔴 **判据两边都必须是真的整数。** 这三条门写的是 `a !== b`,
331
+ // 而 `undefined !== undefined` 为假 —— 于是两边都缺席时,
332
+ // 门原地退化成恒真、放行任何 fork。上游 `getBaseline` / `getViewer`
333
+ // 已经各自把 id 钉成整数了,**这里再钉一次**是纵深:
334
+ // 那两处与这里相隔很远,将来有人绕过它们直接调 `ensureFork`
335
+ // (测试、别的命令)时,门不该跟着失效。
336
+ const intOr = (v, what) => {
337
+ if (typeof v !== 'number' || !Number.isInteger(v)) {
338
+ throw new RemoteError(
339
+ `fork 身份核对拿不到整数的 ${what}(得到 ${JSON.stringify(v)})—— 拒绝继续。\n`
340
+ + ' 🔴 判据缺席时 `a !== b` 会退化成恒真,那道门就等于没设。',
341
+ );
342
+ }
343
+ return v;
344
+ };
345
+ intOr(upstreamId, 'upstream repo id');
346
+ intOr(viewer.id, '认证用户 id');
347
+
348
+ const check = (repo) => {
349
+ if (repo.fork !== true) {
350
+ throw new RemoteError(
351
+ `${viewer.login}/${UPSTREAM_REPO} 存在,但它**不是** fork(fork=false)——\n`
352
+ + ' 拒绝往一个同名的普通仓库里写投稿。请改名或删掉它,再重跑。',
353
+ );
354
+ }
355
+ if (intOr(repo.owner?.id, `${viewer.login}/${UPSTREAM_REPO} 的 owner.id`) !== viewer.id) {
356
+ throw new RemoteError(
357
+ `${viewer.login}/${UPSTREAM_REPO} 的 owner id 是 ${repo.owner?.id},`
358
+ + `而认证用户是 ${viewer.id} —— 拒绝继续。`,
359
+ );
360
+ }
361
+ if (intOr(repo.parent?.id, `${viewer.login}/${UPSTREAM_REPO} 的 parent.id`) !== upstreamId) {
362
+ throw new RemoteError(
363
+ `${viewer.login}/${UPSTREAM_REPO} 是一个 fork,但它的 parent id 是 ${repo.parent?.id},`
364
+ + `不是 ${UPSTREAM_FULL}(id=${upstreamId})——\n`
365
+ + ' 「是个 fork」不等于「是**这个仓库**的 fork」。拒绝继续。',
366
+ );
367
+ }
368
+ return repo;
369
+ };
370
+
371
+ const probe = async () => {
372
+ const r = await client.get(path, { softStatus: [404] });
373
+ return r.status === 404 ? null : (r.json ?? {});
374
+ };
375
+
376
+ const existing = await probe();
377
+ if (existing !== null) {
378
+ const repo = check(existing);
379
+ await assertForkHasBase(client, { login: viewer.login, baseCommitSha });
380
+ return { repo, created: false };
381
+ }
382
+
383
+ // 🔴 POST 之前先查过了(上面那次)。POST 之后**再查**才算数:
384
+ // fork 是**异步**创建的,201 只表示"排上队了"。
385
+ try {
386
+ await client.post(`${UP}/forks`, {});
387
+ } catch (e) {
388
+ // 🔴 超时 / 5xx 时**不重发** —— 去查。fork 可能已经建出来了。
389
+ // ⚠️ 查不到也**不等于**那个 POST 没到 GitHub,所以下面轮询到期是以
390
+ // 网络错误退出、请用户重跑,而不是再发一次。
391
+ if (e?.timedOut !== true && !(e instanceof ApiHttpError && e.status >= 500)) throw e;
392
+ }
393
+
394
+ const deadline = now() + maxWaitMs;
395
+ let delay = 1000;
396
+ for (;;) {
397
+ const repo = await probe();
398
+ if (repo !== null) {
399
+ check(repo);
400
+ const ready = await client.get(
401
+ `${path}/git/ref/heads/${seg(BASE_BRANCH)}`, { softStatus: [404, 409] },
402
+ );
403
+ if (ready.status === 200) {
404
+ await assertForkHasBase(client, { login: viewer.login, baseCommitSha });
405
+ return { repo, created: true };
406
+ }
407
+ }
408
+ if (now() >= deadline) {
409
+ throw new RemoteError(
410
+ `等 ${viewer.login}/${UPSTREAM_REPO} 这个 fork 就绪超过 ${Math.round(maxWaitMs / 1000)} 秒。\n`
411
+ + ' fork 是异步创建的,请稍后重跑 —— 本命令**先查后建**,不会重复建 fork。\n'
412
+ + ' ⚠️ 到这一步为止**没有**建过任何分支或 PR。',
413
+ { exitCode: 6 },
414
+ );
415
+ }
416
+ await sleep(delay);
417
+ delay = Math.min(delay * 2, 8000);
418
+ }
419
+ }
420
+
421
+ /**
422
+ * 🔴 fork 里必须真的读得到我们选定的那个 base commit —— 否则后面
423
+ * `base_tree` / `parents` 引用的对象在 fork 的网络里不存在,
424
+ * 建 tree / commit 会以一个语焉不详的 422 失败,而真正的原因
425
+ * (fork 刚建出来、对象还没同步完)在那条错误里根本看不出来。
426
+ */
427
+ async function assertForkHasBase(client, { login, baseCommitSha }) {
428
+ const r = await client.get(
429
+ `${forkPathFor(login)}/git/commits/${seg(baseCommitSha)}`, { softStatus: [404, 409, 422] },
430
+ );
431
+ if (r.status !== 200) {
432
+ throw new RemoteError(
433
+ `你的 fork ${login}/${UPSTREAM_REPO} 里读不到上游 ${BASE_BRANCH} 的 commit ${baseCommitSha}`
434
+ + `(HTTP ${r.status})。\n`
435
+ + ' fork 刚建出来时对象同步需要一点时间;请稍后重跑。\n'
436
+ + ' ⚠️ 到这一步为止**没有**建过任何分支或 PR。',
437
+ { exitCode: 6 },
438
+ );
439
+ }
440
+ }
441
+
442
+ const defaultSleep = (ms) => new Promise((r) => { setTimeout(r, ms); });
443
+
444
+ // ── 重复投稿检测 ────────────────────────────────────────────────────────────
445
+
446
+ /**
447
+ * 同版本可能已经在**另一个未合并的 PR** 里 —— 只看 `artifacts/` 是看不见的。
448
+ *
449
+ * 🔴 **判据是沿 PR 的 `head.sha` 逐层解析那条路径,不是读 `/pulls/<n>/files`。**
450
+ * files 列表会分页、也会被 GitHub 截断,而"我只看了第一页所以没看到"
451
+ * 与"它不在里面"是两件完全不同的事 —— 前者是不知道,后者才是放行。
452
+ * tree 解析每一层都完整读过:基线已经证明这条路径在 base 上不存在,
453
+ * 所以**任何**开放 PR 的 head 上出现它,就是冲突。
454
+ *
455
+ * ⚠️ **诚实边界(请读完再信这道门)**:它证明的是**扫描那一瞬间**没有冲突。
456
+ * 扫完之后立刻有人开新 PR / 往已有 PR 推新 commit,这里什么都不知道。
457
+ * **它不是并发正确性门**,只是一次 UX 上的提前告知 —— 最终裁决在服务端的
458
+ * PR gate 与 promote。
459
+ *
460
+ * 🔴 分页预算用尽 → `getAllPages` 抛 `TruncatedError`,**不在这里吞掉**:
461
+ * 一个静默截断的重复检测,比没有这道门更坏。
462
+ */
463
+ export async function findOpenDuplicates(client, { submissionSegments, maxPages = 10 }) {
464
+ const pulls = await getAllPages(client, `${UP}/pulls?state=open`, { perPage: 100, maxPages });
465
+ const matches = [];
466
+ for (const pr of pulls) {
467
+ const headSha = pr.head?.sha;
468
+ if (typeof headSha !== 'string') {
469
+ throw new RemoteError(`PR #${pr.number} 没有 head.sha —— 拒绝在看不全的情况下宣布"没有重复"`);
470
+ }
471
+ // 🔴 从**上游**仓库解析:PR 的 head commit 通过 `refs/pull/<n>/head` 在上游可读,
472
+ // 即使它长在别人的 fork 上。
473
+ const commit = await client.get(`${UP}/git/commits/${seg(headSha)}`, { softStatus: [404, 422] });
474
+ if (commit.status !== 200) {
475
+ throw new RemoteError(
476
+ `读不到 PR #${pr.number} 的 head commit ${headSha}(HTTP ${commit.status})——\n`
477
+ + ' 拒绝在"有一个 PR 没查成"的情况下宣布没有重复投稿。',
478
+ { exitCode: 6 },
479
+ );
480
+ }
481
+ const rootTree = commit.json?.tree?.sha;
482
+ if (typeof rootTree !== 'string') {
483
+ throw new RemoteError(`PR #${pr.number} 的 head commit 没有 tree sha —— 拒绝继续`);
484
+ }
485
+ if (await resolvePath(client, UP, rootTree, submissionSegments) !== null) {
486
+ matches.push({
487
+ number: pr.number, title: pr.title, url: pr.html_url,
488
+ author: pr.user?.login ?? '(未知)',
489
+ });
490
+ }
491
+ }
492
+ return { matches, scanned: pulls.length };
493
+ }
494
+
495
+ // ── 写:blob → tree → commit → ref ─────────────────────────────────────────
496
+
497
+ /**
498
+ * 分支名。`tree_digest` 前 8 位做后缀 —— 内容变了就是另一个分支,**从不复用**。
499
+ *
500
+ * 🔴 出厂前过一道**字符白名单**。分支名会被拼进我们自己发出去的 API URL,
501
+ * 而 `encodeURI` **不转义** `?` `#` `&`(它们是 URI 的保留字符)——
502
+ * 一个混进这三个字符的分支名能往我们的请求里注入 query / fragment。
503
+ * ⚠️ 上游 `readIdentity` 已经用共享 grammar 把 ns/name/version 钉死了,
504
+ * 所以今天走不到这里。**留着是因为这道门与那道门相隔很远**:
505
+ * 哪天有人放宽了 version 的 grammar,这里会当场失败,而不是悄悄发出
506
+ * 一个被注入的请求。
507
+ */
508
+ const RE_SAFE_BRANCH = /^[A-Za-z0-9._@/+-]+$/;
509
+
510
+ export function branchName({ namespace, name, version, treeDigest }) {
511
+ const hex = /:([0-9a-f]{64})$/.exec(treeDigest)?.[1];
512
+ if (hex === undefined) throw new RemoteError(`认不得的 tree_digest 形状:${treeDigest}`);
513
+ const b = `submit/${namespace}/${name}@${version}-${hex.slice(0, 8)}`;
514
+ if (!RE_SAFE_BRANCH.test(b) || b.includes('..') || b.includes('//') || b.endsWith('/')) {
515
+ throw new RemoteError(
516
+ `拒绝使用分支名 ${JSON.stringify(b)} —— 它含有会被拼进 API URL 的危险字符。\n`
517
+ + ' 这是由 manifest 的 namespace / name / version 拼出来的,请检查那三个字段。',
518
+ );
519
+ }
520
+ return b;
521
+ }
522
+
523
+ /**
524
+ * 🔴 分支已存在 → **只读、核对、报告,不改写**。
525
+ * 不 force-push、不 amend 是硬约束:那两个动作会让别人已经看过 / 审过的东西
526
+ * 在他不知情的时候变掉。
527
+ */
528
+ export async function assertBranchFree(client, { login, branch }) {
529
+ const r = await client.get(
530
+ `${forkPathFor(login)}/git/ref/heads/${encodeURI(branch)}`, { softStatus: [404] },
531
+ );
532
+ if (r.status === 404) return null;
533
+ const sha = r.json?.object?.sha ?? '(未知)';
534
+ throw new RemoteError(
535
+ `你的 fork 上已经有分支 ${branch}(指向 ${sha})——\n`
536
+ + ' 🔴 本命令**不会** force-push、也不会 amend 已有分支:那会让别人已经看过的\n'
537
+ + ' 内容在他不知情时变掉。\n'
538
+ + ' ⚠️ 分支名带了内容摘要,所以同名意味着**同一份字节**已经推上去过了。\n'
539
+ + ' 怎么办:去 GitHub 上看那个分支(也许 PR 已经开着了);\n'
540
+ + ' 确实要重来就手动删掉它,再重跑本命令。',
541
+ { branch, sha, exitCode: 3 },
542
+ );
543
+ }
544
+
545
+ /**
546
+ * 建 blob → tree → commit,**最后**一次性建 ref。
547
+ *
548
+ * 🔴 顺序是安全语义的一部分:ref 之前的三步产出的都是**不可见的 object**,
549
+ * 中途失败在仓库上什么都看不见。反过来(先建分支再一个个写文件 ——
550
+ * Contents API 的形状)失败会留下**半棵可见的投稿树**,而我们的
551
+ * 「同名分支只读、不改写」策略会让那棵半成品谁也收拾不了。
552
+ *
553
+ * 🔴 `mode` 必须原样带过去(`100644` / `100755`)。Contents API 没有这个字段,
554
+ * 那正是它不能用的根本原因:mode 进 `tree_digest`(同字节不同 mode 是两个
555
+ * 不同摘要),也进 capability 语义(`executableEvidence` 看的就是可执行位)。
556
+ */
557
+ export async function pushSubmission(client, {
558
+ login, branch, files, baseTreeSha, baseCommitSha, message,
559
+ }) {
560
+ const fp = forkPathFor(login);
561
+
562
+ const treeItems = [];
563
+ for (const f of files) {
564
+ if (f.mode !== 0o644 && f.mode !== 0o755) {
565
+ throw new RemoteError(`内部错误:${f.repoPath} 的 mode 是 0${f.mode.toString(8)},只允许 0644/0755`);
566
+ }
567
+ const b = await client.post(`${fp}/git/blobs`, {
568
+ content: f.data.toString('base64'), encoding: 'base64',
569
+ });
570
+ const sha = b.json?.sha;
571
+ if (typeof sha !== 'string') throw new RemoteError(`建 blob 失败:${f.repoPath} 没有拿到 sha`);
572
+ treeItems.push({
573
+ path: f.repoPath, mode: f.mode === 0o755 ? '100755' : '100644', type: 'blob', sha,
574
+ });
575
+ }
576
+
577
+ const tr = await client.post(`${fp}/git/trees`, { base_tree: baseTreeSha, tree: treeItems });
578
+ const newTreeSha = tr.json?.sha;
579
+ if (typeof newTreeSha !== 'string') throw new RemoteError('建 tree 失败:没有拿到 sha');
580
+
581
+ const cm = await client.post(`${fp}/git/commits`, {
582
+ message, tree: newTreeSha, parents: [baseCommitSha],
583
+ });
584
+ const commitSha = cm.json?.sha;
585
+ if (typeof commitSha !== 'string') throw new RemoteError('建 commit 失败:没有拿到 sha');
586
+
587
+ try {
588
+ await client.post(`${fp}/git/refs`, { ref: `refs/heads/${branch}`, sha: commitSha });
589
+ } catch (e) {
590
+ if (e instanceof ApiHttpError && e.status === 422) {
591
+ throw new RemoteError(
592
+ `建分支 ${branch} 时它已经存在了(422)—— 拒绝改写。\n`
593
+ + ` 你要的那个 commit 已经建好了(${commitSha}),但**没有**任何分支指向它,\n`
594
+ + ' 所以仓库上看不到任何变化。请手动处理已有分支后重跑。',
595
+ { exitCode: 3 },
596
+ );
597
+ }
598
+ if (e?.timedOut === true) {
599
+ // 🔴 建 ref 超时:**读回**。ref 是三个非幂等 POST 里唯一能被读回**确证**的 ——
600
+ // 它指向的 sha 要么正是我们这次建的 commit(成功),要么是别的(冲突)。
601
+ // fork 与 PR 没有这种"内容自证"的性质,所以它们只能以网络错误退出。
602
+ const now = await client.get(`${fp}/git/ref/heads/${encodeURI(branch)}`, { softStatus: [404] });
603
+ if (now.status === 200 && now.json?.object?.sha === commitSha) {
604
+ return { commitSha, newTreeSha, treeItems };
605
+ }
606
+ if (now.status === 200) {
607
+ throw new RemoteError(
608
+ `建分支 ${branch} 超时后读回,它指向 ${now.json?.object?.sha},不是我们建的 ${commitSha}`
609
+ + ' —— 拒绝改写。',
610
+ { exitCode: 3 },
611
+ );
612
+ }
613
+ }
614
+ throw e;
615
+ }
616
+ return { commitSha, newTreeSha, treeItems };
617
+ }
618
+
619
+ // ── 写完之后的核对 ──────────────────────────────────────────────────────────
620
+
621
+ /**
622
+ * 🔴🔴 **这里是最容易写成「看起来守住了」的地方,所以判据要说清楚。**
623
+ *
624
+ * 一个**不够**的写法(我第一版就是它,Codex 2026-09-05 挑出来的):
625
+ * 用 `GET /compare/main...<login>:<branch>` 断言 `files` 恰好是我们那批路径。
626
+ * 它有两个致命盲区:
627
+ * · `compare` 的 `files` **最多 300 条且不分页**,而载荷最多允许 2000 个文件
628
+ * (`src/untar.mjs` 的 MAX_FILES)—— 超过 300 之后它给的就是一个截断的清单,
629
+ * 而"清单里没有别的东西"在截断下毫无意义;
630
+ * · `compare` **看不见 mode**。而 mode 正是我们放弃 Contents API 的全部理由。
631
+ * 再补一次整仓 `?recursive=1` 也不行:它同样会随仓库增长而截断,
632
+ * 而"先拿到结果再过滤前缀"已经太晚 —— 被截掉的可能正是要找的那部分。
633
+ *
634
+ * **够的写法(本函数)**:沿着 tree 结构逐层证明,每一层都不可能被截断。
635
+ * ① ref 指向我们建的那个 commit;
636
+ * ② 那个 commit 的**唯一** parent 是固定的 base commit,tree 是我们建的那棵;
637
+ * ③ 从 root 起逐层比 base tree 与 new tree:**除了通往投稿目录的那一条链,
638
+ * 每一层的其余条目 `path + type + mode + sha` 全等** ——
639
+ * 这证明"没有动别的任何东西",且不依赖任何会截断的清单;
640
+ * ④ 投稿目录那一棵递归读(≤ 2000 条,且查 truncated),
641
+ * blob 的 `path + mode + sha` 集合与我们上传的集合**严格相等**。
642
+ *
643
+ * ⚠️ 比较一律按**集合 + 计数**,不依赖 GitHub 返回条目的顺序。
644
+ * ⚠️ 核对失败时**不自动清理**分支:不 force-push / 不删是硬约束,
645
+ * 而且一个核对不过的分支正是需要人去看的东西。
646
+ */
647
+ export async function verifyBranch(client, {
648
+ login, branch, segments, treeItems, commitSha, newTreeSha, baseCommitSha, baseTreeSha,
649
+ }) {
650
+ const fp = forkPathFor(login);
651
+ const stop = (msg, extra = '') => {
652
+ throw new RemoteError(
653
+ `🔴 核对失败:${msg}\n${extra}`
654
+ + ` ⚠️ 分支 ${branch} 已经建出来了,本命令**不会**自动删它(不 force-push 是硬约束)。\n`
655
+ + ' 请人工检查后手动处理。**没有**开 PR。',
656
+ { exitCode: 2 },
657
+ );
658
+ };
659
+
660
+ // ① ref → commit
661
+ const ref = (await client.get(`${fp}/git/ref/heads/${encodeURI(branch)}`)).json ?? {};
662
+ if (ref.object?.sha !== commitSha) {
663
+ stop(`分支 ${branch} 指向 ${ref.object?.sha},不是我们建的 ${commitSha}`);
664
+ }
665
+
666
+ // ② commit → parent / tree
667
+ const cm = (await client.get(`${fp}/git/commits/${seg(commitSha)}`)).json ?? {};
668
+ const parents = Array.isArray(cm.parents) ? cm.parents.map((p) => p.sha) : [];
669
+ if (parents.length !== 1 || parents[0] !== baseCommitSha) {
670
+ stop(`commit 的 parent 是 [${parents.join(', ')}],应当恰好是 [${baseCommitSha}]`);
671
+ }
672
+ if (cm.tree?.sha !== newTreeSha) stop(`commit 的 tree 是 ${cm.tree?.sha},不是 ${newTreeSha}`);
673
+
674
+ // ③ 逐层比:除了通往投稿目录那一条链,其余全等
675
+ let baseSha = baseTreeSha;
676
+ let newSha = newTreeSha;
677
+ for (let i = 0; i < segments.length; i += 1) {
678
+ const name = segments[i];
679
+ const where = segments.slice(0, i).join('/') || '<仓库根>';
680
+ const baseEntries = baseSha === null ? [] : await getTree(client, UP, baseSha);
681
+ const newEntries = await getTree(client, fp, newSha);
682
+
683
+ const key = (e) => `${e.path}\0${e.type}\0${e.mode}\0${e.sha}`;
684
+ const baseRest = baseEntries.filter((e) => e.path !== name).map(key).sort();
685
+ const newRest = newEntries.filter((e) => e.path !== name).map(key).sort();
686
+ if (baseRest.length !== newRest.length || baseRest.some((x, k) => x !== newRest[k])) {
687
+ const only = (a, b) => a.filter((x) => !b.includes(x)).map((x) => x.split('\0')[0]);
688
+ stop(
689
+ `${where} 这一层,除了 ${name} 之外还动了别的条目。`,
690
+ ` 多出来:${only(newRest, baseRest).join('、') || '(无)'}\n`
691
+ + ` 少掉了:${only(baseRest, newRest).join('、') || '(无)'}\n`,
692
+ );
693
+ }
694
+
695
+ const newChild = newEntries.find((e) => e.path === name);
696
+ if (newChild === undefined || newChild.type !== 'tree') {
697
+ stop(`新树里 ${where}/${name} 不存在或不是目录`);
698
+ }
699
+ const baseChild = baseEntries.find((e) => e.path === name);
700
+ // 🔴 最后一段(投稿目录本身)**必须是新增的** —— 它在 base 上存在就是覆盖。
701
+ if (i === segments.length - 1 && baseChild !== undefined) {
702
+ stop(`${segments.join('/')} 在上游 base 上已经存在 —— publish 只新增,绝不覆盖。`);
703
+ }
704
+ baseSha = baseChild === undefined ? null : baseChild.sha;
705
+ newSha = newChild.sha;
706
+ }
707
+
708
+ // ④ 投稿目录那一棵:递归读,blob 集合严格相等
709
+ const leaf = await getTree(client, fp, newSha, { recursive: true });
710
+ const prefix = `${segments.join('/')}/`;
711
+ const nonBlob = leaf.filter((e) => e.type !== 'blob' && e.type !== 'tree');
712
+ if (nonBlob.length > 0) {
713
+ stop(`投稿目录里有非文件条目:${nonBlob.map((e) => `${e.path}(${e.type})`).join('、')}`);
714
+ }
715
+ const want = new Map(treeItems.map((t) => [t.path, `${t.mode}\0${t.sha}`]));
716
+ const got = new Map(leaf.filter((e) => e.type === 'blob').map((e) => [prefix + e.path, `${e.mode}\0${e.sha}`]));
717
+ if (want.size !== got.size) {
718
+ stop(`投稿目录下有 ${got.size} 个文件,我们上传了 ${want.size} 个。`);
719
+ }
720
+ for (const [p, v] of want) {
721
+ const g = got.get(p);
722
+ if (g === undefined) stop(`新树里没有 ${p}`);
723
+ if (g !== v) {
724
+ const [gm, gs] = g.split('\0');
725
+ const [wm, ws] = v.split('\0');
726
+ stop(
727
+ gm !== wm ? `${p} 的 mode 是 ${gm},我们要的是 ${wm}` : `${p} 的 blob sha 是 ${gs},我们要的是 ${ws}`,
728
+ gm !== wm ? ' mode 进 tree_digest —— 它错了,制品身份就是另一个。\n' : '',
729
+ );
730
+ }
731
+ }
732
+ return { commitSha, files: [...want.keys()] };
733
+ }
734
+
735
+ // ── PR ──────────────────────────────────────────────────────────────────────
736
+
737
+ /** 已有的、head 指向这个分支的 PR(任何状态)。POST 之前查、POST 超时后也查。 */
738
+ export async function findPrForHead(client, { login, branch }) {
739
+ const r = await client.get(`${UP}/pulls?state=all&head=${seg(`${login}:${branch}`)}`);
740
+ const arr = Array.isArray(r.json) ? r.json : [];
741
+ return arr.length > 0 ? arr[0] : null;
742
+ }
743
+
744
+ export async function openPullRequest(client, {
745
+ login, branch, title, body, viewer, forkId, upstreamId, commitSha,
746
+ }) {
747
+ // 🔴 POST 之前先查:`POST /pulls` 不是幂等的,重发会开出第二个 PR。
748
+ const already = await findPrForHead(client, { login, branch });
749
+ if (already !== null) {
750
+ throw new RemoteError(
751
+ `这个分支上已经有 PR #${already.number}(${already.state}):${already.html_url}\n`
752
+ + ' 🔴 本命令**不会**再开一个,也不会改写已有的那个 —— 只读取、核对、报告。',
753
+ { exitCode: 3, prNumber: already.number, prUrl: already.html_url },
754
+ );
755
+ }
756
+
757
+ let pr;
758
+ try {
759
+ pr = (await client.post(`${UP}/pulls`, {
760
+ title, body, head: `${login}:${branch}`, base: BASE_BRANCH, maintainer_can_modify: true,
761
+ })).json;
762
+ } catch (e) {
763
+ if (e?.timedOut !== true) throw e;
764
+ // 🔴 超时之后**先查再说**,绝不盲目重发。
765
+ // ⚠️ 查不到**不等于**那个 POST 没到 GitHub —— 所以这里以网络错误退出、
766
+ // 请用户重跑(重跑会先查,看到就不再开),而不是自己再发一次。
767
+ const found = await findPrForHead(client, { login, branch });
768
+ if (found === null) {
769
+ throw new RemoteError(
770
+ `开 PR 的请求超时,且随后查不到 head=${login}:${branch} 的 PR。\n`
771
+ + ' 🔴 **这不能证明那个请求没有到达 GitHub。** 拒绝重发(重发可能开出第二个 PR)。\n'
772
+ + ` 分支 ${branch} 已经推上去了。请去 ${UPSTREAM_FULL} 看一眼;\n`
773
+ + ' 确认没有 PR 之后重跑本命令(它会先查,看到已有的就不再开)。',
774
+ { exitCode: 6 },
775
+ );
776
+ }
777
+ pr = found;
778
+ }
779
+
780
+ return assertPr(pr, { branch, viewer, forkId, upstreamId, commitSha });
781
+ }
782
+
783
+ /**
784
+ * PR 建完之后的核对 —— 六条,**全部用不可变 id / 精确字符串**。
785
+ *
786
+ * 🔴 为什么要核对我们自己刚发的东西:`head: "<login>:<branch>"` 是一个**字符串**,
787
+ * 服务端怎么解读它(login 与某个 org 重名、分支名含特殊字符、fork 被改名)
788
+ * 不是我们说了算。核对的是**结果**,不是我们的意图。
789
+ */
790
+ export function assertPr(pr, { branch, viewer, forkId, upstreamId, commitSha }) {
791
+ if (pr === null || typeof pr !== 'object') throw new RemoteError('开 PR 之后没有拿到 PR 对象', { exitCode: 2 });
792
+ const fail = (msg) => {
793
+ throw new RemoteError(
794
+ `🔴 PR #${pr.number} 建出来了,但核对不过:${msg}\n`
795
+ + ` ${pr.html_url ?? ''}\n`
796
+ + ' 本命令**不会**自动关掉或改写它。请人工检查。',
797
+ { exitCode: 2, prNumber: pr.number, prUrl: pr.html_url },
798
+ );
799
+ };
800
+ // 🔴 同 `ensureFork`:id 两边都缺席时 `!==` 退化成恒真。先把判据本身钉成整数。
801
+ for (const [what, v] of [['fork id', forkId], ['上游 repo id', upstreamId], ['认证用户 id', viewer.id],
802
+ ['PR 的 head.repo.id', pr.head?.repo?.id], ['PR 的 base.repo.id', pr.base?.repo?.id],
803
+ ['PR 作者 id', pr.user?.id]]) {
804
+ if (typeof v !== 'number' || !Number.isInteger(v)) fail(`${what} 不是整数(得到 ${JSON.stringify(v)})`);
805
+ }
806
+ if (pr.head?.repo?.id !== forkId) fail(`head 仓库 id 是 ${pr.head?.repo?.id},应为你的 fork(${forkId})`);
807
+ if (pr.head?.ref !== branch) fail(`head 分支是 ${JSON.stringify(pr.head?.ref)},应为 ${branch}`);
808
+ if (pr.head?.sha !== commitSha) fail(`head sha 是 ${pr.head?.sha},应为 ${commitSha}`);
809
+ if (pr.base?.repo?.id !== upstreamId) fail(`base 仓库 id 是 ${pr.base?.repo?.id},应为 ${UPSTREAM_FULL}(${upstreamId})`);
810
+ if (pr.base?.ref !== BASE_BRANCH) fail(`base 分支是 ${JSON.stringify(pr.base?.ref)},应为 ${BASE_BRANCH}`);
811
+ if (pr.user?.id !== viewer.id) fail(`PR 作者 id 是 ${pr.user?.id},应为认证用户 ${viewer.id}`);
812
+ return { number: pr.number, url: pr.html_url, state: pr.state };
813
+ }