@morehao/dsh-skills-console 0.2.3 → 0.3.0

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,332 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { extractTarball } from './archive.js';
5
+ import { discoverSkills } from './discovery.js';
6
+ import { gitTreeSha } from './git-tree.js';
7
+ import { parseSource } from './updates.js';
8
+
9
+ /**
10
+ * 远端通道:codeload tarball、Atom commit feed、限流时的快照扫描。
11
+ *
12
+ * 安全边界(与设计文档 §安全一致):
13
+ * - 只接受 HTTPS,只允许 GitHub 的 host;重定向手动跟随,跨 host 一律拒绝并剥离 Authorization。
14
+ * - token 只进 header,绝不拼进 URL、错误消息或日志。
15
+ * - 响应 `content-length` 与流式累计双重限额,超限立即中止并删掉半截文件。
16
+ * - 默认 60s 超时;超时/中止都抛 RemoteError,不留下半写文件。
17
+ */
18
+
19
+ export const DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
20
+ export const DEFAULT_TIMEOUT_MS = 60_000;
21
+ const MAX_REDIRECTS = 5;
22
+ const ALLOWED_HOSTS = new Set([
23
+ 'codeload.github.com',
24
+ 'github.com',
25
+ 'objects.githubusercontent.com',
26
+ 'raw.githubusercontent.com',
27
+ ]);
28
+
29
+ export class RemoteError extends Error {
30
+ constructor(code, message, extra = {}) {
31
+ super(message);
32
+ this.name = 'RemoteError';
33
+ this.code = code;
34
+ Object.assign(this, extra);
35
+ }
36
+ }
37
+
38
+ /** commit(40 位)或分支/tag;分支里的 `/` 是合法的,但 `..`/空白/反斜杠不行。 */
39
+ export function assertRef(ref) {
40
+ if (typeof ref !== 'string' || ref.length === 0 || ref.length > 200) {
41
+ throw new RemoteError('invalid-ref', 'ref 必须是非空字符串');
42
+ }
43
+ if (!/^[A-Za-z0-9._/-]+$/.test(ref) || ref.includes('..') || ref.startsWith('/') || ref.endsWith('/')) {
44
+ throw new RemoteError('invalid-ref', `ref 含非法字符: ${ref}`);
45
+ }
46
+ return ref;
47
+ }
48
+
49
+ export function codeloadUrl({ source, ref }) {
50
+ const parsed = parseSource(source);
51
+ if (!parsed) throw new RemoteError('invalid-source', `不是 GitHub 来源: ${source}`);
52
+ assertRef(ref);
53
+ return `https://codeload.github.com/${parsed.owner}/${parsed.repo}/tar.gz/${ref}`;
54
+ }
55
+
56
+ function assertAllowedUrl(url) {
57
+ let parsed;
58
+ try {
59
+ parsed = new URL(url);
60
+ } catch {
61
+ throw new RemoteError('redirect-rejected', `重定向目标不是合法 URL: ${url}`);
62
+ }
63
+ if (parsed.protocol !== 'https:') {
64
+ throw new RemoteError('redirect-rejected', `只允许 HTTPS: ${url}`);
65
+ }
66
+ if (!ALLOWED_HOSTS.has(parsed.hostname)) {
67
+ throw new RemoteError('redirect-rejected', `不允许的 host: ${parsed.hostname}`);
68
+ }
69
+ if (parsed.username || parsed.password) {
70
+ throw new RemoteError('redirect-rejected', '重定向目标不能带 userinfo');
71
+ }
72
+ return parsed;
73
+ }
74
+
75
+ function tooLarge(maxBytes, bytes) {
76
+ return new RemoteError('tarball-too-large', `tarball 超过 ${maxBytes} 字节上限(已收到 ${bytes})`, {
77
+ maxBytes,
78
+ bytes,
79
+ });
80
+ }
81
+
82
+ const ATOM_ID_RE = /<id>tag:github\.com,2008:Grit::Commit\/([0-9a-f]{40})<\/id>/i;
83
+ const ATOM_LINK_RE = /https:\/\/github\.com\/[^"<\s]+\/commit\/([0-9a-f]{40})/i;
84
+
85
+ /** 从 GitHub 的 commits Atom feed 里取第一条 commit(feed 按时间倒序)。 */
86
+ export function parseAtomCommit(text) {
87
+ const body = String(text || '');
88
+ const match = ATOM_ID_RE.exec(body) || ATOM_LINK_RE.exec(body);
89
+ return match ? match[1].toLowerCase() : null;
90
+ }
91
+
92
+ /**
93
+ * Atom commit feed:`/commits/<ref>.atom`(不带 ref 即默认分支)。
94
+ * 不占 REST 配额,也不需要 `--json` / git;失败抛 RemoteError,由调用方决定是否降级。
95
+ */
96
+ export async function fetchAtomCommit({
97
+ source,
98
+ ref = null,
99
+ token = null,
100
+ fetchImpl = fetch,
101
+ timeoutMs = DEFAULT_TIMEOUT_MS,
102
+ } = {}) {
103
+ const parsed = parseSource(source);
104
+ if (!parsed) throw new RemoteError('bad-source', `只接受 owner/repo 或 GitHub URL: ${source}`);
105
+ if (ref !== null) assertRef(ref);
106
+ const refPath = ref ? `/${ref.split('/').map(encodeURIComponent).join('/')}` : '';
107
+ const url = `https://github.com/${parsed.owner}/${parsed.repo}/commits${refPath}.atom`;
108
+ const controller = new AbortController();
109
+ let timedOut = false;
110
+ const timer = setTimeout(() => {
111
+ timedOut = true;
112
+ controller.abort();
113
+ }, timeoutMs);
114
+ timer.unref?.();
115
+ try {
116
+ const headers = { accept: 'application/atom+xml', 'user-agent': 'dsh-skills-console' };
117
+ if (token) headers.authorization = `Bearer ${token}`;
118
+ let response;
119
+ try {
120
+ response = await fetchImpl(url, { headers, signal: controller.signal });
121
+ } catch (error) {
122
+ if (timedOut) throw new RemoteError('timeout', `读取 Atom 超时(${timeoutMs}ms)`, { cause: error });
123
+ throw new RemoteError('network', `读取 Atom 失败: ${error.message}`, { cause: error });
124
+ }
125
+ if (!response.ok) {
126
+ const text = await response.text().catch(() => '');
127
+ throw new RemoteError('http-error', `HTTP ${response.status}: ${text.slice(0, 200)}`, {
128
+ status: response.status,
129
+ });
130
+ }
131
+ const commit = parseAtomCommit(await response.text());
132
+ if (!commit) throw new RemoteError('atom-empty', 'Atom feed 里没有 commit');
133
+ return { commit, url, status: response.status };
134
+ } finally {
135
+ clearTimeout(timer);
136
+ }
137
+ }
138
+
139
+ function skillDirOf(skillPath) {
140
+ const index = skillPath.lastIndexOf('/');
141
+ return index < 0 ? '' : skillPath.slice(0, index);
142
+ }
143
+
144
+ /**
145
+ * 用 codeload tarball 组装一份与 REST 同形的快照(限流兜底)。
146
+ * 只在临时目录里解压/扫描;rootSha 与每个 skill 的 treeSha 都在本地算。
147
+ */
148
+ export async function snapshotFromTarball({
149
+ source,
150
+ commit,
151
+ ref = null,
152
+ subpath = null,
153
+ token = null,
154
+ fetchImpl = fetch,
155
+ maxBytes = DEFAULT_MAX_BYTES,
156
+ timeoutMs = DEFAULT_TIMEOUT_MS,
157
+ } = {}) {
158
+ const parsed = parseSource(source);
159
+ if (!parsed) throw new RemoteError('bad-source', `只接受 owner/repo 或 GitHub URL: ${source}`);
160
+ if (typeof commit !== 'string' || !/^[0-9a-f]{40}$/i.test(commit)) {
161
+ throw new RemoteError('missing-commit', 'codeload 快照需要 40 位 commit');
162
+ }
163
+ const repo = `${parsed.owner}/${parsed.repo}`;
164
+ const work = fs.mkdtempSync(path.join(os.tmpdir(), 'dshsc-scan-'));
165
+ try {
166
+ const tarball = path.join(work, 'repo.tar.gz');
167
+ await downloadTarball({ source: repo, ref: commit, dest: tarball, token, fetchImpl, maxBytes, timeoutMs });
168
+ const extracted = await extractTarball({ tarball, dest: path.join(work, 'stage') });
169
+ let skills;
170
+ try {
171
+ skills = discoverSkills({ dir: extracted.dir, subpath, repoName: parsed.repo });
172
+ } catch (error) {
173
+ throw new RemoteError('scan-failed', `扫描快照失败: ${error.message}`, { cause: error, code: error.code });
174
+ }
175
+ let rootSha;
176
+ try {
177
+ rootSha = gitTreeSha(extracted.dir);
178
+ } catch (error) {
179
+ throw new RemoteError('scan-failed', `计算 root tree 失败: ${error.message}`, { cause: error });
180
+ }
181
+ const entries = [];
182
+ const tree = [];
183
+ const seenDirs = new Set();
184
+ for (const skill of skills) {
185
+ let treeSha;
186
+ try {
187
+ treeSha = gitTreeSha(skill.dirPath);
188
+ } catch (error) {
189
+ throw new RemoteError('scan-failed', `计算 ${skill.name} tree 失败: ${error.message}`, { cause: error });
190
+ }
191
+ entries.push({ name: skill.name, skillPath: skill.skillPath, treeSha });
192
+ const dir = skillDirOf(skill.skillPath);
193
+ if (dir && !seenDirs.has(dir)) {
194
+ seenDirs.add(dir);
195
+ tree.push({ path: dir, type: 'tree', sha: treeSha });
196
+ }
197
+ // blob 条目只用于判定「skillPath 是否还在」;真正比对用目录 treeSha
198
+ tree.push({ path: skill.skillPath, type: 'blob' });
199
+ }
200
+ return {
201
+ branch: ref || null,
202
+ via: 'codeload',
203
+ tree,
204
+ rootSha,
205
+ snapshot: {
206
+ commit: commit.toLowerCase(),
207
+ rootSha,
208
+ entries,
209
+ fetchedAt: new Date().toISOString(),
210
+ etag: null,
211
+ },
212
+ };
213
+ } finally {
214
+ fs.rmSync(work, { recursive: true, force: true });
215
+ }
216
+ }
217
+
218
+ /**
219
+ * 把 codeload tarball 下载到 `dest`。`dest` 的父目录必须已存在。
220
+ * 成功返回 `{ file, bytes, url }`;任何失败都会删除 `dest` 并抛 RemoteError。
221
+ */
222
+ export async function downloadTarball({
223
+ source,
224
+ ref,
225
+ token,
226
+ dest,
227
+ fetchImpl = fetch,
228
+ maxBytes = DEFAULT_MAX_BYTES,
229
+ timeoutMs = DEFAULT_TIMEOUT_MS,
230
+ signal: externalSignal,
231
+ } = {}) {
232
+ if (typeof dest !== 'string' || dest.length === 0) {
233
+ throw new RemoteError('invalid-dest', 'downloadTarball 需要 dest 文件路径');
234
+ }
235
+ if (!Number.isFinite(maxBytes) || maxBytes <= 0) {
236
+ throw new RemoteError('invalid-limit', 'maxBytes 必须是正数');
237
+ }
238
+
239
+ const initialUrl = codeloadUrl({ source, ref });
240
+
241
+ const controller = new AbortController();
242
+ let timedOut = false;
243
+ const timer =
244
+ Number.isFinite(timeoutMs) && timeoutMs > 0
245
+ ? setTimeout(() => {
246
+ timedOut = true;
247
+ controller.abort();
248
+ }, timeoutMs)
249
+ : null;
250
+ const signal = externalSignal ? AbortSignal.any([controller.signal, externalSignal]) : controller.signal;
251
+
252
+ let url = initialUrl;
253
+ const authHost = new URL(initialUrl).hostname;
254
+ let handle = null;
255
+ let declared = null;
256
+ try {
257
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
258
+ const headers = {
259
+ accept: 'application/x-gzip',
260
+ 'user-agent': 'dsh-skills-console',
261
+ };
262
+ // token 只发给最初那个 host;跨 host 重定向一律剥离,避免泄漏到 CDN/第三方。
263
+ const currentHost = new URL(url).hostname;
264
+ if (token && currentHost === authHost) headers.authorization = `Bearer ${token}`;
265
+
266
+ let response;
267
+ try {
268
+ response = await fetchImpl(url, { headers, redirect: 'manual', signal });
269
+ } catch (error) {
270
+ if (timedOut) throw new RemoteError('timeout', `下载超时(${timeoutMs}ms)`, { cause: error });
271
+ if (signal.aborted) throw new RemoteError('aborted', '下载已中止', { cause: error });
272
+ throw new RemoteError('download-failed', `下载失败: ${error.message}`, { cause: error });
273
+ }
274
+
275
+ if (response.status >= 300 && response.status < 400) {
276
+ const location = response.headers.get('location');
277
+ await response.body?.cancel().catch(() => {});
278
+ if (!location) throw new RemoteError('redirect-rejected', `HTTP ${response.status} 缺少 Location`);
279
+ if (hop === MAX_REDIRECTS) {
280
+ throw new RemoteError('redirect-rejected', `重定向超过 ${MAX_REDIRECTS} 次`);
281
+ }
282
+ const next = assertAllowedUrl(new URL(location, url).toString());
283
+ // 跨 host 时不沿用上一跳的 Authorization:下一轮按新 host 重新决定。
284
+ url = next.toString();
285
+ continue;
286
+ }
287
+
288
+ if (!response.ok) {
289
+ const text = await response.text().catch(() => '');
290
+ throw new RemoteError('http-error', `HTTP ${response.status}: ${text.slice(0, 200)}`, {
291
+ status: response.status,
292
+ });
293
+ }
294
+
295
+ const lengthHeader = response.headers.get('content-length');
296
+ declared = lengthHeader === null ? null : Number(lengthHeader);
297
+ if (declared !== null && Number.isFinite(declared) && declared > maxBytes) {
298
+ throw tooLarge(maxBytes, declared);
299
+ }
300
+ if (!response.body) throw new RemoteError('download-failed', '响应没有 body');
301
+
302
+ handle = await fs.promises.open(dest, 'w');
303
+ let bytes = 0;
304
+ for await (const chunk of response.body) {
305
+ bytes += chunk.byteLength;
306
+ if (bytes > maxBytes) throw tooLarge(maxBytes, bytes);
307
+ let offset = 0;
308
+ while (offset < chunk.byteLength) {
309
+ const { bytesWritten } = await handle.write(chunk, offset, chunk.byteLength - offset);
310
+ offset += bytesWritten;
311
+ }
312
+ }
313
+ if (bytes === 0) throw new RemoteError('download-failed', '响应为空');
314
+ if (declared !== null && Number.isFinite(declared) && bytes !== declared) {
315
+ throw new RemoteError('truncated-download', `下载不完整:声明 ${declared} 字节,实际 ${bytes} 字节`);
316
+ }
317
+ await handle.close();
318
+ handle = null;
319
+ return { file: dest, bytes, url };
320
+ }
321
+ // 循环正常结束只可能是重定向用尽,上面的 hop 判断已抛错。
322
+ throw new RemoteError('redirect-rejected', '重定向次数过多');
323
+ } catch (error) {
324
+ if (handle) await handle.close().catch(() => {});
325
+ await fs.promises.unlink(dest).catch(() => {});
326
+ if (error instanceof RemoteError) throw error;
327
+ if (timedOut) throw new RemoteError('timeout', `下载超时(${timeoutMs}ms)`, { cause: error });
328
+ throw new RemoteError('download-failed', `下载失败: ${error.message}`, { cause: error });
329
+ } finally {
330
+ if (timer) clearTimeout(timer);
331
+ }
332
+ }