@geoly-ai/skills-hub 0.3.6 → 0.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -2
- package/scripts/submission/promotion-file.mjs +246 -0
- package/scripts/submission/run-gates.mjs +250 -0
- package/scripts/submission/scan-text.mjs +363 -0
- package/scripts/submission/structural-gates.mjs +336 -0
- package/src/cli.mjs +7 -0
- package/src/commands/publish.mjs +400 -0
- package/src/publish/github.mjs +223 -0
- package/src/publish/payload.mjs +241 -0
- package/src/publish/remote.mjs +813 -0
- package/src/publish/token.mjs +244 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
// `skills-hub publish` —— 把「fork、建目录、算摘要、开 PR」这一串变成一条命令。
|
|
2
|
+
//
|
|
3
|
+
// ── 🔴 授权模型(用户 2026-09-05 拍板,不要重新设计)─────────────────────
|
|
4
|
+
//
|
|
5
|
+
// **不注册 OAuth App / GitHub App,直接用用户已有的 GitHub token,
|
|
6
|
+
// 不做 `login` / `logout`。** 决策记录见 `docs/m0/10-open-questions.md` Q4
|
|
7
|
+
// 与 `docs/m0/06-submission.md` §9 的偏离说明。
|
|
8
|
+
// 理由:注册应用是一次性且不可逆的公开面。
|
|
9
|
+
//
|
|
10
|
+
// 这个决定的代价必须**说出来**,而不是藏起来:用户已有的 token 很可能是
|
|
11
|
+
// classic `repo` PAT,权限面**比 `public_repo` 还大**,而 CLI **无法收窄它**。
|
|
12
|
+
// 所以本命令在任何写操作之前,把权限面摆到用户脸上并要求确认(§6 `disclose`)。
|
|
13
|
+
//
|
|
14
|
+
// ── 🔴 两条不可打折的实现约束 ────────────────────────────────────────────
|
|
15
|
+
//
|
|
16
|
+
// ① **全走 GitHub API,一次 `git` 都不用。** 带 token 的 `git push` 会让本地
|
|
17
|
+
// `pre-push` hook 拿到 token —— 那是任意代码执行 + 凭据泄漏。
|
|
18
|
+
// ② **Git Data API,不是 Contents API。** Contents API 没有设置 mode 的字段,
|
|
19
|
+
// `0755` 会被静默写成 `0644`;而 mode 进 `tree_digest`(同字节不同 mode 是
|
|
20
|
+
// 两个不同摘要),也进 capability 语义。用 Contents API 投稿 =
|
|
21
|
+
// 静默改掉制品身份。
|
|
22
|
+
//
|
|
23
|
+
// ── 📌 本地校验只是 UX,不是信任门 ───────────────────────────────────────
|
|
24
|
+
// 真正的判定在服务端的 PR gate(`validate-pr.yml` 从 base 那棵树跑校验器)。
|
|
25
|
+
// 这里跑的是**同一批**校验器(`scripts/submission/*`),目的只是让投稿者在
|
|
26
|
+
// 开 PR 之前就看到会红的地方。`--yes` 跳过的**只有**权限风险确认那一问,
|
|
27
|
+
// 不跳过这里任何一道校验。
|
|
28
|
+
|
|
29
|
+
import { rmSync, readFileSync } from 'node:fs';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
import { resolve as resolvePath } from 'node:path';
|
|
32
|
+
|
|
33
|
+
import { EXIT, UsageError } from '../exit-codes.mjs';
|
|
34
|
+
import { resolveToken } from '../publish/token.mjs';
|
|
35
|
+
import {
|
|
36
|
+
createClient, UPSTREAM_FULL, UPSTREAM_REPO, BASE_BRANCH, API_ORIGIN,
|
|
37
|
+
} from '../publish/github.mjs';
|
|
38
|
+
import { readIdentity, stagePayload, runLocalGates, PayloadError } from '../publish/payload.mjs';
|
|
39
|
+
import {
|
|
40
|
+
getViewer, getBaseline, ensureFork, findOpenDuplicates, branchName,
|
|
41
|
+
assertBranchFree, pushSubmission, verifyBranch, openPullRequest, RemoteError,
|
|
42
|
+
} from '../publish/remote.mjs';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 06-submission.md §9 里仍然成立的一条:**以 `npx github:` 运行时 publish 拒绝执行**。
|
|
46
|
+
*
|
|
47
|
+
* 判据:npm 从 git 安装一个包时,会往安装下来的 `package.json` 里写
|
|
48
|
+
* `gitHead`(commit sha)或把 `_resolved` 写成 `git+https://…`。
|
|
49
|
+
* 从 registry 装的包两者都没有。
|
|
50
|
+
*
|
|
51
|
+
* ⚠️ **诚实边界:这是尽力而为,不是完备判据。**
|
|
52
|
+
* · npm 的哪些版本写哪个字段没有正式契约,改了不会有任何东西变红;
|
|
53
|
+
* · 直接 `node /path/to/checkout/bin/skills-hub.mjs publish` 从**源码树**跑,
|
|
54
|
+
* 两个字段都没有 —— 本函数放行,而那其实也是"未经 registry 分发的代码"。
|
|
55
|
+
* 这道门挡的是最常见的那一种(`npx github:geoly-ai/skills-hub publish`),
|
|
56
|
+
* 不要把它当成"只有签过名的 CLI 才能投稿"的保证。真正的保证在服务端。
|
|
57
|
+
*/
|
|
58
|
+
export function looksLikeGitInstall(pkgJsonText) {
|
|
59
|
+
let doc;
|
|
60
|
+
try { doc = JSON.parse(pkgJsonText); } catch { return false; }
|
|
61
|
+
if (typeof doc.gitHead === 'string' && doc.gitHead !== '') return true;
|
|
62
|
+
if (typeof doc._resolved === 'string' && /^git[+:]/.test(doc._resolved)) return true;
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function ownPackageJsonPath() {
|
|
67
|
+
return fileURLToPath(new URL('../../package.json', import.meta.url));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* publish 自己的 flag —— **不进 `commands/context.mjs`**(那里只放全局 flag,
|
|
72
|
+
* 且 `test/flag-doc-parity.test.mjs` 只扫那个文件)。命令面按
|
|
73
|
+
* `09-cli.md` §1 的既有规格:`publish [path] [--pack]`,另加 `--dry-run`。
|
|
74
|
+
*
|
|
75
|
+
* 🔴 `--pack` 是一条**断言**,不是提示:它说「我期望这是一个 pack」。
|
|
76
|
+
* 与实际 manifest 不符即失败。写成"只是个提示、以 manifest 为准"的话,
|
|
77
|
+
* 它就是一个用户以为自己设了、实际什么都没设的开关。
|
|
78
|
+
*/
|
|
79
|
+
function parseArgs(args) {
|
|
80
|
+
const o = { path: null, pack: false, dryRun: false };
|
|
81
|
+
for (const a of args) {
|
|
82
|
+
if (a === '--dry-run') { o.dryRun = true; continue; }
|
|
83
|
+
if (a === '--pack') { o.pack = true; continue; }
|
|
84
|
+
if (a.startsWith('-')) {
|
|
85
|
+
throw new UsageError(
|
|
86
|
+
`publish 不认得 ${a}。可用:publish [path] [--pack] [--dry-run]`
|
|
87
|
+
+ '(全局 --yes / --json 照常可用)',
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
if (o.path !== null) throw new UsageError('publish 只接受一个载荷目录');
|
|
91
|
+
o.path = a;
|
|
92
|
+
}
|
|
93
|
+
return o;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 权限披露。**在任何写请求之前**,且**不受 `--yes` 影响** ——
|
|
98
|
+
* `--yes` 只是免掉那一问,不是免掉这一屏。
|
|
99
|
+
*
|
|
100
|
+
* 🔴 为什么这一屏必须存在:我们**无法收窄**用户的 token。
|
|
101
|
+
* 一个 classic `repo` PAT 能读写他**所有**私有仓库;`public_repo` 能写他
|
|
102
|
+
* 所有公开仓库。用户交给我们的权限,远大于「给 skills-hub 投一个 skill」
|
|
103
|
+
* 所需 —— 那是这个授权模型的真实代价,藏起来就是在替他做决定。
|
|
104
|
+
*/
|
|
105
|
+
export function disclosureLines({ tokenInfo, viewer, plan }) {
|
|
106
|
+
const L = [];
|
|
107
|
+
L.push('');
|
|
108
|
+
L.push('🔴 权限面披露 —— 请在继续之前读完');
|
|
109
|
+
L.push('─'.repeat(64));
|
|
110
|
+
L.push(` token 来源 ${tokenInfo.sourceLabel}`);
|
|
111
|
+
if (tokenInfo.others.length > 0) {
|
|
112
|
+
L.push(` ⚠️ 同时存在 ${tokenInfo.others.join('、')}(值相同,本次未使用它们)`);
|
|
113
|
+
}
|
|
114
|
+
L.push(` 认证身份 ${viewer.login}(node_id ${viewer.nodeId},id ${viewer.id})`);
|
|
115
|
+
L.push(` 可见 scope ${viewer.scopes === null
|
|
116
|
+
? '看不见(fine-grained token 通常不回 x-oauth-scopes 头)'
|
|
117
|
+
: (viewer.scopes.length === 0 ? '(空 —— 多半是 fine-grained token)' : viewer.scopes.join(', '))}`);
|
|
118
|
+
L.push('');
|
|
119
|
+
L.push(' 这个 token 能做什么(CLI **无法**收窄它):');
|
|
120
|
+
L.push(' · classic `repo` PAT → 可读写你**所有**仓库,含**私有**仓库');
|
|
121
|
+
L.push(' · classic `public_repo` → 可写你**所有**公开仓库');
|
|
122
|
+
L.push(' · fine-grained → 以你建它时授予的仓库与权限为准(这里看不见)');
|
|
123
|
+
L.push(' 🔴 本命令不持有、不存储、不上传你的 token —— 它只活在这个进程里。');
|
|
124
|
+
L.push(' 但「我们不持有」**不等于**「它权限很小」。要收窄,请另建一个');
|
|
125
|
+
L.push(` fine-grained token,只授权你的 fork(${viewer.login}/${UPSTREAM_REPO})`);
|
|
126
|
+
L.push(' 与上游的 Contents(读) + Pull requests(写)。');
|
|
127
|
+
L.push('');
|
|
128
|
+
L.push(' 本次将要执行的**写**操作:');
|
|
129
|
+
L.push(` 1. 若无 fork → 在 ${API_ORIGIN} 建 ${viewer.login}/${UPSTREAM_REPO}(fork 自 ${UPSTREAM_FULL})`);
|
|
130
|
+
L.push(` 2. 在你的 fork 上建 ${plan.files.length} 个 blob + 1 棵 tree + 1 个 commit`);
|
|
131
|
+
L.push(` 3. 建分支 ${plan.branch}(**最后一步**才建,中途失败仓库上看不到任何东西)`);
|
|
132
|
+
L.push(` 4. 向 ${UPSTREAM_FULL} 的 ${BASE_BRANCH} 开一个 PR,并置`);
|
|
133
|
+
L.push(' `maintainer_can_modify: true` —— ⚠️ 这**额外授予上游维护者**修改');
|
|
134
|
+
L.push(` 你 fork 上这个分支的权利(只限这个分支)。GitHub 的 PR 就是这么协作的,`);
|
|
135
|
+
L.push(' 但它是一项你正在给出的权限,不该藏在默认值里。');
|
|
136
|
+
L.push(' 🔴 不 force-push、不 amend、不改写任何已存在的分支或 PR。');
|
|
137
|
+
L.push('');
|
|
138
|
+
L.push(` 写入路径(只有这些,全部在 ${plan.prefix} 之下):`);
|
|
139
|
+
for (const f of plan.files) L.push(` ${f.mode === 0o755 ? '0755' : '0644'} ${f.repoPath}`);
|
|
140
|
+
L.push('');
|
|
141
|
+
L.push(` tree_digest ${plan.treeDigest}`);
|
|
142
|
+
L.push('─'.repeat(64));
|
|
143
|
+
return L;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function readLine(stdin) {
|
|
147
|
+
return new Promise((resolve) => {
|
|
148
|
+
if (stdin === null || stdin === undefined) { resolve(''); return; }
|
|
149
|
+
if (stdin.readableEnded === true || stdin.destroyed === true) { resolve(''); return; }
|
|
150
|
+
let buf = '';
|
|
151
|
+
const done = () => { stdin.off?.('data', onData); resolve(buf); };
|
|
152
|
+
const onData = (c) => {
|
|
153
|
+
buf += String(c);
|
|
154
|
+
if (buf.includes('\n')) done();
|
|
155
|
+
};
|
|
156
|
+
stdin.on('data', onData);
|
|
157
|
+
stdin.once('end', done);
|
|
158
|
+
stdin.once('close', done);
|
|
159
|
+
stdin.once('error', done);
|
|
160
|
+
stdin.resume?.();
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 🔴 `--yes` **只**代表「我读过权限披露、接受这个风险」。
|
|
166
|
+
* 它不跳过本地结构门、不跳过重复投稿检测、不跳过 fork/PR 的事后核对。
|
|
167
|
+
* 这一点写在这里,也写在 `--help` 里 —— 因为「一个 flag 到底关掉了什么」
|
|
168
|
+
* 是用户唯一没法从行为上观察到的东西(他只会看到"过了")。
|
|
169
|
+
*/
|
|
170
|
+
async function confirm(ctx, out) {
|
|
171
|
+
const tty = ctx.stdin?.isTTY === true;
|
|
172
|
+
if (ctx.yes) {
|
|
173
|
+
out.note('已给 --yes:视为**确认了上面的权限风险**。⚠️ 它不跳过任何一道校验。');
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (!tty) {
|
|
177
|
+
throw new UsageError(
|
|
178
|
+
'非交互环境下必须显式给 --yes 才能继续 —— 它表示你读过上面的权限披露并接受风险。\n'
|
|
179
|
+
+ ' 🔴 --yes **不跳过**任何一道校验,也不跳过 fork / PR 的事后核对。',
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
out.line('');
|
|
183
|
+
out.line('请输入 publish 以确认(回车不算确认):');
|
|
184
|
+
const answer = (await readLine(ctx.stdin)).trim();
|
|
185
|
+
if (answer !== 'publish') {
|
|
186
|
+
throw new UsageError(
|
|
187
|
+
`未确认:需要输入 publish,实际得到 ${JSON.stringify(answer)}。什么都没做。`,
|
|
188
|
+
{ telemetryReason: 'user-abort' },
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function cmdPublish(ctx, args, out) {
|
|
194
|
+
const o = parseArgs(args);
|
|
195
|
+
|
|
196
|
+
// ── 前置守卫(都在任何网络请求之前)────────────────────────────────────
|
|
197
|
+
if (ctx.offline) {
|
|
198
|
+
throw new UsageError(
|
|
199
|
+
'publish 需要网络(它要读上游 base、建 fork、开 PR),与 --offline 互斥。\n'
|
|
200
|
+
+ ' 🔴 --offline 承诺的是「整个 CLI 不得有任何网络出口」,不是「尽量少出网」。',
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
let ownPkg = '';
|
|
204
|
+
try { ownPkg = readFileSync(ownPackageJsonPath(), 'utf8'); } catch { ownPkg = ''; }
|
|
205
|
+
if (looksLikeGitInstall(ownPkg)) {
|
|
206
|
+
throw new UsageError(
|
|
207
|
+
'拒绝以 `npx github:…`(从 git 安装的副本)运行 publish —— 06-submission.md §9。\n'
|
|
208
|
+
+ ' 🔴 这条命令会拿你的 GitHub token 去做写操作;那份代码没有经过 npm 的\n'
|
|
209
|
+
+ ' 发布链(provenance / 签名 / 版本不可变),我们不该请你把凭据交给它。\n'
|
|
210
|
+
+ ' 请用 `npx @geoly-ai/skills-hub publish`。',
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── 载荷:读一次盘,之后全用这份字节 ──────────────────────────────────
|
|
215
|
+
const payloadDir = resolvePath(ctx.cwd, o.path ?? '.');
|
|
216
|
+
const identity = readIdentity(payloadDir);
|
|
217
|
+
// 🔴 `--pack` 是断言:不符就失败,不是"以 manifest 为准"地静默忽略。
|
|
218
|
+
const wantKind = o.pack ? 'pack' : 'skill';
|
|
219
|
+
if (identity.kind !== wantKind) {
|
|
220
|
+
throw new UsageError(
|
|
221
|
+
`载荷是一个 ${identity.kind}(根目录里是 ${identity.kind}.json),`
|
|
222
|
+
+ `但${o.pack ? '你给了 --pack' : '你没给 --pack'} —— 期望的是 ${wantKind}。\n`
|
|
223
|
+
+ ` ${o.pack ? '去掉 --pack' : '加上 --pack'} 再重跑,或者检查载荷目录是不是指错了。`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
const staged = stagePayload(payloadDir, identity);
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
// ── token(不落盘、不进 argv、不进子进程 env)────────────────────────
|
|
230
|
+
// 🔴 **不包 catch-all。** `resolveToken` 只抛 `TokenError`;用
|
|
231
|
+
// `catch { throw new TokenError(...) }` 兜住的话,一个 CLI 自身的 bug
|
|
232
|
+
// 会被伪装成「你的认证有问题」,而用户会去查他的 token —— 查不出任何东西。
|
|
233
|
+
const tokenInfo = resolveToken({ env: ctx.env });
|
|
234
|
+
|
|
235
|
+
const client = createClient({
|
|
236
|
+
token: tokenInfo.token,
|
|
237
|
+
// 🔴 显式传:`createClient` 不做隐式回退。于是"用了真 fetch"是一个
|
|
238
|
+
// 写得出来的动作,测试忘了注入不会静默出网。
|
|
239
|
+
fetchImpl: ctx.fetchImpl ?? globalThis.fetch,
|
|
240
|
+
userAgent: `skills-hub/${ctx.cliVersion} (+https://github.com/${UPSTREAM_FULL})`,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// ── 基线(只读)──────────────────────────────────────────────────────
|
|
244
|
+
// 🔴 顺序:基线 → 本地门 → `/user` → 重复检测 → 披露 → 确认 → 写。
|
|
245
|
+
// `/user` 排在本地门**之后**是有意的:一个连结构门都过不了的投稿,
|
|
246
|
+
// 不该先让我们去问「你是谁」——那次查询会把用户的身份连同 token 发出去,
|
|
247
|
+
// 而它对这次失败没有任何用处。
|
|
248
|
+
// ⚠️ 401/403 在这一路上**不换下一个 token 来源重试**:
|
|
249
|
+
// 那会让用户看到的身份与实际投稿身份不同。
|
|
250
|
+
const segments = staged.prefix.slice(0, -1).split('/'); // ['submissions', ns, 'name@ver']
|
|
251
|
+
const baseline = await getBaseline(client, { submissionSegments: segments });
|
|
252
|
+
|
|
253
|
+
// ── 本地门(跑的是服务端那一批,不是另写的一套)──────────────────────
|
|
254
|
+
const gates = runLocalGates({ staging: staged.staging, baseline });
|
|
255
|
+
const problems = [...gates.problems];
|
|
256
|
+
|
|
257
|
+
// base 里已经有这条路径 → 拒(禁止覆盖 base 中已存在的路径)
|
|
258
|
+
if (baseline.existingSubmission !== null) {
|
|
259
|
+
problems.push(
|
|
260
|
+
`${staged.prefix} 在上游 ${BASE_BRANCH} 上已经存在 —— publish 只新增,绝不覆盖已有路径。`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
if (gates.scanErrors > 0) {
|
|
264
|
+
problems.push(`载荷里有 ${gates.scanErrors} 处**服务端会拒**的字符问题(见下面的扫描结果)`);
|
|
265
|
+
}
|
|
266
|
+
for (const l of gates.scanLines) out.warn({ kind: 'scan-text', message: l });
|
|
267
|
+
|
|
268
|
+
if (problems.length > 0) {
|
|
269
|
+
throw new PayloadError(
|
|
270
|
+
// ⚠️ 措辞要准:到这里为止已经发过若干**只读** GET(取基线要用),
|
|
271
|
+
// 但**一个写请求都没有**。说成「没有发出任何请求」是不实的。
|
|
272
|
+
`${problems.length} 处不合规,**没有**发出任何写请求(fork / 分支 / PR 都没建):\n`
|
|
273
|
+
+ problems.map((p) => ` · ${p}`).join('\n')
|
|
274
|
+
+ '\n 📌 这批门与服务端 PR gate 跑的是同一批实现,所以本地过了 CI 大概率也过。',
|
|
275
|
+
{ violations: problems.map((m) => ({ code: 'E_PUBLISH_GATE', message: m })) },
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// 🔴 provenance 是**可选**的(8b9741c 起)。用户自己写了不是错,但值得提醒。
|
|
280
|
+
if (identity.kind === 'skill' && identity.manifest.provenance !== undefined) {
|
|
281
|
+
out.warn({
|
|
282
|
+
kind: 'provenance-declared',
|
|
283
|
+
message:
|
|
284
|
+
'skill.json 里写了 provenance —— 它会被 promote **逐字核对**,'
|
|
285
|
+
+ '而其中 `submitted_by_pr` / `author_github_id` 在 PR 开出来之前根本不存在。\n'
|
|
286
|
+
+ ' 建议直接删掉 provenance:它现在是可选的,promote 会按 PR 事实自动填,'
|
|
287
|
+
+ '省掉一轮回填与重新审批。',
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const branch = branchName({ ...identity, treeDigest: staged.treeDigest });
|
|
292
|
+
const plan = { files: staged.files, prefix: staged.prefix, treeDigest: staged.treeDigest, branch };
|
|
293
|
+
|
|
294
|
+
// ── 身份(披露与 fork 都要它;排在本地门之后,见上面那段说明)────────
|
|
295
|
+
const viewer = await getViewer(client);
|
|
296
|
+
|
|
297
|
+
// ── 重复投稿竞争(读,且**在 fork 之前**)────────────────────────────
|
|
298
|
+
// 🔴 必须早于 fork:撞上重复时不该在用户账号下留一个多余的 fork,
|
|
299
|
+
// `--dry-run` 的「一个写请求都不发」也才是真的。
|
|
300
|
+
const dup = await findOpenDuplicates(client, { submissionSegments: segments });
|
|
301
|
+
if (dup.matches.length > 0) {
|
|
302
|
+
throw new RemoteError(
|
|
303
|
+
`${staged.prefix} 已经在 ${dup.matches.length} 个**开着的** PR 里了:\n`
|
|
304
|
+
+ dup.matches.map((m) => ` · #${m.number} by ${m.author} —— ${m.url}`).join('\n')
|
|
305
|
+
+ '\n 🔴 只读取、核对、报告 —— 本命令不会再开一个,也不改写已有的那个。\n'
|
|
306
|
+
+ ' ⚠️ 只看 artifacts/ 是发现不了这种竞争的:同一个版本可能正躺在一个'
|
|
307
|
+
+ '尚未合并的 PR 里。',
|
|
308
|
+
{ exitCode: 3 },
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── 披露 + 确认(🔴 在任何写请求之前)────────────────────────────────
|
|
313
|
+
for (const l of disclosureLines({ tokenInfo, viewer, plan })) out.line(l);
|
|
314
|
+
|
|
315
|
+
if (o.dryRun) {
|
|
316
|
+
out.line('');
|
|
317
|
+
out.line('--dry-run:到此为止,**一个写请求都没有发**。');
|
|
318
|
+
return out.emit('publish', {
|
|
319
|
+
dry_run: true,
|
|
320
|
+
artifact_id: identity.id,
|
|
321
|
+
tree_digest: staged.treeDigest,
|
|
322
|
+
branch,
|
|
323
|
+
prefix: staged.prefix,
|
|
324
|
+
files: staged.files.map((f) => ({ path: f.repoPath, mode: f.mode === 0o755 ? '100755' : '100644' })),
|
|
325
|
+
token_source: tokenInfo.source,
|
|
326
|
+
viewer: { login: viewer.login, node_id: viewer.nodeId, scopes: viewer.scopes },
|
|
327
|
+
}, EXIT.OK);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
await confirm(ctx, out);
|
|
331
|
+
|
|
332
|
+
// ── 写 ───────────────────────────────────────────────────────────────
|
|
333
|
+
const { repo: fork, created } = await ensureFork(client, {
|
|
334
|
+
viewer, upstreamId: baseline.upstreamId, baseCommitSha: baseline.baseCommitSha,
|
|
335
|
+
});
|
|
336
|
+
if (created) out.note(`已建 fork ${viewer.login}/${UPSTREAM_REPO}`);
|
|
337
|
+
|
|
338
|
+
await assertBranchFree(client, { login: viewer.login, branch });
|
|
339
|
+
|
|
340
|
+
const pushed = await pushSubmission(client, {
|
|
341
|
+
login: viewer.login,
|
|
342
|
+
branch,
|
|
343
|
+
files: staged.files,
|
|
344
|
+
baseTreeSha: baseline.baseTreeSha,
|
|
345
|
+
baseCommitSha: baseline.baseCommitSha,
|
|
346
|
+
message: `投稿 ${identity.id}\n\ntree_digest: ${staged.treeDigest}\n`,
|
|
347
|
+
});
|
|
348
|
+
out.note(`已建 commit ${pushed.commitSha},分支 ${branch}`);
|
|
349
|
+
|
|
350
|
+
// 🔴 写完用**独立的读**证明结果,而不是相信 201。
|
|
351
|
+
await verifyBranch(client, {
|
|
352
|
+
login: viewer.login, branch, segments, ...pushed,
|
|
353
|
+
baseCommitSha: baseline.baseCommitSha, baseTreeSha: baseline.baseTreeSha,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const pr = await openPullRequest(client, {
|
|
357
|
+
login: viewer.login,
|
|
358
|
+
branch,
|
|
359
|
+
title: `投稿 ${identity.id}`,
|
|
360
|
+
body: prBody({ identity, treeDigest: staged.treeDigest, files: staged.files }),
|
|
361
|
+
viewer,
|
|
362
|
+
forkId: fork.id,
|
|
363
|
+
upstreamId: baseline.upstreamId,
|
|
364
|
+
commitSha: pushed.commitSha,
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
out.line('');
|
|
368
|
+
out.line(`✔ PR #${pr.number} 已开:${pr.url}`);
|
|
369
|
+
out.line(' 接下来由服务端的 PR gate 判定 —— 本地那几道门只是提前告诉你会不会红。');
|
|
370
|
+
return out.emit('publish', {
|
|
371
|
+
dry_run: false,
|
|
372
|
+
artifact_id: identity.id,
|
|
373
|
+
tree_digest: staged.treeDigest,
|
|
374
|
+
branch,
|
|
375
|
+
prefix: staged.prefix,
|
|
376
|
+
commit_sha: pushed.commitSha,
|
|
377
|
+
pr: { number: pr.number, url: pr.url, state: pr.state },
|
|
378
|
+
token_source: tokenInfo.source,
|
|
379
|
+
viewer: { login: viewer.login, node_id: viewer.nodeId, scopes: viewer.scopes },
|
|
380
|
+
}, EXIT.OK);
|
|
381
|
+
} finally {
|
|
382
|
+
// 暂存目录里是投稿载荷(公开内容),但它是我们建的,就该我们收。
|
|
383
|
+
try { rmSync(staged.staging, { recursive: true, force: true }); } catch { /* 尽力而为 */ }
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function prBody({ identity, treeDigest, files }) {
|
|
388
|
+
return [
|
|
389
|
+
`## ${identity.id}`,
|
|
390
|
+
'',
|
|
391
|
+
`- kind: \`${identity.kind}\``,
|
|
392
|
+
`- tree_digest: \`${treeDigest}\``,
|
|
393
|
+
`- 文件数: ${files.length}`,
|
|
394
|
+
'',
|
|
395
|
+
'由 `skills-hub publish` 生成。载荷只写在',
|
|
396
|
+
`\`submissions/${identity.namespace}/${identity.name}@${identity.version}/\` 之下。`,
|
|
397
|
+
'',
|
|
398
|
+
'> ⚠️ `provenance` 由 promote 按 PR 事实填 —— 本 PR 未自行声明它。',
|
|
399
|
+
].join('\n');
|
|
400
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// `publish` 的 GitHub API 客户端 —— **这是 publish 唯一的出网口**。
|
|
2
|
+
//
|
|
3
|
+
// ── 🔴 为什么全走 API,一次 `git` 都不用 ────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// 带 token 的 `git push` 会让**本地** `pre-push` hook 拿到那个 token。
|
|
6
|
+
// hook 是仓库里的普通可执行文件,`git` 无条件运行它 —— 于是「投稿一个 skill」
|
|
7
|
+
// 变成了「把你的 GitHub 凭据交给一段任意代码」。
|
|
8
|
+
// 这不是理论风险:投稿者的载荷目录常常就在某个 git 仓库里。
|
|
9
|
+
//
|
|
10
|
+
// ── 🔴 为什么是 Git Data API,不是 Contents API ───────────────────────────
|
|
11
|
+
//
|
|
12
|
+
// Contents API **没有设置 mode 的字段** —— 一个 `0755` 的文件会被写成 `0644`。
|
|
13
|
+
// 而 mode **进 `tree_digest`**(`src/tree-digest.mjs`:mode 是每个叶子的编码的一部分,
|
|
14
|
+
// 同字节不同 mode 算出来是两个不同摘要),也进 capability 语义
|
|
15
|
+
// (`structural-gates.executableEvidence` 看的就是可执行位)。
|
|
16
|
+
// 所以用 Contents API 投稿,等于**静默地改掉制品身份**。
|
|
17
|
+
//
|
|
18
|
+
// 另外 Contents API 每个文件一次 commit:中途失败会在 fork 上留下**半棵可见的
|
|
19
|
+
// 投稿树**,而我们的策略是「同名分支只读、不自动改写」—— 那棵半成品就没人能收拾了。
|
|
20
|
+
// Git Data API 的顺序是 blob → tree → commit → **最后一次性建 ref**:
|
|
21
|
+
// 失败只留下不可见的 orphan object,仓库上什么都看不见。
|
|
22
|
+
|
|
23
|
+
import { TokenError, scrub } from './token.mjs';
|
|
24
|
+
|
|
25
|
+
/** 🔴 API host 固定。不从任何输入取,不支持 GHE。 */
|
|
26
|
+
export const API_ORIGIN = 'https://api.github.com';
|
|
27
|
+
/** 🔴 上游固定。不是参数。 */
|
|
28
|
+
export const UPSTREAM_OWNER = 'geoly-ai';
|
|
29
|
+
export const UPSTREAM_REPO = 'skills-hub';
|
|
30
|
+
export const UPSTREAM_FULL = `${UPSTREAM_OWNER}/${UPSTREAM_REPO}`;
|
|
31
|
+
/** 🔴 base 分支固定。 */
|
|
32
|
+
export const BASE_BRANCH = 'main';
|
|
33
|
+
|
|
34
|
+
/** 网络类错误 → EXIT.NETWORK(6)。 */
|
|
35
|
+
export class ApiNetworkError extends Error {
|
|
36
|
+
constructor(message, extra = {}) {
|
|
37
|
+
super(message); this.name = 'ApiNetworkError'; this.exitCode = 6; Object.assign(this, extra);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* HTTP 层的失败。`status` 一定有;401/403 映射到 EXIT.AUTH(7),其余到 EXIT.NETWORK(6)。
|
|
43
|
+
*
|
|
44
|
+
* 🔴 **401/403 绝不静默换下一个 token 来源重试**(见 `commands/publish.mjs`):
|
|
45
|
+
* 那会让用户看到的身份与实际投稿身份不同 —— 而这条命令的全部风险都在身份上。
|
|
46
|
+
*/
|
|
47
|
+
export class ApiHttpError extends Error {
|
|
48
|
+
constructor(message, { status, method, path, docUrl = null } = {}) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = 'ApiHttpError';
|
|
51
|
+
this.status = status;
|
|
52
|
+
this.method = method;
|
|
53
|
+
this.path = path;
|
|
54
|
+
this.docUrl = docUrl;
|
|
55
|
+
this.exitCode = (status === 401 || status === 403) ? 7 : 6;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 路径参数的转义。**每一段都转**,然后 URL 里绝不出现未转义的用户输入。
|
|
61
|
+
*
|
|
62
|
+
* ⚠️ `encodeURIComponent` 会把 `/` 转成 `%2F` —— 这正是我们要的:
|
|
63
|
+
* 一个叫 `a/b` 的分支名不该变成两层路径。调用方把**不该转义的**斜杠
|
|
64
|
+
* 写在模板字面量里,把**每一个变量**用这个函数包起来。
|
|
65
|
+
*/
|
|
66
|
+
export const seg = (s) => encodeURIComponent(String(s));
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 建客户端。
|
|
70
|
+
*
|
|
71
|
+
* @param {object} a
|
|
72
|
+
* @param {string} a.token
|
|
73
|
+
* @param {function} a.fetchImpl 🔴 **必填**。不给就抛 —— 见下。
|
|
74
|
+
* @param {string} a.userAgent
|
|
75
|
+
* @param {number} [a.timeoutMs]
|
|
76
|
+
*/
|
|
77
|
+
export function createClient({ token, fetchImpl, userAgent, timeoutMs = 30000 }) {
|
|
78
|
+
// 🔴 `fetchImpl` 必填、且**没有隐式回退到 globalThis.fetch**。
|
|
79
|
+
// 测试忘了注入时,回退版本会**真的出网**,而那次出网看起来像一次通过的测试。
|
|
80
|
+
// 这条命令会创建 fork、建分支、开 PR —— 一次意外出网就是一次真实的写操作。
|
|
81
|
+
// 调用方(`commands/publish.mjs`)显式传 `ctx.fetchImpl ?? globalThis.fetch`,
|
|
82
|
+
// 于是"用了真 fetch"是一个**写得出来的动作**,不是一个默认值。
|
|
83
|
+
if (typeof fetchImpl !== 'function') {
|
|
84
|
+
throw new ApiNetworkError('publish 的 GitHub 客户端必须显式拿到 fetch 实现(不接受隐式回退)');
|
|
85
|
+
}
|
|
86
|
+
if (typeof token !== 'string' || token === '') throw new TokenError('内部错误:客户端拿到空 token');
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {'GET'|'POST'|'PATCH'} method
|
|
90
|
+
* @param {string} path 以 `/` 开头;**变量段必须已经过 `seg()`**
|
|
91
|
+
* @param {object} [o]
|
|
92
|
+
* @param {object} [o.body]
|
|
93
|
+
* @param {number[]} [o.okStatus] 允许的状态码;默认 2xx
|
|
94
|
+
* @param {number[]} [o.softStatus] 不抛错、原样返回的状态码(如 404 探测)
|
|
95
|
+
*/
|
|
96
|
+
async function request(method, path, { body = undefined, softStatus = [] } = {}) {
|
|
97
|
+
const url = new URL(path, API_ORIGIN);
|
|
98
|
+
// 🔴 **构造完之后**再断言 origin。在拼接之前断言等于没断言:
|
|
99
|
+
// `path` 若是 `https://evil.example/x`,`new URL` 会**整个换掉** origin。
|
|
100
|
+
// 这一条不是理论上的 —— 分支名、login 都进路径,而它们来自远端响应。
|
|
101
|
+
if (url.origin !== API_ORIGIN) {
|
|
102
|
+
throw new ApiNetworkError(`拒绝向 ${url.origin} 发请求 —— publish 的 API host 固定为 ${API_ORIGIN}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const headers = {
|
|
106
|
+
// 🔴 Authorization 只在这里出现一次,且这个对象**绝不进任何错误/日志**。
|
|
107
|
+
Authorization: `Bearer ${token}`,
|
|
108
|
+
Accept: 'application/vnd.github+json',
|
|
109
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
110
|
+
'User-Agent': userAgent,
|
|
111
|
+
};
|
|
112
|
+
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
|
113
|
+
|
|
114
|
+
const ac = new AbortController();
|
|
115
|
+
const t = setTimeout(() => ac.abort(), timeoutMs);
|
|
116
|
+
let res;
|
|
117
|
+
try {
|
|
118
|
+
res = await fetchImpl(url.href, {
|
|
119
|
+
method,
|
|
120
|
+
headers,
|
|
121
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
122
|
+
// 🔴 `manual`,且下面对任何 3xx 都抛 —— **不跟随,一步都不跟**。
|
|
123
|
+
// 跟随会把 `Authorization` 带去一个我们没有断言过的 host。
|
|
124
|
+
// `redirect:'error'` 也行,但 `manual` 让我们能把状态码报出来。
|
|
125
|
+
redirect: 'manual',
|
|
126
|
+
signal: ac.signal,
|
|
127
|
+
});
|
|
128
|
+
} catch (e) {
|
|
129
|
+
if (ac.signal.aborted) {
|
|
130
|
+
// 🔴 超时是**特殊的**:POST 可能已经在服务端生效了。
|
|
131
|
+
// 调用方必须先查询结果再决定是否重试 —— 所以这里把 `timedOut` 标出来。
|
|
132
|
+
throw new ApiNetworkError(
|
|
133
|
+
`${method} ${path} 超时(${timeoutMs} ms)—— 🔴 若这是一次写操作,`
|
|
134
|
+
+ '它**可能已经生效**;请先查询结果再决定是否重试。',
|
|
135
|
+
{ timedOut: true, method, path },
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
// undici 抛的永远是 `TypeError: fetch failed`,真因在 cause 上。
|
|
139
|
+
const cause = e?.cause?.code ?? e?.cause?.message ?? e?.message ?? '未知';
|
|
140
|
+
throw new ApiNetworkError(scrub(`${method} ${path} 失败:${cause}`, token), { method, path });
|
|
141
|
+
} finally {
|
|
142
|
+
clearTimeout(t);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 🔴 3xx 一律抛。**同 host 的重定向也抛** —— GitHub 会对改过名的仓库发 301,
|
|
146
|
+
// 而"上游改名了"是一件必须让人看见的事,不是可以静默跟随的细节。
|
|
147
|
+
if (res.status >= 300 && res.status < 400) {
|
|
148
|
+
throw new ApiNetworkError(
|
|
149
|
+
`${method} ${path} 返回了 ${res.status} 重定向 —— 拒绝跟随。\n`
|
|
150
|
+
+ ' 🔴 跟随重定向会把 Authorization 头带去一个没被断言过的 host。\n'
|
|
151
|
+
+ ' 常见成因:上游仓库改过名。请核对 `geoly-ai/skills-hub` 是否仍然有效。',
|
|
152
|
+
{ method, path },
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const text = await res.text().catch(() => '');
|
|
157
|
+
let json = null;
|
|
158
|
+
if (text !== '') { try { json = JSON.parse(text); } catch { json = null; } }
|
|
159
|
+
|
|
160
|
+
if (res.ok || softStatus.includes(res.status)) {
|
|
161
|
+
return { status: res.status, json, headers: res.headers, ok: res.ok };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 🔴 错误文案里**只**放 status / method / path / 响应体的 `message` 与 `documentation_url`。
|
|
165
|
+
// 不放 request headers,不放整个响应体(它可能回显了我们发过去的内容)。
|
|
166
|
+
// 再过一遍 `scrub` —— 那是最后一道,不是第一道。
|
|
167
|
+
const apiMsg = typeof json?.message === 'string' ? json.message : `HTTP ${res.status}`;
|
|
168
|
+
const errs = Array.isArray(json?.errors)
|
|
169
|
+
? json.errors.map((x) => (typeof x?.message === 'string' ? x.message : x?.code)).filter(Boolean)
|
|
170
|
+
: [];
|
|
171
|
+
throw new ApiHttpError(
|
|
172
|
+
scrub(`${method} ${path} → ${res.status}:${apiMsg}${errs.length ? `(${errs.join(';')})` : ''}`, token),
|
|
173
|
+
{
|
|
174
|
+
status: res.status,
|
|
175
|
+
method,
|
|
176
|
+
path,
|
|
177
|
+
docUrl: typeof json?.documentation_url === 'string' ? json.documentation_url : null,
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
request,
|
|
184
|
+
get: (p, o) => request('GET', p, o),
|
|
185
|
+
post: (p, body, o) => request('POST', p, { ...o, body }),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 分页取全部页。
|
|
191
|
+
*
|
|
192
|
+
* 🔴 `maxPages` 到顶时**不静默截断**:抛 `TruncatedError`,由调用方决定怎么说。
|
|
193
|
+
* 一个"取了前 3 页就当作全部"的重复检测,正是那种「看起来在查、其实查不全」的门。
|
|
194
|
+
*/
|
|
195
|
+
export class TruncatedError extends Error {
|
|
196
|
+
constructor(message, extra = {}) {
|
|
197
|
+
super(message); this.name = 'TruncatedError'; this.exitCode = 6; Object.assign(this, extra);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function getAllPages(client, pathWithoutPage, { perPage = 100, maxPages = 10 } = {}) {
|
|
202
|
+
const out = [];
|
|
203
|
+
const sepChar = pathWithoutPage.includes('?') ? '&' : '?';
|
|
204
|
+
for (let page = 1; page <= maxPages; page += 1) {
|
|
205
|
+
const r = await client.get(`${pathWithoutPage}${sepChar}per_page=${perPage}&page=${page}`);
|
|
206
|
+
// 🔴 200 但不是数组 → **抛**。把它当成空数组,等于把「响应形状不对」
|
|
207
|
+
// 翻译成「这一页什么都没有」,而后者会让"没有重复投稿"这个结论成立。
|
|
208
|
+
if (!Array.isArray(r.json)) {
|
|
209
|
+
throw new TruncatedError(
|
|
210
|
+
`${pathWithoutPage} 第 ${page} 页返回的不是数组 —— 拒绝把它当成"这一页是空的"`,
|
|
211
|
+
{ path: pathWithoutPage, page },
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
const arr = r.json;
|
|
215
|
+
out.push(...arr);
|
|
216
|
+
if (arr.length < perPage) return out;
|
|
217
|
+
}
|
|
218
|
+
throw new TruncatedError(
|
|
219
|
+
`${pathWithoutPage} 的分页超过 ${maxPages} 页(每页 ${perPage})—— 拒绝把"取了前 ${maxPages} 页"`
|
|
220
|
+
+ '当成"全部"。',
|
|
221
|
+
{ path: pathWithoutPage, maxPages, perPage, got: out.length },
|
|
222
|
+
);
|
|
223
|
+
}
|