@1e0zj/dsh-plugin-mall 0.1.16 → 0.1.18

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/src/github.js CHANGED
@@ -1,541 +1,625 @@
1
- // GitHub API helpers for the dsh plugin marketplace.
2
- // Pure functions with no harness imports, so this module is unit-testable
3
- // standalone (node src/github.js --self-test).
4
- import { readFileSync } from "node:fs";
5
- import { join } from "node:path";
6
-
7
- const SEARCH_TOPIC = "topic:dsh-plugin";
8
- /** GitHub search never serves past the first 1000 results. */
9
- const SEARCH_WINDOW = 1000;
10
-
11
- export function buildHeaders(token) {
12
- const headers = {
13
- "User-Agent": "dsh-plugin-mall",
14
- Accept: "application/vnd.github+json",
15
- "X-GitHub-Api-Version": "2022-11-28",
16
- };
17
- if (token) headers.Authorization = `Bearer ${token}`;
18
- return headers;
19
- }
20
-
21
- const DEFAULT_API_BASE = "https://api.github.com";
22
-
23
- function apiUrl(apiBase, path) {
24
- const raw = typeof apiBase === "string" && apiBase.length > 0 ? apiBase : DEFAULT_API_BASE;
25
- const base = raw.endsWith("/") ? raw : `${raw}/`;
26
- return `${base}${path.replace(/^\//, "")}`;
27
- }
28
-
29
- async function requestJson(path, { apiBase, token, signal }) {
30
- let response;
31
- try {
32
- response = await fetch(apiUrl(apiBase, path), {
33
- headers: buildHeaders(token),
34
- signal,
35
- });
36
- } catch (error) {
37
- if (error?.name === "AbortError") throw error;
38
- throw new Error(`GitHub API request failed: ${error?.message ?? String(error)}`);
39
- }
40
- const remaining = response.headers.get("x-ratelimit-remaining");
41
- const resetAt = response.headers.get("x-ratelimit-reset");
42
- const body = await response.json().catch(() => undefined);
43
- if (response.status === 403 && remaining === "0" && resetAt !== null) {
44
- const reset = new Date(Number(resetAt) * 1000).toISOString();
45
- throw new Error(`GitHub API rate limit exceeded; resets at ${reset} (UTC). Set GITHUB_TOKEN or DSH_MARKET_GITHUB_TOKEN for a higher limit.`);
46
- }
47
- if (response.status === 404) {
48
- throw new Error(`GitHub API 404: ${body?.message ?? "not found"}`);
49
- }
50
- if (!response.ok) {
51
- throw new Error(`GitHub API ${response.status}: ${body?.message ?? response.statusText}`);
52
- }
53
- return body;
54
- }
55
-
56
- /** Pick the stable, compact fields the tools render. */
57
- function pickRepo(item) {
58
- return {
59
- fullName: item.full_name ?? "",
60
- htmlUrl: item.html_url ?? "",
61
- description: item.description ?? "",
62
- stars: item.stargazers_count ?? 0,
63
- forks: item.forks_count ?? 0,
64
- isFork: item.fork === true,
65
- language: item.language,
66
- license: item.license?.spdx_id,
67
- topics: item.topics ?? [],
68
- updatedAt: item.updated_at ?? "",
69
- archived: item.archived ?? false,
70
- defaultBranch: item.default_branch ?? "main",
71
- };
72
- }
73
-
74
- /**
75
- * Search repositories tagged `topic:dsh-plugin`, optionally narrowed by
76
- * keywords (name/description/readme match), star-ranked by default.
77
- * `minStars` (default 1) is pushed into the query as `stars:>=N` so the
78
- * topic's noise (empty/demo repos riding the tag) is filtered server-side
79
- * and `total` stays accurate; pass 0 to disable.
80
- */
81
- export async function searchPlugins({ query, sort = "stars", perPage = 10, page = 1, minStars, apiBase, token, signal }) {
82
- const trimmed = typeof query === "string" ? query.trim() : "";
83
- const parts = [SEARCH_TOPIC];
84
- if (trimmed.length > 0) parts.push(trimmed);
85
- const safeMinStars = Math.max(Math.trunc(Number(minStars ?? 1)) || 0, 0);
86
- if (safeMinStars > 0) parts.push(`stars:>=${safeMinStars}`);
87
- const q = parts.join(" ");
88
- const safePerPage = Math.min(Math.max(Math.trunc(perPage) || 10, 1), 100);
89
- const safePage = Math.max(Math.trunc(page) || 1, 1);
90
- const path = `/search/repositories?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}&order=desc&per_page=${safePerPage}&page=${safePage}`;
91
- let body;
92
- try {
93
- body = await requestJson(path, { apiBase, token, signal });
94
- } catch (error) {
95
- // Past the first 1000 results GitHub 422s with "Only the first 1000
96
- // search results are available" — surface that as a clean empty
97
- // truncated page instead of a hard error.
98
- if (/first 1000 search results/i.test(String(error?.message ?? ""))) {
99
- return { total: SEARCH_WINDOW, page: safePage, perPage: safePerPage, items: [], truncated: true };
100
- }
101
- throw error;
102
- }
103
- return {
104
- total: body.total_count ?? 0,
105
- page: safePage,
106
- perPage: safePerPage,
107
- items: (body.items ?? []).map(pickRepo),
108
- };
109
- }
110
-
111
- // ── npm registry (prefer-npm installs + update checks) ──────────────────────
112
- //
113
- // npm tarballs beat GitHub whole-repo tarballs: smaller (files field only),
114
- // faster, integrity-checked. `preferNpmSpec` rewrites a github: install spec
115
- // to its npm package name but only when the registry entry's repository URL
116
- // points back at that GitHub repo, which doubles as an anti-squatting check
117
- // (an unrelated package squatting the name never matches, install falls back
118
- // to the explicit github: spec).
119
- //
120
- // The registry to query is the CALLER's business (see resolveRegistry in
121
- // installer.js): it has to be the one pnpm installs from. Querying npmjs while
122
- // pnpm installs from a mirror fails three ways at once and all of them are
123
- // silent anti-squatting never matches (every install degrades to a whole-repo
124
- // GitHub clone plus a prepare build), `latest` comes back null (the update
125
- // button disappears), and assertSafeToInstall sees no manifest, so the
126
- // host-shadow guard stops guarding.
127
-
128
- export const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
129
- // `${registry}|${name}` -> {info, at}. Keyed by registry so a config change
130
- // cannot serve answers from a different registry.
131
- const npmCache = new Map();
132
- // Success and failure expire alike. A never-expiring success cache means
133
- // `hasUpdate` is frozen for the process lifetime: the author publishes, and no
134
- // amount of "刷新已装" shows it until dsh restarts — self-defeating for a
135
- // marketplace whose selling point is update management.
136
- const NPM_CACHE_TTL = 300000;
137
-
138
- /** Strip trailing slashes so `${base}/${name}` never doubles up. */
139
- function normalizeRegistry(registry) {
140
- const value = String(registry ?? "").trim();
141
- return (value.length > 0 ? value : DEFAULT_NPM_REGISTRY).replace(/\/+$/, "");
142
- }
143
-
144
- /**
145
- * Look up a package's `latest` manifest on an npm registry.
146
- * @param name - the npm package name.
147
- * @param options - `registry` defaults to npmjs; pass what pnpm installs from.
148
- * @returns `{latest, repositoryUrl, hostDeps}`, or null when unknown/unreachable.
149
- */
150
- export async function npmPackageInfo(name, { registry } = {}) {
151
- const clean = String(name ?? "").trim();
152
- if (clean.length === 0 || !/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i.test(clean)) return null;
153
- const base = normalizeRegistry(registry);
154
- const key = `${base}|${clean}`;
155
- const cached = npmCache.get(key);
156
- if (cached !== undefined && Date.now() - cached.at < NPM_CACHE_TTL) return cached.info;
157
- let info = null;
158
- try {
159
- // 单版本端点返回 latest 的完整 manifest(dependencies + repository 都在)。
160
- // abbreviated packument 不含 repository,防抢注比对会永远落空。
161
- // 镜像(npmmirror 等)的同名端点响应格式一致,两个字段都保留。
162
- const response = await fetch(`${base}/${clean.replace("/", "%2F")}/latest`, {
163
- headers: { "User-Agent": "dsh-plugin-mall", Accept: "application/json" },
164
- });
165
- if (response.ok) {
166
- const body = await response.json();
167
- if (typeof body?.version === "string") {
168
- const rawRepository = body.repository;
169
- const repositoryUrl = typeof rawRepository === "string" ? rawRepository : rawRepository?.url;
170
- info = {
171
- latest: body.version,
172
- repositoryUrl: typeof repositoryUrl === "string" ? repositoryUrl : undefined,
173
- hostDeps: hostShadowDependencies(body),
174
- };
175
- }
176
- }
177
- } catch {
178
- info = null; // registry unreachable — caller falls back
179
- }
180
- npmCache.set(key, { info, at: Date.now() });
181
- return info;
182
- }
183
-
184
- /**
185
- * Rewrite "github:owner/repo" (or "owner/repo") to the npm package name when
186
- * that package exists on npm AND its repository URL points back at the repo
187
- * (anti-squatting). Anything else passes through untouched.
188
- */
189
- export async function preferNpmSpec({ spec, registry, sources }) {
190
- const raw = String(spec ?? "");
191
- // A scoped npm name ("@scope/name", "@scope/name@1.2.3") is shaped exactly
192
- // like owner/repo and matches the regex below, sending every such install
193
- // off to verify a repository that cannot exist — two wasted CDN requests and
194
- // a bogus cache entry. Same guard normalizeSpec already uses.
195
- if (raw.startsWith("@")) return raw;
196
- const githubMatch = /^(?:github:)?([^/\s]+\/[^/\s]+?)(?:\.git)?$/i.exec(raw);
197
- if (githubMatch === null) return raw;
198
- const repo = githubMatch[1];
199
- const { results } = await verifyPlugins({ repos: [repo], sources }); // cache hit after first verify
200
- const declaredName = results[repo]?.name;
201
- if (typeof declaredName !== "string") return raw;
202
- const info = await npmPackageInfo(declaredName, { registry });
203
- if (info === null || info.repositoryUrl === undefined) return raw;
204
- const pointsBack = new RegExp(`github\\.com[/:]${repo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(/|\\.git|$)`, "i").test(info.repositoryUrl);
205
- return pointsBack ? declaredName : raw;
206
- }
207
-
208
- /**
209
- * Refuse to install a package whose `dependencies` shadow host framework
210
- * packages. Host copies inside a profile split module identities and crash
211
- * all tool scheduling — the dsh contract is peerDependencies, but the host
212
- * enforces nothing, so the marketplace is the last line of defense.
213
- * Sources: registry abbreviated metadata for npm names, the verify cache
214
- * (raw package.json) for github: specs.
215
- */
216
- /**
217
- * Extract the bare npm package name: "name", "name@version", "@s/n@version"
218
- * "name"/"@s/n". Returns null for anything that is not an npm name shape.
219
- */
220
- function npmNameOf(raw) {
221
- if (typeof raw !== "string" || raw.length === 0) return null;
222
- if (raw.startsWith("@")) {
223
- const match = /^(@[^/@\s]+\/[^/@\s]+?)(?:@[^/\s]+)?$/.exec(raw);
224
- return match === null ? null : match[1];
225
- }
226
- const match = /^([^@\s]+?)(?:@[^/\s]+)?$/.exec(raw);
227
- return match === null ? null : match[1];
228
- }
229
-
230
- export async function assertSafeToInstall({ spec, registry, sources }) {
231
- const raw = String(spec ?? "");
232
- let hostDeps;
233
- if (/^(?:file:|link:)/i.test(raw)) {
234
- // 本地路径直通:直接读它的 package.json 查 dependencies。
235
- const dir = raw.replace(/^(?:file:|link:)/i, "").replace(/[\\/]+$/, "");
236
- try {
237
- const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
238
- hostDeps = hostShadowDependencies(pkg);
239
- } catch {
240
- return; // 读不到清单——pnpm 会给出真实错误,这里放行
241
- }
242
- } else if (/^github:([^/\s]+\/[^/\s]+?)(?:\.git)?(?:#.+)?$/i.test(raw)) {
243
- const repo = /^github:([^/\s]+\/[^/\s]+?)(?:\.git)?(?:#.+)?$/i.exec(raw)[1];
244
- const { results } = await verifyPlugins({ repos: [repo], sources });
245
- hostDeps = results[repo]?.hostDeps;
246
- } else if (/^https?:\/\//i.test(raw)) {
247
- return; // 远程 tarball 下载前无法廉价检查;罕见路径,放行
248
- } else {
249
- // npm 名(含带版本形式):剥掉 @version 再查。
250
- const name = npmNameOf(raw);
251
- if (name === null || name.length === 0) {
252
- // 无法识别形状的 spec 一律拒绝,而不是静默跳过检查。
253
- throw new Error(`cannot analyze install spec ${JSON.stringify(raw)} for host-shadow dependencies — refusing to install`);
254
- }
255
- const info = await npmPackageInfo(name, { registry });
256
- hostDeps = info?.hostDeps;
257
- }
258
- if (hostDeps !== undefined && hostDeps !== null && hostDeps.length > 0) {
259
- throw new Error(`${raw} declares ${hostDeps.length} host framework package(s) as dependencies (${hostDeps.slice(0, 3).join(", ")}${hostDeps.length > 3 ? ", …" : ""}) — installing it would duplicate host modules and crash dsh tool scheduling. Ask the plugin author to move @deepseek-ai/* to peerDependencies.`);
260
- }
261
- }
262
-
263
- /** Loose semver-ish comparison: "0.2.10" vs "0.2.9" → 1. Non-numeric parts
264
- * read as 0; numerically equal versions where one carries a pre-release
265
- * segment ("-rc.1") sort below the plain release ("2.0.0-beta" < "2.0.0"). */
266
- export function compareVersions(a, b) {
267
- const pa = String(a ?? "").split(".");
268
- const pb = String(b ?? "").split(".");
269
- for (let index = 0; index < Math.max(pa.length, pb.length); index++) {
270
- const na = Number(pa[index]) || 0;
271
- const nb = Number(pb[index]) || 0;
272
- if (na !== nb) return na < nb ? -1 : 1;
273
- }
274
- const preA = String(a ?? "").includes("-");
275
- const preB = String(b ?? "").includes("-");
276
- if (preA !== preB) return preA ? -1 : 1;
277
- return 0;
278
- }
279
- // ── plugin verification (raw CDN, no API quota) ─────────────────────────────
280
- //
281
- // The topic carries thousands of repos that are not dsh plugins at all. The
282
- // authoritative signal is a package.json declaring `dsh.bundle.patch` (host
283
- // bundle) or `dsh.client` (browser UI plugin) — the same contract
284
- // classifyPackage applies locally. package.json is fetched from
285
- // raw.githubusercontent.com (a CDN that does not consume REST API quota), so a
286
- // page of 20 verifies in one burst even without a token. Results are cached
287
- // for the process lifetime; a fetch failure caches "unknown" rather than
288
- // retrying forever.
289
-
290
- /** Cap on concurrent outbound requests — shared by verification and update checks. */
291
- export const NETWORK_CONCURRENCY = 8;
292
- // repo -> {kind, name, version, hostDeps, ts}
293
- const verifyCache = new Map();
294
- // Verdicts expire. Caching a success for the process lifetime freezes the badge
295
- // on whatever the repo looked like the first time it was seen: when dsh-TUI
296
- // moved its @deepseek-ai/* deps to peerDependencies, the red "宿主依赖风险"
297
- // badge stayed up until dsh restarted, still accusing a repo that had been
298
- // fixed. Ten minutes keeps a browsing session on cache while letting a fix
299
- // surface on its own.
300
- const VERIFY_TTL = 600000;
301
- // Network failures retry sooner "unknown" carries no information worth keeping.
302
- const VERIFY_TTL_UNKNOWN = 60000;
303
-
304
- /**
305
- * Run `task` over `items` with at most `limit` workers in flight. The one
306
- * fan-out primitive for this module's network work, so nothing accidentally
307
- * bursts every item at once.
308
- */
309
- export async function mapLimit(items, limit, task) {
310
- const list = Array.isArray(items) ? items : [];
311
- let cursor = 0;
312
- const worker = async () => {
313
- while (cursor < list.length) {
314
- const index = cursor++;
315
- await task(list[index], index);
316
- }
317
- };
318
- await Promise.all(Array.from({ length: Math.min(limit, list.length) }, worker));
319
- }
320
-
321
- // package.json is fetched from CDNs, not the REST API, so verification never
322
- // burns API quota. jsDelivr first (reachable where raw.githubusercontent.com
323
- // is blocked), raw as fallback; a 404 only means "no manifest" once EVERY
324
- // reachable source 404s (jsDelivr lags new pushes, so one 404 is not final).
325
- // URL templates, `{repo}` substituted with owner/name — overridable through
326
- // the `rawSources` config for self-hosted reverse proxies.
327
- export const DEFAULT_RAW_SOURCES = [
328
- "https://cdn.jsdelivr.net/gh/{repo}@HEAD/package.json",
329
- "https://raw.githubusercontent.com/{repo}/HEAD/package.json",
330
- ];
331
-
332
- /** The configured source templates, or the built-in pair. */
333
- function rawSourcesOf(sources) {
334
- const list = (Array.isArray(sources) ? sources : [])
335
- .map((entry) => String(entry ?? "").trim())
336
- .filter((entry) => entry.length > 0 && entry.includes("{repo}"));
337
- return list.length > 0 ? list : DEFAULT_RAW_SOURCES;
338
- }
339
-
340
- async function fetchRawPackageJson(repo, signal, sources) {
341
- let saw404 = false;
342
- let lastError;
343
- for (const template of rawSourcesOf(sources)) {
344
- try {
345
- const response = await fetch(template.replace("{repo}", repo), {
346
- headers: { "User-Agent": "dsh-plugin-mall", Accept: "application/json" },
347
- signal,
348
- });
349
- if (response.status === 404) { saw404 = true; continue; }
350
- if (!response.ok) throw new Error(`source returned ${response.status}`);
351
- return await response.json();
352
- } catch (error) {
353
- if (error?.name === "AbortError") throw error;
354
- lastError = error;
355
- }
356
- }
357
- if (saw404) return undefined;
358
- throw lastError ?? new Error("no raw source reachable");
359
- }
360
-
361
- /**
362
- * Framework packages the host provides via the profiles/node_modules fallback.
363
- * A plugin declaring any of these as `dependencies` (instead of
364
- * peerDependencies) installs real copies into the profile — the loader then
365
- * holds two module instances, symbol identities split, and every tool call
366
- * crashes with an undiagnosable "reading 'prepare'". See installer docs.
367
- *
368
- * ONLY the @deepseek-ai scope belongs here. This used to also match the bare
369
- * upstream `cosmokit` and `schemastery` on the theory that dsh forked the
370
- * cordis trio and therefore shipped those too. It does not: the host tree has
371
- * no bare copy at any depth, and none of its 195 @deepseek-ai packages depends
372
- * on one — the fork is complete and cut its ties to the upstream names. A
373
- * plugin depending on bare `schemastery` shadows nothing, so flagging it was a
374
- * pure false positive, and an expensive one: it hard-blocked 7 real plugins in
375
- * the topic's top 300, `omdsh-dev/DSH-better-sidebar` (★1832) among them —
376
- * every one of which declares its @deepseek-ai/* deps as peers correctly.
377
- * Fixtures at the bottom of this file pin the distinction.
378
- */
379
- const HOST_PACKAGES = /^@deepseek-ai\//;
380
-
381
- /** The @deepseek-ai/* entries inside `dependencies`. */
382
- function hostShadowDependencies(pkg) {
383
- if (pkg === undefined || typeof pkg !== "object") return undefined;
384
- const deps = Object.keys(pkg.dependencies ?? {});
385
- const host = deps.filter((name) => HOST_PACKAGES.test(name));
386
- return host.length > 0 ? host : undefined;
387
- }
388
-
389
- /**
390
- * Verify repositories as real dsh plugins by their package.json declaration.
391
- * @param {{repos: string[], signal?: AbortSignal, sources?: string[]}} options - "owner/name" list.
392
- * @returns the {results} map: fullName -> {kind: "bundle"|"client"|"plain"|"no-manifest"|"unknown", name?, version?, hostDeps?}.
393
- */
394
- export async function verifyPlugins({ repos, signal, sources }) {
395
- // A GitHub owner never starts with "@", so rejecting that shape here keeps
396
- // scoped npm names ("@scope/name") out of repository verification no matter
397
- // which caller passes them in.
398
- const wanted = [...new Set((Array.isArray(repos) ? repos : []).map(String)
399
- .filter((repo) => /^[^@/\s][^/\s]*\/[^/\s]+$/.test(repo) && !repo.includes("..")))];
400
- // 每条判定都带时间戳:成功 10 分钟过期,网络失败 60 秒过期。
401
- const pending = wanted.filter((repo) => {
402
- const cached = verifyCache.get(repo);
403
- if (cached === undefined) return true;
404
- const ttl = cached.kind === "unknown" ? VERIFY_TTL_UNKNOWN : VERIFY_TTL;
405
- return Date.now() - (cached.ts ?? 0) > ttl;
406
- });
407
- await mapLimit(pending, NETWORK_CONCURRENCY, async (repo) => {
408
- try {
409
- const pkg = await fetchRawPackageJson(repo, signal, sources);
410
- const kind = pkg === undefined ? "no-manifest"
411
- : typeof pkg.dsh?.bundle?.patch === "string" ? "bundle"
412
- : pkg.dsh?.client !== undefined ? "client"
413
- : "plain";
414
- verifyCache.set(repo, { kind, name: pkg?.name, version: pkg?.version, hostDeps: hostShadowDependencies(pkg), ts: Date.now() });
415
- } catch (error) {
416
- if (error?.name === "AbortError") throw error;
417
- verifyCache.set(repo, { kind: "unknown", ts: Date.now() });
418
- }
419
- });
420
- // 一律重建对象:内部的 ts 不该出现在发给浏览器的响应里。
421
- const results = {};
422
- for (const repo of wanted) {
423
- const cached = verifyCache.get(repo);
424
- results[repo] = { kind: cached?.kind ?? "unknown", name: cached?.name, version: cached?.version, hostDeps: cached?.hostDeps };
425
- }
426
- return { results };
427
- }
428
-
429
- /**
430
- * Fetch one repository's metadata plus its package.json (base64-decoded),
431
- * which is what tells us whether it declares a dsh bundle patch.
432
- */
433
- export async function repoInfo({ repo, apiBase, token, signal }) {
434
- const trimmed = String(repo ?? "").trim();
435
- if (!/^[^/\s]+\/[^/\s]+$/.test(trimmed) || trimmed.includes("..")) {
436
- throw new Error(`market_info: repo must be "owner/name", got ${JSON.stringify(trimmed)}`);
437
- }
438
- let meta;
439
- try {
440
- meta = await requestJson(`/repos/${trimmed}`, { apiBase, token, signal });
441
- } catch (error) {
442
- throw new Error(`market_info: repository ${trimmed} not found on GitHub (${error.message})`);
443
- }
444
- let packageJson;
445
- try {
446
- const contents = await requestJson(`/repos/${trimmed}/contents/package.json`, { apiBase, token, signal });
447
- if (typeof contents.content === "string") {
448
- packageJson = JSON.parse(Buffer.from(contents.content, "base64").toString("utf8"));
449
- }
450
- } catch {
451
- packageJson = undefined; // no package.json at the repo root
452
- }
453
- return {
454
- meta: pickRepo(meta),
455
- packageJson: packageJson === undefined ? undefined : {
456
- name: packageJson.name,
457
- version: packageJson.version,
458
- description: packageJson.description,
459
- type: packageJson.type,
460
- dshBundlePatch: typeof packageJson.dsh?.bundle?.patch === "string" ? packageJson.dsh.bundle.patch : undefined,
461
- dshClientPlatform: packageJson.dsh?.client?.platform,
462
- dshClientInjectCount: Array.isArray(packageJson.dsh?.client?.inject) ? packageJson.dsh.client.inject.length : undefined,
463
- dependencyCount: Object.keys(packageJson.dependencies ?? {}).length,
464
- peerDependencyCount: Object.keys(packageJson.peerDependencies ?? {}).length,
465
- },
466
- };
467
- }
468
-
469
- // ── offline fixtures ────────────────────────────────────────────────────────
470
- //
471
- // The host-shadow check has no live regression case any more: dsh-TUI, the
472
- // plugin it was built against, moved its @deepseek-ai/* deps to
473
- // peerDependencies after being reported, and nothing else on the network pins
474
- // the bare-vs-scoped distinction. These manifests do. Shapes are taken from
475
- // real repositories in the dsh-plugin topic, trimmed to the relevant fields.
476
- const HOST_SHADOW_FIXTURES = [
477
- {
478
- label: "omdsh-dev/DSH-better-sidebar 上游裸包,宿主不提供,放行",
479
- pkg: {
480
- dependencies: { schemastery: "^3.18.0", ws: "^8.18.0", clsx: "^2.1.1", "node-pty": "^1.1.0" },
481
- peerDependencies: { "@deepseek-ai/dsh-tools": "^0.1.0-rc.6", "@deepseek-ai/cordis": "^4.0.1" },
482
- },
483
- expect: undefined,
484
- },
485
- {
486
- label: "裸 cordis / cosmokit — fork 已与上游断开,放行",
487
- pkg: { dependencies: { cordis: "^4.0.0-rc.7", cosmokit: "^1.6.3" } },
488
- expect: undefined,
489
- },
490
- {
491
- label: "作用域包写进 dependencies 真·宿主重复,拦截",
492
- pkg: { dependencies: { "@deepseek-ai/schemastery": "^1.0.0" } },
493
- expect: ["@deepseek-ai/schemastery"],
494
- },
495
- {
496
- label: "合法 peer + 非法 dep 混合 — 仍须拦截",
497
- pkg: {
498
- dependencies: { "@deepseek-ai/dsh-settings": "*", clsx: "^2" },
499
- peerDependencies: { "@deepseek-ai/dsh-tools": "*" },
500
- },
501
- expect: ["@deepseek-ai/dsh-settings"],
502
- },
503
- {
504
- label: "只在 peerDependencies 声明 — 正确写法,放行",
505
- pkg: { peerDependencies: { "@deepseek-ai/dsh-tools": "*", "@deepseek-ai/cordis": "^4.0.1" } },
506
- expect: undefined,
507
- },
508
- { label: "无 dependencies 字段", pkg: {}, expect: undefined },
509
- { label: "非对象清单", pkg: undefined, expect: undefined },
510
- ];
511
-
512
- /** Run the offline fixtures; returns the failure count. */
513
- function runHostShadowFixtures() {
514
- let failed = 0;
515
- for (const { label, pkg, expect } of HOST_SHADOW_FIXTURES) {
516
- const actual = hostShadowDependencies(pkg);
517
- const ok = JSON.stringify(actual ?? null) === JSON.stringify(expect ?? null);
518
- if (!ok) failed++;
519
- console.log(` ${ok ? "PASS" : "FAIL"} ${label}${ok ? "" : ` 期望 ${JSON.stringify(expect)},实得 ${JSON.stringify(actual)}`}`);
520
- }
521
- return failed;
522
- }
523
-
524
- // Self-test entry: node src/github.js --self-test
525
- // Offline fixtures run first and gate the network smoke test below.
526
- if (process.argv[1]?.endsWith("github.js") && process.argv.includes("--self-test")) {
527
- console.log("宿主依赖检测 fixtures:");
528
- const failed = runHostShadowFixtures();
529
- console.log(`${HOST_SHADOW_FIXTURES.length - failed}/${HOST_SHADOW_FIXTURES.length} passed\n`);
530
- if (failed > 0) process.exit(1);
531
- if (process.argv.includes("--offline")) process.exit(0);
532
- const apiBase = "https://api.github.com";
533
- const result = await searchPlugins({ query: "", perPage: 3, apiBase });
534
- console.log(`total=${result.total} page=${result.page} perPage=${result.perPage}`);
535
- for (const item of result.items) console.log(`${item.fullName} ★${item.stars} ${item.language ?? ""}`);
536
- if (result.items.length > 0) {
537
- const info = await repoInfo({ repo: result.items[0].fullName, apiBase });
538
- console.log(`repo=${info.meta.fullName} defaultBranch=${info.meta.defaultBranch} archived=${info.meta.archived}`);
539
- console.log(`packageJson=${info.packageJson ? `${info.packageJson.name}@${info.packageJson.version} bundle=${info.packageJson.dshBundlePatch ?? "none"}` : "absent"}`);
540
- }
541
- }
1
+ // GitHub API helpers for the dsh plugin marketplace.
2
+ // Pure functions with no harness imports, so this module is unit-testable
3
+ // standalone (node src/github.js --self-test).
4
+ import { readFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+
7
+ const SEARCH_TOPIC = "topic:dsh-plugin";
8
+ /** GitHub search never serves past the first 1000 results. */
9
+ const SEARCH_WINDOW = 1000;
10
+
11
+ export function buildHeaders(token) {
12
+ const headers = {
13
+ "User-Agent": "dsh-plugin-mall",
14
+ Accept: "application/vnd.github+json",
15
+ "X-GitHub-Api-Version": "2022-11-28",
16
+ };
17
+ if (token) headers.Authorization = `Bearer ${token}`;
18
+ return headers;
19
+ }
20
+
21
+ const DEFAULT_API_BASE = "https://api.github.com";
22
+
23
+ function apiUrl(apiBase, path) {
24
+ const raw = typeof apiBase === "string" && apiBase.length > 0 ? apiBase : DEFAULT_API_BASE;
25
+ const base = raw.endsWith("/") ? raw : `${raw}/`;
26
+ return `${base}${path.replace(/^\//, "")}`;
27
+ }
28
+
29
+ /**
30
+ * GET with bounded retries. The GitHub search API 504s under load — a plain
31
+ * transient that a retry clears — and its cold responses can take 8s+ while
32
+ * warm ones take 300ms. So: up to 3 attempts, 500ms/1500ms backoff, retrying
33
+ * only 5xx statuses, network errors, and our own per-attempt timeout. Never
34
+ * retried: 4xx (deterministic — the 422 "first 1000 results" contract in
35
+ * searchPlugins depends on failing fast) and caller cancellation, which
36
+ * propagates immediately.
37
+ */
38
+ const REQUEST_TIMEOUT = 12000;
39
+ const RETRY_DELAYS = [500, 1500];
40
+
41
+ async function requestJson(path, { apiBase, token, signal }) {
42
+ const url = apiUrl(apiBase, path);
43
+ let lastError;
44
+ for (let attempt = 0; attempt <= RETRY_DELAYS.length; attempt++) {
45
+ if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, RETRY_DELAYS[attempt - 1]));
46
+ let response;
47
+ try {
48
+ const timeoutSignal = AbortSignal.timeout(REQUEST_TIMEOUT);
49
+ response = await fetch(url, {
50
+ headers: buildHeaders(token),
51
+ signal: signal === undefined ? timeoutSignal : AbortSignal.any([signal, timeoutSignal]),
52
+ });
53
+ } catch (error) {
54
+ if (error?.name === "AbortError" && signal?.aborted) throw error; // caller cancelled
55
+ // 网络错误或单次超时——都值得重试,错误文本留给最后一轮。
56
+ lastError = new Error(error?.name === "AbortError"
57
+ ? `GitHub API request timed out after ${REQUEST_TIMEOUT / 1000}s (attempt ${attempt + 1})`
58
+ : `GitHub API request failed: ${error?.message ?? String(error)}`);
59
+ continue;
60
+ }
61
+ if (response.status >= 500 && attempt < RETRY_DELAYS.length) {
62
+ lastError = new Error(`GitHub API ${response.status}: ${response.statusText}`);
63
+ continue; // 5xx 瞬时故障,退避后重试
64
+ }
65
+ const remaining = response.headers.get("x-ratelimit-remaining");
66
+ const resetAt = response.headers.get("x-ratelimit-reset");
67
+ const body = await response.json().catch(() => undefined);
68
+ if (response.status === 403 && remaining === "0" && resetAt !== null) {
69
+ const reset = new Date(Number(resetAt) * 1000).toISOString();
70
+ throw new Error(`GitHub API rate limit exceeded; resets at ${reset} (UTC). Set GITHUB_TOKEN or DSH_MARKET_GITHUB_TOKEN for a higher limit.`);
71
+ }
72
+ if (response.status === 404) {
73
+ throw new Error(`GitHub API 404: ${body?.message ?? "not found"}`);
74
+ }
75
+ if (!response.ok) {
76
+ throw new Error(`GitHub API ${response.status}: ${body?.message ?? response.statusText}`);
77
+ }
78
+ return body;
79
+ }
80
+ throw lastError ?? new Error("GitHub API request failed");
81
+ }
82
+
83
+ /** Pick the stable, compact fields the tools render. */
84
+ function pickRepo(item) {
85
+ return {
86
+ fullName: item.full_name ?? "",
87
+ htmlUrl: item.html_url ?? "",
88
+ description: item.description ?? "",
89
+ stars: item.stargazers_count ?? 0,
90
+ forks: item.forks_count ?? 0,
91
+ isFork: item.fork === true,
92
+ language: item.language,
93
+ license: item.license?.spdx_id,
94
+ topics: item.topics ?? [],
95
+ updatedAt: item.updated_at ?? "",
96
+ archived: item.archived ?? false,
97
+ defaultBranch: item.default_branch ?? "main",
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Search repositories tagged `topic:dsh-plugin`, optionally narrowed by
103
+ * keywords (name/description/readme match), star-ranked by default.
104
+ * `minStars` (default 1) is pushed into the query as `stars:>=N` so the
105
+ * topic's noise (empty/demo repos riding the tag) is filtered server-side
106
+ * and `total` stays accurate; pass 0 to disable.
107
+ */
108
+ export async function searchPlugins({ query, sort = "stars", perPage = 10, page = 1, minStars, apiBase, token, signal }) {
109
+ const trimmed = typeof query === "string" ? query.trim() : "";
110
+ const parts = [SEARCH_TOPIC];
111
+ if (trimmed.length > 0) parts.push(trimmed);
112
+ const safeMinStars = Math.max(Math.trunc(Number(minStars ?? 1)) || 0, 0);
113
+ if (safeMinStars > 0) parts.push(`stars:>=${safeMinStars}`);
114
+ const q = parts.join(" ");
115
+ const safePerPage = Math.min(Math.max(Math.trunc(perPage) || 10, 1), 100);
116
+ const safePage = Math.max(Math.trunc(page) || 1, 1);
117
+ const path = `/search/repositories?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}&order=desc&per_page=${safePerPage}&page=${safePage}`;
118
+ let body;
119
+ try {
120
+ body = await requestJson(path, { apiBase, token, signal });
121
+ } catch (error) {
122
+ // Past the first 1000 results GitHub 422s with "Only the first 1000
123
+ // search results are available" surface that as a clean empty
124
+ // truncated page instead of a hard error.
125
+ if (/first 1000 search results/i.test(String(error?.message ?? ""))) {
126
+ return { total: SEARCH_WINDOW, page: safePage, perPage: safePerPage, items: [], truncated: true };
127
+ }
128
+ throw error;
129
+ }
130
+ return {
131
+ total: body.total_count ?? 0,
132
+ page: safePage,
133
+ perPage: safePerPage,
134
+ items: (body.items ?? []).map(pickRepo),
135
+ };
136
+ }
137
+
138
+ // ── npm registry (prefer-npm installs + update checks) ──────────────────────
139
+ //
140
+ // npm tarballs beat GitHub whole-repo tarballs: smaller (files field only),
141
+ // faster, integrity-checked. `preferNpmSpec` rewrites a github: install spec
142
+ // to its npm package name — but only when the registry entry's repository URL
143
+ // points back at that GitHub repo, which doubles as an anti-squatting check
144
+ // (an unrelated package squatting the name never matches, install falls back
145
+ // to the explicit github: spec).
146
+ //
147
+ // The registry to query is the CALLER's business (see resolveRegistry in
148
+ // installer.js): it has to be the one pnpm installs from. Querying npmjs while
149
+ // pnpm installs from a mirror fails three ways at once and all of them are
150
+ // silent anti-squatting never matches (every install degrades to a whole-repo
151
+ // GitHub clone plus a prepare build), `latest` comes back null (the update
152
+ // button disappears), and assertSafeToInstall sees no manifest, so the
153
+ // host-shadow guard stops guarding.
154
+
155
+ export const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
156
+ // `${registry}|${name}` -> {info, at}. Keyed by registry so a config change
157
+ // cannot serve answers from a different registry.
158
+ const npmCache = new Map();
159
+ // Success and failure expire alike. A never-expiring success cache means
160
+ // `hasUpdate` is frozen for the process lifetime: the author publishes, and no
161
+ // amount of "刷新已装" shows it until dsh restarts — self-defeating for a
162
+ // marketplace whose selling point is update management.
163
+ const NPM_CACHE_TTL = 300000;
164
+
165
+ /** Strip trailing slashes so `${base}/${name}` never doubles up. */
166
+ function normalizeRegistry(registry) {
167
+ const value = String(registry ?? "").trim();
168
+ return (value.length > 0 ? value : DEFAULT_NPM_REGISTRY).replace(/\/+$/, "");
169
+ }
170
+
171
+ /**
172
+ * Look up a package's `latest` manifest on an npm registry.
173
+ * @param name - the npm package name.
174
+ * @param options - `registry` defaults to npmjs; pass what pnpm installs from.
175
+ * @returns `{latest, repositoryUrl, hostDeps}`, or null when unknown/unreachable.
176
+ */
177
+ export async function npmPackageInfo(name, { registry } = {}) {
178
+ const clean = String(name ?? "").trim();
179
+ if (clean.length === 0 || !/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i.test(clean)) return null;
180
+ const base = normalizeRegistry(registry);
181
+ const key = `${base}|${clean}`;
182
+ const cached = npmCache.get(key);
183
+ if (cached !== undefined && Date.now() - cached.at < NPM_CACHE_TTL) return cached.info;
184
+ let info = null;
185
+ try {
186
+ // 单版本端点返回 latest 的完整 manifest(dependencies + repository 都在)。
187
+ // abbreviated packument 不含 repository,防抢注比对会永远落空。
188
+ // 镜像(npmmirror 等)的同名端点响应格式一致,两个字段都保留。
189
+ const response = await fetch(`${base}/${clean.replace("/", "%2F")}/latest`, {
190
+ headers: { "User-Agent": "dsh-plugin-mall", Accept: "application/json" },
191
+ });
192
+ if (response.ok) {
193
+ const body = await response.json();
194
+ if (typeof body?.version === "string") {
195
+ const rawRepository = body.repository;
196
+ const repositoryUrl = typeof rawRepository === "string" ? rawRepository : rawRepository?.url;
197
+ info = {
198
+ latest: body.version,
199
+ repositoryUrl: typeof repositoryUrl === "string" ? repositoryUrl : undefined,
200
+ hostDeps: hostShadowDependencies(body),
201
+ };
202
+ }
203
+ }
204
+ } catch {
205
+ info = null; // registry unreachable — caller falls back
206
+ }
207
+ npmCache.set(key, { info, at: Date.now() });
208
+ return info;
209
+ }
210
+
211
+ /**
212
+ * Rewrite "github:owner/repo" (or "owner/repo") to the npm package name when
213
+ * that package exists on npm AND its repository URL points back at the repo
214
+ * (anti-squatting). Anything else passes through untouched.
215
+ */
216
+ export async function preferNpmSpec({ spec, registry, sources }) {
217
+ const raw = String(spec ?? "");
218
+ // A scoped npm name ("@scope/name", "@scope/name@1.2.3") is shaped exactly
219
+ // like owner/repo and matches the regex below, sending every such install
220
+ // off to verify a repository that cannot exist — two wasted CDN requests and
221
+ // a bogus cache entry. Same guard normalizeSpec already uses.
222
+ if (raw.startsWith("@")) return raw;
223
+ const githubMatch = /^(?:github:)?([^/\s]+\/[^/\s]+?)(?:\.git)?$/i.exec(raw);
224
+ if (githubMatch === null) return raw;
225
+ const repo = githubMatch[1];
226
+ const { results } = await verifyPlugins({ repos: [repo], sources }); // cache hit after first verify
227
+ const declaredName = results[repo]?.name;
228
+ if (typeof declaredName !== "string") return raw;
229
+ const info = await npmPackageInfo(declaredName, { registry });
230
+ if (info === null || info.repositoryUrl === undefined) return raw;
231
+ const pointsBack = new RegExp(`github\\.com[/:]${repo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(/|\\.git|$)`, "i").test(info.repositoryUrl);
232
+ return pointsBack ? declaredName : raw;
233
+ }
234
+
235
+ /**
236
+ * Refuse to install a package whose `dependencies` shadow host framework
237
+ * packages. Host copies inside a profile split module identities and crash
238
+ * all tool scheduling — the dsh contract is peerDependencies, but the host
239
+ * enforces nothing, so the marketplace is the last line of defense.
240
+ * Sources: registry abbreviated metadata for npm names, the verify cache
241
+ * (raw package.json) for github: specs.
242
+ */
243
+ /**
244
+ * Extract the bare npm package name: "name", "name@version", "@s/n@version"
245
+ * → "name"/"@s/n". Returns null for anything that is not an npm name shape.
246
+ */
247
+ export function npmNameOf(raw) {
248
+ if (typeof raw !== "string" || raw.length === 0) return null;
249
+ if (raw.startsWith("@")) {
250
+ const match = /^(@[^/@\s]+\/[^/@\s]+?)(?:@[^/\s]+)?$/.exec(raw);
251
+ return match === null ? null : match[1];
252
+ }
253
+ const match = /^([^@\s]+?)(?:@[^/\s]+)?$/.exec(raw);
254
+ return match === null ? null : match[1];
255
+ }
256
+
257
+ export async function assertSafeToInstall({ spec, registry, sources }) {
258
+ const raw = String(spec ?? "");
259
+ let hostDeps;
260
+ if (/^(?:file:|link:)/i.test(raw)) {
261
+ // 本地路径直通:直接读它的 package.json 查 dependencies。
262
+ const dir = raw.replace(/^(?:file:|link:)/i, "").replace(/[\\/]+$/, "");
263
+ try {
264
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
265
+ hostDeps = hostShadowDependencies(pkg);
266
+ } catch {
267
+ return; // 读不到清单——pnpm 会给出真实错误,这里放行
268
+ }
269
+ } else if (/^github:([^/\s]+\/[^/\s]+?)(?:\.git)?(?:#.+)?$/i.test(raw)) {
270
+ const repo = /^github:([^/\s]+\/[^/\s]+?)(?:\.git)?(?:#.+)?$/i.exec(raw)[1];
271
+ const { results } = await verifyPlugins({ repos: [repo], sources });
272
+ hostDeps = results[repo]?.hostDeps;
273
+ } else if (/^https?:\/\//i.test(raw)) {
274
+ return; // 远程 tarball 下载前无法廉价检查;罕见路径,放行
275
+ } else {
276
+ // npm 名(含带版本形式):剥掉 @version 再查。
277
+ const name = npmNameOf(raw);
278
+ if (name === null || name.length === 0) {
279
+ // 无法识别形状的 spec 一律拒绝,而不是静默跳过检查。
280
+ throw new Error(`cannot analyze install spec ${JSON.stringify(raw)} for host-shadow dependencies — refusing to install`);
281
+ }
282
+ const info = await npmPackageInfo(name, { registry });
283
+ hostDeps = info?.hostDeps;
284
+ }
285
+ if (hostDeps !== undefined && hostDeps !== null && hostDeps.length > 0) {
286
+ throw new Error(`${raw} declares ${hostDeps.length} host framework package(s) as dependencies (${hostDeps.slice(0, 3).join(", ")}${hostDeps.length > 3 ? ", …" : ""}) — installing it would duplicate host modules and crash dsh tool scheduling. Ask the plugin author to move @deepseek-ai/* to peerDependencies.`);
287
+ }
288
+ }
289
+
290
+ // ── install-script disclosure ───────────────────────────────────────────────
291
+ //
292
+ // pnpm blocks dependency lifecycle scripts by default; the marketplace used to
293
+ // silently allow them and retry. Allowing them is a real decision — those
294
+ // commands run on the user's machine with the user's privileges, before any
295
+ // plugin code loads — so the caller has to be able to show WHAT it is asking
296
+ // approval for, not just a package name. Everything here is one registry
297
+ // request per blocked package (there are typically 0-3).
298
+
299
+ /**
300
+ * Look up what a blocked package would actually execute.
301
+ * @param entries - `{name, version}` pairs (version optional falls back to latest).
302
+ * @param options - `registry` to query; `installedName` marks which entry, if
303
+ * any, is the package the user actually asked for — everything else is a
304
+ * transitive dependency they never chose, which is the part worth flagging.
305
+ * @returns one record per entry; fields absent when the registry is unreachable.
306
+ */
307
+ export async function describeBuildScripts(entries, { registry, installedName } = {}) {
308
+ const base = normalizeRegistry(registry);
309
+ const list = (Array.isArray(entries) ? entries : []).map((entry) => (
310
+ typeof entry === "string" ? { name: entry } : { name: entry?.name, version: entry?.version }
311
+ )).filter((entry) => typeof entry.name === "string" && entry.name.length > 0);
312
+ const out = [];
313
+ await mapLimit(list, NETWORK_CONCURRENCY, async ({ name, version }) => {
314
+ const record = { name, version, direct: installedName !== undefined && name === installedName };
315
+ try {
316
+ const point = version === undefined ? "latest" : encodeURIComponent(version);
317
+ const response = await fetch(`${base}/${name.replace("/", "%2F")}/${point}`, {
318
+ headers: { "User-Agent": "dsh-plugin-mall", Accept: "application/json" },
319
+ });
320
+ if (response.ok) {
321
+ const body = await response.json();
322
+ record.version = body.version ?? version;
323
+ const scripts = body.scripts ?? {};
324
+ record.scripts = {};
325
+ for (const key of ["preinstall", "install", "postinstall"]) {
326
+ if (typeof scripts[key] === "string") record.scripts[key] = scripts[key];
327
+ }
328
+ record.provenance = body.dist?.attestations !== undefined;
329
+ record.unpackedSize = body.dist?.unpackedSize;
330
+ }
331
+ } catch {
332
+ /* 查不到就只报名字。绝不因为查询失败而放行,也不因此阻断——
333
+ 这里只负责补充事实,是否放行由用户决定。 */
334
+ }
335
+ try {
336
+ // 下载量只在 npmjs 的统计 API 上有,镜像用户可能打不通;纯可选信息。
337
+ const stats = await fetch(`https://api.npmjs.org/downloads/point/last-week/${name.replace("/", "%2F")}`);
338
+ if (stats.ok) record.weeklyDownloads = (await stats.json())?.downloads;
339
+ } catch {
340
+ /* 可选 */
341
+ }
342
+ out.push(record);
343
+ });
344
+ return out.sort((a, b) => Number(b.direct) - Number(a.direct) || a.name.localeCompare(b.name));
345
+ }
346
+
347
+ /** Loose semver-ish comparison: "0.2.10" vs "0.2.9" → 1. Non-numeric parts
348
+ * read as 0; numerically equal versions where one carries a pre-release
349
+ * segment ("-rc.1") sort below the plain release ("2.0.0-beta" < "2.0.0"). */
350
+ export function compareVersions(a, b) {
351
+ const pa = String(a ?? "").split(".");
352
+ const pb = String(b ?? "").split(".");
353
+ for (let index = 0; index < Math.max(pa.length, pb.length); index++) {
354
+ const na = Number(pa[index]) || 0;
355
+ const nb = Number(pb[index]) || 0;
356
+ if (na !== nb) return na < nb ? -1 : 1;
357
+ }
358
+ const preA = String(a ?? "").includes("-");
359
+ const preB = String(b ?? "").includes("-");
360
+ if (preA !== preB) return preA ? -1 : 1;
361
+ return 0;
362
+ }
363
+ // ── plugin verification (raw CDN, no API quota) ─────────────────────────────
364
+ //
365
+ // The topic carries thousands of repos that are not dsh plugins at all. The
366
+ // authoritative signal is a package.json declaring `dsh.bundle.patch` (host
367
+ // bundle) or `dsh.client` (browser UI plugin) — the same contract
368
+ // classifyPackage applies locally. package.json is fetched from
369
+ // raw.githubusercontent.com (a CDN that does not consume REST API quota), so a
370
+ // page of 20 verifies in one burst even without a token. Results are cached
371
+ // for the process lifetime; a fetch failure caches "unknown" rather than
372
+ // retrying forever.
373
+
374
+ /** Cap on concurrent outbound requests shared by verification and update checks. */
375
+ export const NETWORK_CONCURRENCY = 8;
376
+ // repo -> {kind, name, version, hostDeps, ts}
377
+ const verifyCache = new Map();
378
+ // Verdicts expire. Caching a success for the process lifetime freezes the badge
379
+ // on whatever the repo looked like the first time it was seen: when dsh-TUI
380
+ // moved its @deepseek-ai/* deps to peerDependencies, the red "宿主依赖风险"
381
+ // badge stayed up until dsh restarted, still accusing a repo that had been
382
+ // fixed. Ten minutes keeps a browsing session on cache while letting a fix
383
+ // surface on its own.
384
+ const VERIFY_TTL = 600000;
385
+ // Network failures retry sooner — "unknown" carries no information worth keeping.
386
+ const VERIFY_TTL_UNKNOWN = 60000;
387
+
388
+ /**
389
+ * Run `task` over `items` with at most `limit` workers in flight. The one
390
+ * fan-out primitive for this module's network work, so nothing accidentally
391
+ * bursts every item at once.
392
+ */
393
+ export async function mapLimit(items, limit, task) {
394
+ const list = Array.isArray(items) ? items : [];
395
+ let cursor = 0;
396
+ const worker = async () => {
397
+ while (cursor < list.length) {
398
+ const index = cursor++;
399
+ await task(list[index], index);
400
+ }
401
+ };
402
+ await Promise.all(Array.from({ length: Math.min(limit, list.length) }, worker));
403
+ }
404
+
405
+ // package.json is fetched from CDNs, not the REST API, so verification never
406
+ // burns API quota. jsDelivr first (reachable where raw.githubusercontent.com
407
+ // is blocked), raw as fallback; a 404 only means "no manifest" once EVERY
408
+ // reachable source 404s (jsDelivr lags new pushes, so one 404 is not final).
409
+ // URL templates, `{repo}` substituted with owner/name — overridable through
410
+ // the `rawSources` config for self-hosted reverse proxies.
411
+ export const DEFAULT_RAW_SOURCES = [
412
+ "https://cdn.jsdelivr.net/gh/{repo}@HEAD/package.json",
413
+ "https://raw.githubusercontent.com/{repo}/HEAD/package.json",
414
+ ];
415
+
416
+ /** The configured source templates, or the built-in pair. */
417
+ function rawSourcesOf(sources) {
418
+ const list = (Array.isArray(sources) ? sources : [])
419
+ .map((entry) => String(entry ?? "").trim())
420
+ .filter((entry) => entry.length > 0 && entry.includes("{repo}"));
421
+ return list.length > 0 ? list : DEFAULT_RAW_SOURCES;
422
+ }
423
+
424
+ async function fetchRawPackageJson(repo, signal, sources) {
425
+ let saw404 = false;
426
+ let lastError;
427
+ for (const template of rawSourcesOf(sources)) {
428
+ try {
429
+ const response = await fetch(template.replace("{repo}", repo), {
430
+ headers: { "User-Agent": "dsh-plugin-mall", Accept: "application/json" },
431
+ signal,
432
+ });
433
+ if (response.status === 404) { saw404 = true; continue; }
434
+ if (!response.ok) throw new Error(`source returned ${response.status}`);
435
+ return await response.json();
436
+ } catch (error) {
437
+ if (error?.name === "AbortError") throw error;
438
+ lastError = error;
439
+ }
440
+ }
441
+ if (saw404) return undefined;
442
+ throw lastError ?? new Error("no raw source reachable");
443
+ }
444
+
445
+ /**
446
+ * Framework packages the host provides via the profiles/node_modules fallback.
447
+ * A plugin declaring any of these as `dependencies` (instead of
448
+ * peerDependencies) installs real copies into the profile — the loader then
449
+ * holds two module instances, symbol identities split, and every tool call
450
+ * crashes with an undiagnosable "reading 'prepare'". See installer docs.
451
+ *
452
+ * ONLY the @deepseek-ai scope belongs here. This used to also match the bare
453
+ * upstream `cosmokit` and `schemastery` on the theory that dsh forked the
454
+ * cordis trio and therefore shipped those too. It does not: the host tree has
455
+ * no bare copy at any depth, and none of its 195 @deepseek-ai packages depends
456
+ * on one — the fork is complete and cut its ties to the upstream names. A
457
+ * plugin depending on bare `schemastery` shadows nothing, so flagging it was a
458
+ * pure false positive, and an expensive one: it hard-blocked 7 real plugins in
459
+ * the topic's top 300, `omdsh-dev/DSH-better-sidebar` (★1832) among them —
460
+ * every one of which declares its @deepseek-ai/* deps as peers correctly.
461
+ * Fixtures at the bottom of this file pin the distinction.
462
+ */
463
+ const HOST_PACKAGES = /^@deepseek-ai\//;
464
+
465
+ /** The @deepseek-ai/* entries inside `dependencies`. */
466
+ function hostShadowDependencies(pkg) {
467
+ if (pkg === undefined || typeof pkg !== "object") return undefined;
468
+ const deps = Object.keys(pkg.dependencies ?? {});
469
+ const host = deps.filter((name) => HOST_PACKAGES.test(name));
470
+ return host.length > 0 ? host : undefined;
471
+ }
472
+
473
+ /**
474
+ * Verify repositories as real dsh plugins by their package.json declaration.
475
+ * @param {{repos: string[], signal?: AbortSignal, sources?: string[]}} options - "owner/name" list.
476
+ * @returns the {results} map: fullName -> {kind: "bundle"|"client"|"plain"|"no-manifest"|"unknown", name?, version?, hostDeps?}.
477
+ */
478
+ export async function verifyPlugins({ repos, signal, sources }) {
479
+ // A GitHub owner never starts with "@", so rejecting that shape here keeps
480
+ // scoped npm names ("@scope/name") out of repository verification no matter
481
+ // which caller passes them in.
482
+ const wanted = [...new Set((Array.isArray(repos) ? repos : []).map(String)
483
+ .filter((repo) => /^[^@/\s][^/\s]*\/[^/\s]+$/.test(repo) && !repo.includes("..")))];
484
+ // 每条判定都带时间戳:成功 10 分钟过期,网络失败 60 秒过期。
485
+ const pending = wanted.filter((repo) => {
486
+ const cached = verifyCache.get(repo);
487
+ if (cached === undefined) return true;
488
+ const ttl = cached.kind === "unknown" ? VERIFY_TTL_UNKNOWN : VERIFY_TTL;
489
+ return Date.now() - (cached.ts ?? 0) > ttl;
490
+ });
491
+ await mapLimit(pending, NETWORK_CONCURRENCY, async (repo) => {
492
+ try {
493
+ const pkg = await fetchRawPackageJson(repo, signal, sources);
494
+ const kind = pkg === undefined ? "no-manifest"
495
+ : typeof pkg.dsh?.bundle?.patch === "string" ? "bundle"
496
+ : pkg.dsh?.client !== undefined ? "client"
497
+ : "plain";
498
+ verifyCache.set(repo, { kind, name: pkg?.name, version: pkg?.version, hostDeps: hostShadowDependencies(pkg), ts: Date.now() });
499
+ } catch (error) {
500
+ if (error?.name === "AbortError") throw error;
501
+ verifyCache.set(repo, { kind: "unknown", ts: Date.now() });
502
+ }
503
+ });
504
+ // 一律重建对象:内部的 ts 不该出现在发给浏览器的响应里。
505
+ const results = {};
506
+ for (const repo of wanted) {
507
+ const cached = verifyCache.get(repo);
508
+ results[repo] = { kind: cached?.kind ?? "unknown", name: cached?.name, version: cached?.version, hostDeps: cached?.hostDeps };
509
+ }
510
+ return { results };
511
+ }
512
+
513
+ /**
514
+ * Fetch one repository's metadata plus its package.json (base64-decoded),
515
+ * which is what tells us whether it declares a dsh bundle patch.
516
+ */
517
+ export async function repoInfo({ repo, apiBase, token, signal }) {
518
+ const trimmed = String(repo ?? "").trim();
519
+ if (!/^[^/\s]+\/[^/\s]+$/.test(trimmed) || trimmed.includes("..")) {
520
+ throw new Error(`market_info: repo must be "owner/name", got ${JSON.stringify(trimmed)}`);
521
+ }
522
+ let meta;
523
+ try {
524
+ meta = await requestJson(`/repos/${trimmed}`, { apiBase, token, signal });
525
+ } catch (error) {
526
+ throw new Error(`market_info: repository ${trimmed} not found on GitHub (${error.message})`);
527
+ }
528
+ let packageJson;
529
+ try {
530
+ const contents = await requestJson(`/repos/${trimmed}/contents/package.json`, { apiBase, token, signal });
531
+ if (typeof contents.content === "string") {
532
+ packageJson = JSON.parse(Buffer.from(contents.content, "base64").toString("utf8"));
533
+ }
534
+ } catch {
535
+ packageJson = undefined; // no package.json at the repo root
536
+ }
537
+ return {
538
+ meta: pickRepo(meta),
539
+ packageJson: packageJson === undefined ? undefined : {
540
+ name: packageJson.name,
541
+ version: packageJson.version,
542
+ description: packageJson.description,
543
+ type: packageJson.type,
544
+ dshBundlePatch: typeof packageJson.dsh?.bundle?.patch === "string" ? packageJson.dsh.bundle.patch : undefined,
545
+ dshClientPlatform: packageJson.dsh?.client?.platform,
546
+ dshClientInjectCount: Array.isArray(packageJson.dsh?.client?.inject) ? packageJson.dsh.client.inject.length : undefined,
547
+ dependencyCount: Object.keys(packageJson.dependencies ?? {}).length,
548
+ peerDependencyCount: Object.keys(packageJson.peerDependencies ?? {}).length,
549
+ },
550
+ };
551
+ }
552
+
553
+ // ── offline fixtures ────────────────────────────────────────────────────────
554
+ //
555
+ // The host-shadow check has no live regression case any more: dsh-TUI, the
556
+ // plugin it was built against, moved its @deepseek-ai/* deps to
557
+ // peerDependencies after being reported, and nothing else on the network pins
558
+ // the bare-vs-scoped distinction. These manifests do. Shapes are taken from
559
+ // real repositories in the dsh-plugin topic, trimmed to the relevant fields.
560
+ const HOST_SHADOW_FIXTURES = [
561
+ {
562
+ label: "omdsh-dev/DSH-better-sidebar — 上游裸包,宿主不提供,放行",
563
+ pkg: {
564
+ dependencies: { schemastery: "^3.18.0", ws: "^8.18.0", clsx: "^2.1.1", "node-pty": "^1.1.0" },
565
+ peerDependencies: { "@deepseek-ai/dsh-tools": "^0.1.0-rc.6", "@deepseek-ai/cordis": "^4.0.1" },
566
+ },
567
+ expect: undefined,
568
+ },
569
+ {
570
+ label: "裸 cordis / cosmokit — fork 已与上游断开,放行",
571
+ pkg: { dependencies: { cordis: "^4.0.0-rc.7", cosmokit: "^1.6.3" } },
572
+ expect: undefined,
573
+ },
574
+ {
575
+ label: "作用域包写进 dependencies — 真·宿主重复,拦截",
576
+ pkg: { dependencies: { "@deepseek-ai/schemastery": "^1.0.0" } },
577
+ expect: ["@deepseek-ai/schemastery"],
578
+ },
579
+ {
580
+ label: "合法 peer + 非法 dep 混合 — 仍须拦截",
581
+ pkg: {
582
+ dependencies: { "@deepseek-ai/dsh-settings": "*", clsx: "^2" },
583
+ peerDependencies: { "@deepseek-ai/dsh-tools": "*" },
584
+ },
585
+ expect: ["@deepseek-ai/dsh-settings"],
586
+ },
587
+ {
588
+ label: "只在 peerDependencies 声明 — 正确写法,放行",
589
+ pkg: { peerDependencies: { "@deepseek-ai/dsh-tools": "*", "@deepseek-ai/cordis": "^4.0.1" } },
590
+ expect: undefined,
591
+ },
592
+ { label: "无 dependencies 字段", pkg: {}, expect: undefined },
593
+ { label: "非对象清单", pkg: undefined, expect: undefined },
594
+ ];
595
+
596
+ /** Run the offline fixtures; returns the failure count. */
597
+ function runHostShadowFixtures() {
598
+ let failed = 0;
599
+ for (const { label, pkg, expect } of HOST_SHADOW_FIXTURES) {
600
+ const actual = hostShadowDependencies(pkg);
601
+ const ok = JSON.stringify(actual ?? null) === JSON.stringify(expect ?? null);
602
+ if (!ok) failed++;
603
+ console.log(` ${ok ? "PASS" : "FAIL"} ${label}${ok ? "" : ` 期望 ${JSON.stringify(expect)},实得 ${JSON.stringify(actual)}`}`);
604
+ }
605
+ return failed;
606
+ }
607
+
608
+ // Self-test entry: node src/github.js --self-test
609
+ // Offline fixtures run first and gate the network smoke test below.
610
+ if (process.argv[1]?.endsWith("github.js") && process.argv.includes("--self-test")) {
611
+ console.log("宿主依赖检测 fixtures:");
612
+ const failed = runHostShadowFixtures();
613
+ console.log(`${HOST_SHADOW_FIXTURES.length - failed}/${HOST_SHADOW_FIXTURES.length} passed\n`);
614
+ if (failed > 0) process.exit(1);
615
+ if (process.argv.includes("--offline")) process.exit(0);
616
+ const apiBase = "https://api.github.com";
617
+ const result = await searchPlugins({ query: "", perPage: 3, apiBase });
618
+ console.log(`total=${result.total} page=${result.page} perPage=${result.perPage}`);
619
+ for (const item of result.items) console.log(`${item.fullName} ★${item.stars} ${item.language ?? ""}`);
620
+ if (result.items.length > 0) {
621
+ const info = await repoInfo({ repo: result.items[0].fullName, apiBase });
622
+ console.log(`repo=${info.meta.fullName} defaultBranch=${info.meta.defaultBranch} archived=${info.meta.archived}`);
623
+ console.log(`packageJson=${info.packageJson ? `${info.packageJson.name}@${info.packageJson.version} bundle=${info.packageJson.dshBundlePatch ?? "none"}` : "absent"}`);
624
+ }
625
+ }