@stofone/dsh-agent-roster 0.0.0-stage → 0.5.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.
- package/CHANGELOG.md +139 -0
- package/LICENSE +21 -0
- package/README.md +290 -3
- package/README.zh.md +282 -0
- package/bin/cli.mjs +325 -0
- package/client.js +1950 -0
- package/cordis.patch.yml +9 -0
- package/examples/core-review/roster.yaml +82 -0
- package/examples/research/roster.yaml +42 -0
- package/icon.svg +11 -0
- package/index/registry.json +23 -0
- package/index.js +298 -0
- package/lib/bridge.js +233 -0
- package/lib/config-view.js +59 -0
- package/lib/format.js +223 -0
- package/lib/home.js +108 -0
- package/lib/http.js +122 -0
- package/lib/json.js +89 -0
- package/lib/library.js +161 -0
- package/lib/parse.js +148 -0
- package/lib/registry.js +285 -0
- package/lib/remote.js +623 -0
- package/lib/render.js +343 -0
- package/lib/serialize.js +92 -0
- package/lib/service.js +421 -0
- package/lib/teams-state.js +146 -0
- package/lib/tools.js +601 -0
- package/lib/validate.js +747 -0
- package/lib/verify.js +164 -0
- package/lib/write.js +290 -0
- package/locale/en.json +245 -0
- package/locale/zh.json +245 -0
- package/package.json +98 -4
- package/schema/roster.v1.schema.json +193 -0
package/lib/remote.js
ADDED
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 远程档案源(公共 registry)读取。
|
|
3
|
+
*
|
|
4
|
+
* 照 dsh-market 的做法抄了三件事,一样都不多:
|
|
5
|
+
*
|
|
6
|
+
* 1. **静态 JSON 优先**:registry 就是一份 JSON,由仓库的 Action 在合并 PR 后重建,
|
|
7
|
+
* 客户端按 TTL 拉取(默认一小时一次,正好落在 GitHub 未认证 API 的 60 次/小时预算内)。
|
|
8
|
+
* 2. **npm 包兜底**:同一份 JSON 可以发布成 npm 包(`dsh-agent-roster-catalog`),
|
|
9
|
+
* 走镜像下载 tarball 再解出 `package/registry.json`——这是 dsh-market 解决
|
|
10
|
+
* “GitHub Pages 被代理拦” 的办法,我们原样保留,只是默认不启用(还没发布)。
|
|
11
|
+
* 3. **失败时用缓存**:网络挂了不代表面板不能看,旧缓存照用,但必须标 `stale` 并给出原因,
|
|
12
|
+
* 绝不把“没拉到”伪装成“没有档案”。
|
|
13
|
+
*
|
|
14
|
+
* 与 dsh-market 的差异只有一处:它每次都现拉(catalog 是它的主路径),
|
|
15
|
+
* 我们把 TTL 与缓存放在前面——因为我们的 registry 不是安装依赖,晚一小时知道新档案没有代价。
|
|
16
|
+
*
|
|
17
|
+
* 这一层**只读**:不装插件、不写档案库,拿到的档案带 `origin` 标记后交给上层展示。
|
|
18
|
+
*
|
|
19
|
+
* @module dsh-agent-roster/remote
|
|
20
|
+
*/
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
import fs from "node:fs";
|
|
23
|
+
import nodePath from "node:path";
|
|
24
|
+
import { gunzipSync } from "node:zlib";
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
COLLECTION_FIELDS,
|
|
28
|
+
LIMITS,
|
|
29
|
+
PATTERNS,
|
|
30
|
+
REGISTRY_FIELDS,
|
|
31
|
+
REGISTRY_KIND,
|
|
32
|
+
ROSTER_KIND,
|
|
33
|
+
} from "./format.js";
|
|
34
|
+
import { parseDocumentText } from "./parse.js";
|
|
35
|
+
import { validateRoster } from "./validate.js";
|
|
36
|
+
|
|
37
|
+
/** 默认 TTL:一小时。用户说“60 次/小时更新就行”,这里就是那个预算。 */
|
|
38
|
+
export const REGISTRY_TTL_MS = 60 * 60 * 1000;
|
|
39
|
+
|
|
40
|
+
/** 实测发现的最短刷新间隔:`--refresh` 也绕不过它,避免页面狂刷把配额烧光。 */
|
|
41
|
+
export const MIN_REFRESH_MS = 60 * 1000;
|
|
42
|
+
|
|
43
|
+
/** 官方 registry 地址(本仓库的 `index/registry.json`,合并 PR 即更新)。 */
|
|
44
|
+
export const DEFAULT_REGISTRY_URL =
|
|
45
|
+
"https://raw.githubusercontent.com/STofone/dsh-agent-roster/main/index/registry.json";
|
|
46
|
+
|
|
47
|
+
/** 缓存目录名,放在档案库根下,跟着 `$DSH_HOME` 走。 */
|
|
48
|
+
export const CACHE_DIR_NAME = ".index-cache";
|
|
49
|
+
|
|
50
|
+
/** entry 文件所在的仓库前缀(只支持 GitHub raw,不引入 git 协议)。 */
|
|
51
|
+
export const GITHUB_RAW = "https://raw.githubusercontent.com";
|
|
52
|
+
|
|
53
|
+
/** 各下载区使用的 npm registry(与 dsh-market 的两个区一致,只是不做自动探测)。 */
|
|
54
|
+
export const NPM_REGISTRIES = {
|
|
55
|
+
global: "https://registry.npmjs.org",
|
|
56
|
+
china: "https://mirrors.cloud.tencent.com/npm",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** 网络单次请求的预算:够了,慢就换下一个源。 */
|
|
60
|
+
const TIMEOUT_MS = 15_000;
|
|
61
|
+
|
|
62
|
+
/** 一次报告用的小工具:与校验器同形状的诊断。 */
|
|
63
|
+
function diagnostic(severity, code, reason, file = "") {
|
|
64
|
+
return { severity, code, reason, file, path: "" };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 把配置里的 `indexes` 归一成源描述。
|
|
69
|
+
*
|
|
70
|
+
* 接受三种写法:
|
|
71
|
+
* - `"url"`:任意 HTTPS 的 registry JSON;
|
|
72
|
+
* - `"npm:pkg[@version]"`:从 npm 包取 `registry.json`;
|
|
73
|
+
* - `{kind, url|pkg, ttlMs, id}`:显式对象。
|
|
74
|
+
*
|
|
75
|
+
* 空数组 = 只用官方源(用户不必为了“能用”先抄一条 URL);传 `false` 只在本机库工作。
|
|
76
|
+
*
|
|
77
|
+
* @param {unknown} value `config.indexes`
|
|
78
|
+
* @returns {{ sources: object[], diagnostics: object[] }}
|
|
79
|
+
*/
|
|
80
|
+
export function normalizeIndexSources(value) {
|
|
81
|
+
const diagnostics = [];
|
|
82
|
+
// 空数组沿用官方源:设置里的默认值就是 `[]`,如果它表示“没有源”,面板一装上就是空的。
|
|
83
|
+
// 真要关掉远端源,显式写 `false`。
|
|
84
|
+
const empty = value === undefined || value === null || value === "" || (Array.isArray(value) && value.length === 0);
|
|
85
|
+
const raw = empty ? [DEFAULT_REGISTRY_URL] : value === false ? [] : Array.isArray(value) ? value : [value];
|
|
86
|
+
const sources = [];
|
|
87
|
+
const seen = new Set();
|
|
88
|
+
|
|
89
|
+
for (const item of raw) {
|
|
90
|
+
const parsed = parseSourceSpec(item);
|
|
91
|
+
if (parsed.error !== undefined) {
|
|
92
|
+
diagnostics.push(diagnostic("warn", "index-source-invalid", parsed.error));
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const id = parsed.id ?? sourceId(parsed);
|
|
96
|
+
if (seen.has(id)) continue;
|
|
97
|
+
seen.add(id);
|
|
98
|
+
sources.push({ ...parsed, id });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { sources, diagnostics };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 单条源描述解析。 */
|
|
105
|
+
function parseSourceSpec(item) {
|
|
106
|
+
if (item === false) return { error: "false 之外的写法才表示启用源" };
|
|
107
|
+
if (typeof item === "string") {
|
|
108
|
+
const text = item.trim();
|
|
109
|
+
if (text === "") return { error: "空字符串不是合法的源" };
|
|
110
|
+
if (text.startsWith("npm:")) return npmSpec(text.slice(4));
|
|
111
|
+
if (/^https:\/\/\S+$/i.test(text)) return { kind: "url", url: text, ttlMs: REGISTRY_TTL_MS };
|
|
112
|
+
return { error: `无法识别的源写法:${text}` };
|
|
113
|
+
}
|
|
114
|
+
if (typeof item === "object" && item !== null) {
|
|
115
|
+
const ttlMs = Number.isFinite(item.ttlMs) && item.ttlMs >= MIN_REFRESH_MS ? item.ttlMs : REGISTRY_TTL_MS;
|
|
116
|
+
if (item.kind === "npm" || typeof item.pkg === "string") {
|
|
117
|
+
const spec = npmSpec(`${item.pkg}${item.version ? `@${item.version}` : ""}`, item);
|
|
118
|
+
if (spec.error !== undefined) return spec;
|
|
119
|
+
return { ...spec, ttlMs, id: typeof item.id === "string" ? item.id : undefined };
|
|
120
|
+
}
|
|
121
|
+
if (typeof item.url === "string" && /^https:\/\/\S+$/i.test(item.url.trim())) {
|
|
122
|
+
return {
|
|
123
|
+
kind: "url",
|
|
124
|
+
url: item.url.trim(),
|
|
125
|
+
ttlMs,
|
|
126
|
+
id: typeof item.id === "string" ? item.id : undefined,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return { error: "对象形式的源必须有 url 或 pkg" };
|
|
130
|
+
}
|
|
131
|
+
return { error: `无法识别的源写法:${String(item)}` };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** `pkg[@version]` 解析(scoped 包名里的 `@` 不算版本分隔符)。 */
|
|
135
|
+
function npmSpec(text, extra = {}) {
|
|
136
|
+
const at = text.lastIndexOf("@");
|
|
137
|
+
const hasVersion = at > 0;
|
|
138
|
+
const pkg = hasVersion ? text.slice(0, at) : text;
|
|
139
|
+
const version = hasVersion ? text.slice(at + 1) : undefined;
|
|
140
|
+
if (!PATTERNS.npmName.test(pkg)) return { error: `不是合法的 npm 包名:${pkg}` };
|
|
141
|
+
return {
|
|
142
|
+
kind: "npm",
|
|
143
|
+
pkg,
|
|
144
|
+
version,
|
|
145
|
+
registry: typeof extra.registry === "string" && extra.registry !== "" ? extra.registry.replace(/\/+$/, "") : undefined,
|
|
146
|
+
file: "package/registry.json",
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 稳定的源 id:同一 URL/包永远得到同一个缓存文件名。 */
|
|
151
|
+
export function sourceId(source) {
|
|
152
|
+
const key = source.kind === "npm" ? `npm:${source.registry ?? ""}${source.pkg}` : `url:${source.url}`;
|
|
153
|
+
return createHash("sha1").update(key).digest("hex").slice(0, 12);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 源的展示标签。 */
|
|
157
|
+
export function sourceLabel(source) {
|
|
158
|
+
if (source.kind === "npm") return `npm:${source.pkg}`;
|
|
159
|
+
try {
|
|
160
|
+
const url = new URL(source.url);
|
|
161
|
+
return `${url.host}${url.pathname}`;
|
|
162
|
+
} catch {
|
|
163
|
+
return String(source.url);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 读一个源:命中缓存就返回缓存,过期则带 ETag 条件请求,失败退回旧缓存。
|
|
169
|
+
*
|
|
170
|
+
* @param {object} source `normalizeIndexSources` 的产物
|
|
171
|
+
* @param {object} [options]
|
|
172
|
+
* @param {string} [options.cacheDir] 缓存目录(默认不缓存:便于纯函数测试)
|
|
173
|
+
* @param {() => Date} [options.now]
|
|
174
|
+
* @param {typeof fetch} [options.fetchImpl]
|
|
175
|
+
* @param {boolean} [options.refresh] 忽略 TTL(但仍受 {@link MIN_REFRESH_MS} 约束)
|
|
176
|
+
* @param {number} [options.region] 选择 npm registry
|
|
177
|
+
* @returns {Promise<object>} `{ source, ok, stale, fetchedAt, document, diagnostics, collections }`
|
|
178
|
+
*/
|
|
179
|
+
export async function loadRegistry(source, options = {}) {
|
|
180
|
+
const now = options.now ?? (() => new Date());
|
|
181
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
182
|
+
const cachePath = options.cacheDir === undefined ? null : cachePathFor(options.cacheDir, source.id);
|
|
183
|
+
const cached = cachePath === null ? null : readCache(cachePath);
|
|
184
|
+
const current = now().getTime();
|
|
185
|
+
const ttlMs = Number.isFinite(source.ttlMs) ? source.ttlMs : REGISTRY_TTL_MS;
|
|
186
|
+
const age = cached === null ? Number.POSITIVE_INFINITY : current - cached.fetchedAt;
|
|
187
|
+
|
|
188
|
+
if (cached !== null && !options.refresh && age < ttlMs) {
|
|
189
|
+
return buildResult(source, cached.document, {
|
|
190
|
+
ok: true,
|
|
191
|
+
stale: false,
|
|
192
|
+
fetchedAt: cached.fetchedAt,
|
|
193
|
+
diagnostics: [],
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
if (cached !== null && options.refresh && age < MIN_REFRESH_MS) {
|
|
197
|
+
return buildResult(source, cached.document, {
|
|
198
|
+
ok: true,
|
|
199
|
+
stale: false,
|
|
200
|
+
fetchedAt: cached.fetchedAt,
|
|
201
|
+
diagnostics: [diagnostic("info", "index-refresh-throttled", `距上次刷新不足 ${MIN_REFRESH_MS / 1000}s,先用缓存`)],
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const fetched = await fetchRegistry(source, { fetchImpl, etag: cached?.etag, version: cached?.version, region: options.region });
|
|
207
|
+
if (fetched.status === "not-modified" && cached !== null) {
|
|
208
|
+
writeCache(cachePath, { ...cached, fetchedAt: current });
|
|
209
|
+
return buildResult(source, cached.document, { ok: true, stale: false, fetchedAt: current, diagnostics: [] });
|
|
210
|
+
}
|
|
211
|
+
const checked = validateRegistry(fetched.document, { file: fetched.file });
|
|
212
|
+
if (!checked.ok) {
|
|
213
|
+
if (cached !== null) {
|
|
214
|
+
return buildResult(source, cached.document, {
|
|
215
|
+
ok: true,
|
|
216
|
+
stale: true,
|
|
217
|
+
fetchedAt: cached.fetchedAt,
|
|
218
|
+
diagnostics: [
|
|
219
|
+
diagnostic("warn", "index-invalid", `${sourceLabel(source)} 的内容不合法,沿用上次缓存`, fetched.file),
|
|
220
|
+
...checked.diagnostics,
|
|
221
|
+
],
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
return buildResult(source, null, {
|
|
225
|
+
ok: false,
|
|
226
|
+
stale: false,
|
|
227
|
+
fetchedAt: null,
|
|
228
|
+
diagnostics: [diagnostic("error", "index-invalid", `${sourceLabel(source)} 的内容不合法`, fetched.file), ...checked.diagnostics],
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
writeCache(cachePath, {
|
|
232
|
+
sourceId: source.id,
|
|
233
|
+
fetchedAt: current,
|
|
234
|
+
etag: fetched.etag,
|
|
235
|
+
version: fetched.version,
|
|
236
|
+
document: fetched.document,
|
|
237
|
+
});
|
|
238
|
+
return buildResult(source, fetched.document, { ok: true, stale: false, fetchedAt: current, diagnostics: [] });
|
|
239
|
+
} catch (error) {
|
|
240
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
241
|
+
if (cached !== null) {
|
|
242
|
+
return buildResult(source, cached.document, {
|
|
243
|
+
ok: true,
|
|
244
|
+
stale: true,
|
|
245
|
+
fetchedAt: cached.fetchedAt,
|
|
246
|
+
diagnostics: [diagnostic("warn", "index-unreachable", `${sourceLabel(source)} 拉取失败,沿用上次缓存:${reason}`)],
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
return buildResult(source, null, {
|
|
250
|
+
ok: false,
|
|
251
|
+
stale: false,
|
|
252
|
+
fetchedAt: null,
|
|
253
|
+
diagnostics: [diagnostic("warn", "index-unreachable", `${sourceLabel(source)} 拉取失败:${reason}`)],
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** 校验通过后把文档包装成统一结果。 */
|
|
259
|
+
function buildResult(source, document, meta) {
|
|
260
|
+
const collections = document === null ? [] : document.collections;
|
|
261
|
+
return {
|
|
262
|
+
source,
|
|
263
|
+
label: sourceLabel(source),
|
|
264
|
+
ok: meta.ok,
|
|
265
|
+
stale: meta.stale,
|
|
266
|
+
fetchedAt: meta.fetchedAt === null ? null : new Date(meta.fetchedAt).toISOString(),
|
|
267
|
+
document,
|
|
268
|
+
collections,
|
|
269
|
+
diagnostics: meta.diagnostics,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* 拉取一份 registry 文档。
|
|
275
|
+
*
|
|
276
|
+
* @returns {Promise<{status: "ok"|"not-modified", document?: unknown, etag?: string, version?: string, file: string}>}
|
|
277
|
+
*/
|
|
278
|
+
async function fetchRegistry(source, options) {
|
|
279
|
+
if (source.kind === "npm") {
|
|
280
|
+
const registry = source.registry ?? NPM_REGISTRIES[options.region === "china" ? "china" : "global"];
|
|
281
|
+
return fetchFromPackage(source, { ...options, registry });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const headers = { accept: "application/json", "user-agent": userAgent() };
|
|
285
|
+
if (typeof options.etag === "string" && options.etag !== "") headers["if-none-match"] = options.etag;
|
|
286
|
+
const response = await options.fetchImpl(source.url, { headers, signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
287
|
+
if (response.status === 304) return { status: "not-modified", file: source.url };
|
|
288
|
+
if (!response.ok) throw new Error(`HTTP ${String(response.status)} ${response.statusText}`.trim());
|
|
289
|
+
const text = await response.text();
|
|
290
|
+
return {
|
|
291
|
+
status: "ok",
|
|
292
|
+
document: JSON.parse(text),
|
|
293
|
+
etag: response.headers.get("etag") ?? undefined,
|
|
294
|
+
file: source.url,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* 从 npm 包取 registry:packument → tarball → 解出 `package/registry.json`。
|
|
300
|
+
*
|
|
301
|
+
* 版本号当缓存校验器:与已知版本相同就不再下载 tarball(dsh-market 同款做法,
|
|
302
|
+
* 比 ETag 更好读,也更容易在日志里对上“哪一版目录”)。
|
|
303
|
+
*/
|
|
304
|
+
async function fetchFromPackage(source, options) {
|
|
305
|
+
const registry = options.registry.replace(/\/+$/, "");
|
|
306
|
+
const metaUrl = `${registry}/${encodeURIComponent(source.pkg).replace(/%40/g, "@").replace(/%2F/g, "/")}/latest`;
|
|
307
|
+
const metaResponse = await options.fetchImpl(metaUrl, {
|
|
308
|
+
headers: { accept: "application/json", "user-agent": userAgent() },
|
|
309
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
310
|
+
});
|
|
311
|
+
if (!metaResponse.ok) throw new Error(`HTTP ${String(metaResponse.status)} reading ${source.pkg} metadata`);
|
|
312
|
+
const meta = await metaResponse.json();
|
|
313
|
+
const version = typeof meta?.version === "string" ? meta.version : "";
|
|
314
|
+
const tarball = typeof meta?.dist?.tarball === "string" ? meta.dist.tarball : "";
|
|
315
|
+
if (tarball === "") throw new Error(`${source.pkg} 的 packument 里没有 dist.tarball`);
|
|
316
|
+
if (version !== "" && version === options.version) return { status: "not-modified", version, file: `${source.pkg}@${version}` };
|
|
317
|
+
|
|
318
|
+
const tarballResponse = await options.fetchImpl(tarball, {
|
|
319
|
+
headers: { accept: "application/octet-stream", "user-agent": userAgent() },
|
|
320
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
321
|
+
});
|
|
322
|
+
if (!tarballResponse.ok) throw new Error(`HTTP ${String(tarballResponse.status)} downloading ${source.pkg}@${version}`);
|
|
323
|
+
const gz = Buffer.from(await tarballResponse.arrayBuffer());
|
|
324
|
+
const wanted = source.file ?? "package/registry.json";
|
|
325
|
+
const bytes = fileFromTarball(gz, wanted);
|
|
326
|
+
if (bytes === null) throw new Error(`${source.pkg}@${version} 里没有 ${wanted}`);
|
|
327
|
+
return {
|
|
328
|
+
status: "ok",
|
|
329
|
+
document: JSON.parse(bytes.toString("utf8")),
|
|
330
|
+
version,
|
|
331
|
+
file: `${source.pkg}@${version}:${wanted}`,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** tar 头:512 字节一块,名字在 0,八进制大小在 124,类型在 156。 */
|
|
336
|
+
const NAME_OFFSET = 0;
|
|
337
|
+
const NAME_LENGTH = 100;
|
|
338
|
+
const SIZE_OFFSET = 124;
|
|
339
|
+
const SIZE_LENGTH = 12;
|
|
340
|
+
const TYPE_OFFSET = 156;
|
|
341
|
+
const BLOCK = 512;
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* 从 gzipped tar 里取出一个文件。
|
|
345
|
+
*
|
|
346
|
+
* 手写而不引依赖:只认一个固定文件名,代码比“为什么又多一个 runtime 依赖”的说明还短。
|
|
347
|
+
*
|
|
348
|
+
* @param {Buffer} gz 压缩后的 tarball
|
|
349
|
+
* @param {string} wanted 完整条目名(npm 风格:`package/registry.json`)
|
|
350
|
+
* @returns {Buffer | null}
|
|
351
|
+
*/
|
|
352
|
+
export function fileFromTarball(gz, wanted) {
|
|
353
|
+
const buffer = gunzipSync(gz);
|
|
354
|
+
let offset = 0;
|
|
355
|
+
while (offset + BLOCK <= buffer.length) {
|
|
356
|
+
const name = buffer.toString("utf8", offset + NAME_OFFSET, offset + NAME_OFFSET + NAME_LENGTH).replace(/\0.*$/s, "");
|
|
357
|
+
if (name === "") break;
|
|
358
|
+
const rawSize = buffer
|
|
359
|
+
.toString("ascii", offset + SIZE_OFFSET, offset + SIZE_OFFSET + SIZE_LENGTH)
|
|
360
|
+
.replace(/\0.*$/s, "")
|
|
361
|
+
.trim();
|
|
362
|
+
const size = Number.parseInt(rawSize, 8);
|
|
363
|
+
if (!Number.isFinite(size) || size < 0) break;
|
|
364
|
+
const type = String.fromCharCode(buffer[offset + TYPE_OFFSET] ?? 0);
|
|
365
|
+
offset += BLOCK;
|
|
366
|
+
if ((type === "0" || type === "\0") && name === wanted) {
|
|
367
|
+
return buffer.subarray(offset, offset + size);
|
|
368
|
+
}
|
|
369
|
+
offset += Math.ceil(size / BLOCK) * BLOCK;
|
|
370
|
+
}
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* 校验 registry 文档:外部输入,全部字段从严,不认识就拒。
|
|
376
|
+
*
|
|
377
|
+
* @param {unknown} value 解析后的 JSON
|
|
378
|
+
* @param {{ file?: string }} [meta]
|
|
379
|
+
* @returns {{ ok: boolean, registry: object | null, diagnostics: object[] }}
|
|
380
|
+
*/
|
|
381
|
+
export function validateRegistry(value, meta = {}) {
|
|
382
|
+
const file = meta.file ?? "";
|
|
383
|
+
const diagnostics = [];
|
|
384
|
+
const fail = (code, reason) => {
|
|
385
|
+
diagnostics.push(diagnostic("error", code, reason, file));
|
|
386
|
+
return { ok: false, registry: null, diagnostics };
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return fail("registry-not-object", "registry 必须是对象");
|
|
390
|
+
if (value.kind !== REGISTRY_KIND) return fail("registry-kind", `kind 必须是 "${REGISTRY_KIND}",实际是 ${JSON.stringify(value.kind)}`);
|
|
391
|
+
|
|
392
|
+
for (const key of Object.keys(value)) {
|
|
393
|
+
if (!REGISTRY_FIELDS.includes(key)) diagnostics.push(diagnostic("warn", "registry-unknown-key", `registry 有未知字段 "${key}"`, file));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (!Array.isArray(value.collections)) return fail("registry-collections", "collections 必须是数组");
|
|
397
|
+
if (value.collections.length > LIMITS.collections) {
|
|
398
|
+
return fail("registry-too-many-collections", `collections 最多 ${LIMITS.collections} 条,实际 ${value.collections.length}`);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const collections = [];
|
|
402
|
+
const seen = new Set();
|
|
403
|
+
value.collections.forEach((entry, index) => {
|
|
404
|
+
const where = `collections[${index}]`;
|
|
405
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
406
|
+
diagnostics.push(diagnostic("error", "collection-shape", `${where} 必须是对象`, file));
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
let bad = false;
|
|
410
|
+
for (const key of Object.keys(entry)) {
|
|
411
|
+
if (!COLLECTION_FIELDS.includes(key)) {
|
|
412
|
+
diagnostics.push(diagnostic("warn", "collection-unknown-key", `${where} 有未知字段 "${key}"`, file));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const id = typeof entry.id === "string" ? entry.id.trim() : "";
|
|
416
|
+
if (!PATTERNS.id.test(id)) {
|
|
417
|
+
diagnostics.push(diagnostic("error", "collection-id", `${where}.id 必须是 kebab-case`, file));
|
|
418
|
+
bad = true;
|
|
419
|
+
} else if (seen.has(id)) {
|
|
420
|
+
diagnostics.push(diagnostic("error", "collection-id-duplicate", `${where}.id 与前面的重复:${id}`, file));
|
|
421
|
+
bad = true;
|
|
422
|
+
}
|
|
423
|
+
const repo = typeof entry.repo === "string" ? entry.repo.trim() : "";
|
|
424
|
+
if (!PATTERNS.repo.test(repo)) {
|
|
425
|
+
diagnostics.push(diagnostic("error", "collection-repo", `${where}.repo 必须形如 github:owner/repo`, file));
|
|
426
|
+
bad = true;
|
|
427
|
+
}
|
|
428
|
+
const ref = entry.ref === undefined ? "main" : String(entry.ref);
|
|
429
|
+
if (!PATTERNS.ref.test(ref)) {
|
|
430
|
+
diagnostics.push(diagnostic("error", "collection-ref", `${where}.ref 不是合法的 git ref`, file));
|
|
431
|
+
bad = true;
|
|
432
|
+
}
|
|
433
|
+
const path = entry.path === undefined ? "rosters" : String(entry.path);
|
|
434
|
+
if (!PATTERNS.relPath.test(path)) {
|
|
435
|
+
diagnostics.push(diagnostic("error", "collection-path", `${where}.path 必须是仓库内相对目录`, file));
|
|
436
|
+
bad = true;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const rosters = [];
|
|
440
|
+
const rawRosters = Array.isArray(entry.rosters) ? entry.rosters : [];
|
|
441
|
+
if (rawRosters.length > LIMITS.rostersPerCollection) {
|
|
442
|
+
diagnostics.push(diagnostic("warn", "collection-rosters-many", `${where}.rosters 超过 ${LIMITS.rostersPerCollection} 份,只取前若干份`, file));
|
|
443
|
+
}
|
|
444
|
+
for (const item of rawRosters.slice(0, LIMITS.rostersPerCollection)) {
|
|
445
|
+
const rosterId = typeof item === "string" ? item : typeof item?.id === "string" ? item.id : "";
|
|
446
|
+
if (!PATTERNS.id.test(rosterId)) {
|
|
447
|
+
diagnostics.push(diagnostic("warn", "collection-roster-id", `${where}.rosters 里有不合法的档案 id:${JSON.stringify(item)}`, file));
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
if (!rosters.includes(rosterId)) rosters.push(rosterId);
|
|
451
|
+
}
|
|
452
|
+
if (bad) return;
|
|
453
|
+
|
|
454
|
+
seen.add(id);
|
|
455
|
+
collections.push({
|
|
456
|
+
id,
|
|
457
|
+
title: typeof entry.title === "string" ? entry.title.trim() : id,
|
|
458
|
+
description: typeof entry.description === "string" ? entry.description.trim() : "",
|
|
459
|
+
repo,
|
|
460
|
+
ref,
|
|
461
|
+
path: path.replace(/^\/+|\/+$/g, ""),
|
|
462
|
+
rosters,
|
|
463
|
+
tags: Array.isArray(entry.tags) ? entry.tags.filter((tag) => typeof tag === "string") : [],
|
|
464
|
+
license: typeof entry.license === "string" ? entry.license : "",
|
|
465
|
+
maintainers: Array.isArray(entry.maintainers) ? entry.maintainers.filter((item) => typeof item === "string") : [],
|
|
466
|
+
homepage: typeof entry.homepage === "string" ? entry.homepage : "",
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
const ok = diagnostics.every((item) => item.severity !== "error") && collections.length > 0;
|
|
471
|
+
return { ok, registry: ok ? { kind: value.kind, collections } : null, diagnostics };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* 把一个 collection 里的档案逐份拉下来并校验。
|
|
476
|
+
*
|
|
477
|
+
* 只读:不落盘到档案库,返回带 `origin` 的档案对象,上层决定怎么展示。
|
|
478
|
+
*
|
|
479
|
+
* @param {object} collection `validateRegistry` 产物里的一条
|
|
480
|
+
* @param {object} [options]
|
|
481
|
+
* @param {typeof fetch} [options.fetchImpl]
|
|
482
|
+
* @param {(id: string) => boolean} [options.only] 只要这几份(面板按需拉取时用)
|
|
483
|
+
* @returns {Promise<{ collection: object, rosters: object[], diagnostics: object[] }>}
|
|
484
|
+
*/
|
|
485
|
+
export async function fetchCollection(collection, options = {}) {
|
|
486
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
487
|
+
const wanted = collection.rosters.filter((id) => options.only === undefined || options.only(id));
|
|
488
|
+
const rosters = [];
|
|
489
|
+
const diagnostics = [];
|
|
490
|
+
|
|
491
|
+
for (const rosterId of wanted) {
|
|
492
|
+
let loaded = null;
|
|
493
|
+
let lastError = "";
|
|
494
|
+
for (const fileName of ["roster.yaml", "roster.yml", "roster.json"]) {
|
|
495
|
+
const url = collectionFileUrl(collection, rosterId, fileName);
|
|
496
|
+
try {
|
|
497
|
+
const response = await fetchImpl(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
498
|
+
if (!response.ok) {
|
|
499
|
+
lastError = `HTTP ${String(response.status)}`;
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
loaded = { url, fileName, text: await response.text() };
|
|
503
|
+
break;
|
|
504
|
+
} catch (error) {
|
|
505
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
if (loaded === null) {
|
|
509
|
+
diagnostics.push(diagnostic("warn", "collection-fetch-failed", `${collection.id}/${rosterId} 拉取失败:${lastError || "未知原因"}`));
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
const parsed = parseDocumentText(loaded.text, { file: loaded.url });
|
|
513
|
+
const checked = parsed.ok
|
|
514
|
+
? validateRoster(parsed.value, { file: loaded.url, expectedId: rosterId })
|
|
515
|
+
: { ok: false, diagnostics: parsed.diagnostics };
|
|
516
|
+
if (!checked.ok) {
|
|
517
|
+
diagnostics.push(...checked.diagnostics);
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
rosters.push({
|
|
521
|
+
...checked.roster,
|
|
522
|
+
origin: {
|
|
523
|
+
kind: "remote",
|
|
524
|
+
collection: collection.id,
|
|
525
|
+
title: collection.title,
|
|
526
|
+
repo: collection.repo,
|
|
527
|
+
ref: collection.ref,
|
|
528
|
+
path: collection.path,
|
|
529
|
+
url: loaded.url,
|
|
530
|
+
},
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return { collection, rosters, diagnostics };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** 拼一份档案的 raw 地址。 */
|
|
538
|
+
export function collectionFileUrl(collection, rosterId, fileName) {
|
|
539
|
+
const repo = collection.repo.replace(/^github:/, "");
|
|
540
|
+
return `${GITHUB_RAW}/${repo}/${encodeURIComponent(collection.ref)}/${collection.path}/${rosterId}/${fileName}`;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** 一次读多个源(串行:GitHub 未认证配额经不起并发烧)。 */
|
|
544
|
+
export async function loadRegistryIndex(config, options = {}) {
|
|
545
|
+
const { sources, diagnostics } = normalizeIndexSources(config?.indexes);
|
|
546
|
+
const indexes = [];
|
|
547
|
+
const collections = [];
|
|
548
|
+
const problems = [...diagnostics];
|
|
549
|
+
|
|
550
|
+
for (const source of sources) {
|
|
551
|
+
const loaded = await loadRegistry(source, {
|
|
552
|
+
...options,
|
|
553
|
+
region: options.region ?? (config?.region === "china" ? "china" : "global"),
|
|
554
|
+
});
|
|
555
|
+
indexes.push({
|
|
556
|
+
id: source.id,
|
|
557
|
+
label: loaded.label,
|
|
558
|
+
kind: source.kind,
|
|
559
|
+
ok: loaded.ok,
|
|
560
|
+
stale: loaded.stale,
|
|
561
|
+
fetchedAt: loaded.fetchedAt,
|
|
562
|
+
ttlMs: source.ttlMs ?? REGISTRY_TTL_MS,
|
|
563
|
+
collections: loaded.collections.length,
|
|
564
|
+
});
|
|
565
|
+
problems.push(...loaded.diagnostics);
|
|
566
|
+
for (const collection of loaded.collections) {
|
|
567
|
+
collections.push({ ...collection, origin: { kind: "registry", source: source.id, label: loaded.label, stale: loaded.stale } });
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return { indexes, collections, diagnostics: problems };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* 缓存目录里的文件名。
|
|
576
|
+
*
|
|
577
|
+
* 目录跟着档案库根走(`$DSH_HOME/agent-roster/.index-cache`),所以它和档案库同生命周期。
|
|
578
|
+
*/
|
|
579
|
+
export function cachePathFor(cacheDir, id) {
|
|
580
|
+
return nodePath.join(cacheDir, `${id}.json`);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** 读缓存;坏了就当没有(不因为一个坏文件让整个源不可用)。 */
|
|
584
|
+
function readCache(file) {
|
|
585
|
+
try {
|
|
586
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
587
|
+
if (typeof parsed?.fetchedAt !== "number" || typeof parsed?.document !== "object" || parsed.document === null) return null;
|
|
588
|
+
return parsed;
|
|
589
|
+
} catch {
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** 原子写缓存:先写临时文件再改名,避免半截 JSON。 */
|
|
595
|
+
function writeCache(file, payload) {
|
|
596
|
+
if (file === null) return;
|
|
597
|
+
fs.mkdirSync(nodePath.dirname(file), { recursive: true });
|
|
598
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
599
|
+
fs.writeFileSync(temp, JSON.stringify(payload));
|
|
600
|
+
fs.renameSync(temp, file);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** 请求头里的自我标识:GitHub 要求有 UA。 */
|
|
604
|
+
function userAgent() {
|
|
605
|
+
return "dsh-agent-roster/registry";
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** 供测试与 CLI 使用:解析一份本地 registry 文件。 */
|
|
609
|
+
export function readRegistryFile(file) {
|
|
610
|
+
const text = fs.readFileSync(file, "utf8");
|
|
611
|
+
const parsed = JSON.parse(text);
|
|
612
|
+
return validateRegistry(parsed, { file });
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** 一份 registry 里所有 collection 的档案总数(面板摘要用)。 */
|
|
616
|
+
export function countRegistryRosters(collections) {
|
|
617
|
+
return collections.reduce((total, collection) => total + collection.rosters.length, 0);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** 供 CLI 造索引时校验档案形态(复用同一套 pattern,不另写一份)。 */
|
|
621
|
+
export function isRegistryRoster(value) {
|
|
622
|
+
return typeof value === "object" && value !== null && value.kind === ROSTER_KIND;
|
|
623
|
+
}
|