@stolyarovmn/dsh-ui-registry-aggregator 0.4.13

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/lib/core.js ADDED
@@ -0,0 +1,1241 @@
1
+ import { lookup as dnsLookup } from "node:dns/promises";
2
+ import { isIP } from "node:net";
3
+ import ipaddr from "ipaddr.js";
4
+ import { Agent } from "undici";
5
+
6
+ const DEFAULT_TIMEOUT_MS = 10_000;
7
+ const DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
8
+ const DEFAULT_MAX_PLUGINS = 2_000;
9
+ const DEFAULT_MAX_REDIRECTS = 3;
10
+ const EVIDENCE_CACHE_TTL_MS = 10 * 60 * 1000;
11
+ const METADATA_CACHE_TTL_MS = 30 * 60 * 1000;
12
+ const npmDownloadCache = new Map();
13
+ const npmManifestCache = new Map();
14
+ const githubManifestCache = new Map();
15
+ const githubStatsCache = new Map();
16
+ const browseDiscoveryCache = new Map();
17
+ const BROWSE_DISCOVERY_TTL_MS = 2 * 60 * 1000;
18
+
19
+ function cachedEvidence(cache, key) {
20
+ const entry = cache.get(key);
21
+ if (!entry) return undefined;
22
+ if (entry.expiresAt <= Date.now()) {
23
+ cache.delete(key);
24
+ return undefined;
25
+ }
26
+ return entry.value;
27
+ }
28
+
29
+ function cacheEvidence(cache, key, value, ttlMs = EVIDENCE_CACHE_TTL_MS) {
30
+ if (typeof value !== "number" || !Number.isFinite(value)) return;
31
+ cache.set(key, { value, expiresAt: Date.now() + ttlMs });
32
+ }
33
+
34
+ export const SOURCE_TYPES = Object.freeze([
35
+ "dsh-plugin-shop",
36
+ "dshplugin-app",
37
+ "npm",
38
+ "github",
39
+ "custom-json",
40
+ "corporate",
41
+ ]);
42
+
43
+ export const NPM_DISCOVERY_KEYWORDS = Object.freeze([
44
+ "dsh-plugin",
45
+ "deepseek-harness",
46
+ "deepseek-harness-plugin",
47
+ "dsh-plugins",
48
+ ]);
49
+
50
+ export const GITHUB_DISCOVERY_TOPICS = Object.freeze([
51
+ "dsh-plugin",
52
+ "deepseek-harness-plugin",
53
+ "dsh-plugins",
54
+ ]);
55
+
56
+ // "dsh-plugin" alone is heavily polluted by repositories using DSH for unrelated
57
+ // acronyms. Require DeepSeek Harness context unless the repository uses the
58
+ // unambiguous deepseek-harness-plugin topic.
59
+ export const GITHUB_DISCOVERY_QUERIES = Object.freeze([
60
+ "topic:deepseek-harness topic:dsh-plugin",
61
+ "topic:deepseek-harness-plugin",
62
+ "topic:deepseek-harness topic:dsh-plugins",
63
+ ]);
64
+
65
+ function text(value) {
66
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
67
+ }
68
+
69
+ function finiteNumber(value) {
70
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
71
+ }
72
+
73
+ function isoDate(value) {
74
+ const raw = text(value);
75
+ if (!raw) return undefined;
76
+ const timestamp = Date.parse(raw);
77
+ return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : undefined;
78
+ }
79
+
80
+ function latestIsoDate(left, right) {
81
+ const values = [left, right].filter(Boolean);
82
+ if (!values.length) return undefined;
83
+ return values.sort((a, b) => Date.parse(b) - Date.parse(a))[0];
84
+ }
85
+
86
+ function cachedValue(cache, key) {
87
+ const entry = cache.get(key);
88
+ if (!entry) return undefined;
89
+ if (entry.expiresAt <= Date.now()) {
90
+ cache.delete(key);
91
+ return undefined;
92
+ }
93
+ return entry.value;
94
+ }
95
+
96
+ function cacheValue(cache, key, value, ttlMs = METADATA_CACHE_TTL_MS) {
97
+ cache.set(key, { value, expiresAt: Date.now() + ttlMs });
98
+ }
99
+
100
+ export function releaseChannel(version) {
101
+ const value = text(version);
102
+ if (!value) return undefined;
103
+ const prerelease = value.match(/^[0-9]+\.[0-9]+\.[0-9]+-([^+]+)/u)?.[1]?.toLowerCase();
104
+ if (!prerelease) return /^[0-9]+\.[0-9]+\.[0-9]+(?:\+.+)?$/u.test(value) ? "stable" : undefined;
105
+ if (/(?:^|[.-])alpha(?:[.-]|$)|(?:^|[.-])a(?:[.-]|$)/u.test(prerelease)) return "alpha";
106
+ if (/(?:^|[.-])beta(?:[.-]|$)|(?:^|[.-])b(?:[.-]|$)/u.test(prerelease)) return "beta";
107
+ if (/(?:^|[.-])rc(?:[.-]|$)/u.test(prerelease)) return "rc";
108
+ return "prerelease";
109
+ }
110
+
111
+ const TAG_ALIASES = new Map([
112
+ ["web-gui", "ui"],
113
+ ["client-plugin", "ui"],
114
+ ["dsh-plugin-ui", "ui"],
115
+ ["dsh-ui", "ui"],
116
+ ["dsh-plugin-theme", "theme"],
117
+ ["dsh-plugin-provider", "provider"],
118
+ ["dsh-plugin-workflow", "workflow"],
119
+ ["dsh-plugin-integration", "integration"],
120
+ ["dsh-plugin-tool", "tool"],
121
+ ["dsh-plugin-automation", "automation"],
122
+ ["dsh-plugin-schedule", "schedule"],
123
+ ["dsh-plugin-scheduler", "scheduler"],
124
+ ["dsh-skill", "skill"],
125
+ ["dsh-plugin-bundle", "bundle"],
126
+ ["dsh-plugin-desktop", "desktop"],
127
+ ]);
128
+
129
+ export const FILTER_TAGS = Object.freeze([
130
+ "ui", "theme", "provider", "workflow", "integration", "tool",
131
+ "automation", "schedule", "scheduler", "skill", "bundle", "desktop",
132
+ ]);
133
+ const DISPLAY_TAGS = new Set(FILTER_TAGS);
134
+
135
+ function tagValues(value) {
136
+ if (Array.isArray(value)) return value.flatMap(tagValues);
137
+ if (typeof value !== "string") return [];
138
+ return value.split(/[\s,]+/u).map((item) => item.trim().toLowerCase()).filter(Boolean);
139
+ }
140
+
141
+ function normalizeTags(record) {
142
+ const raw = [
143
+ ...tagValues(record.tags),
144
+ ...tagValues(record.keywords),
145
+ ...tagValues(record.topics),
146
+ ...tagValues(record.category),
147
+ ...tagValues(record.dsh?.catalog?.category),
148
+ ];
149
+ const tags = [];
150
+ for (const token of raw) {
151
+ const mapped = TAG_ALIASES.get(token) ?? token;
152
+ if (DISPLAY_TAGS.has(mapped) && !tags.includes(mapped)) tags.push(mapped);
153
+ }
154
+ return tags.slice(0, 8);
155
+ }
156
+
157
+ export function normalizeSource(value) {
158
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
159
+ const id = text(value.id);
160
+ const type = text(value.type);
161
+ const url = text(value.url);
162
+ if (!id || id.length > 64 || !/^[a-z0-9][a-z0-9._~-]*$/i.test(id) || !type || !SOURCE_TYPES.includes(type)) return undefined;
163
+ if (url && url.length > 2_048) return undefined;
164
+ return {
165
+ id,
166
+ name: (text(value.name) ?? id).slice(0, 120),
167
+ type,
168
+ ...(url ? { url } : {}),
169
+ enabled: value.enabled !== false,
170
+ };
171
+ }
172
+
173
+ export function canonicalRepository(value) {
174
+ const raw = text(value);
175
+ if (!raw) return undefined;
176
+ let normalized = raw.replace(/^git\+/, "").replace(/^git@github\.com:/i, "https://github.com/");
177
+ try {
178
+ const url = new URL(normalized);
179
+ url.hash = "";
180
+ url.search = "";
181
+ normalized = url.toString();
182
+ } catch {
183
+ return raw.replace(/\.git$/i, "").replace(/\/$/, "").toLowerCase();
184
+ }
185
+ return normalized.replace(/\.git$/i, "").replace(/\/$/, "").toLowerCase();
186
+ }
187
+
188
+ function safeInstallSpec(type, value) {
189
+ const spec = text(value);
190
+ if (!spec || spec.length > 500 || /[\s;&|`$<>"'%!^()]/u.test(spec)) return undefined;
191
+ if (type === "npm") {
192
+ return /^(?:@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*|[a-z0-9][a-z0-9._~-]*)(?:@[a-z0-9][a-z0-9._+~-]*)?$/i.test(spec) ? spec : undefined;
193
+ }
194
+ try {
195
+ const url = new URL(spec.replace(/^git\+/, ""));
196
+ return url.protocol === "https:" && !url.username && !url.password ? spec : undefined;
197
+ } catch {
198
+ return undefined;
199
+ }
200
+ }
201
+
202
+ function normalizeInstall(record, packageName, repository, version) {
203
+ const raw = record.install;
204
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
205
+ const type = raw.type === "npm" || raw.type === "git" ? raw.type : undefined;
206
+ const spec = type ? safeInstallSpec(type, raw.spec) : undefined;
207
+ if (type && spec) return { type, spec };
208
+ }
209
+ const explicit = text(raw);
210
+ if (explicit) {
211
+ const type = /^(?:git\+|https?:\/\/|ssh:|git:)/i.test(explicit) ? "git" : "npm";
212
+ const spec = safeInstallSpec(type, explicit);
213
+ if (spec) return { type, spec };
214
+ }
215
+ if (packageName) {
216
+ const spec = safeInstallSpec("npm", version ? `${packageName}@${version}` : packageName);
217
+ if (spec) return { type: "npm", spec };
218
+ }
219
+ if (repository) {
220
+ const spec = safeInstallSpec("git", repository);
221
+ if (spec) return { type: "git", spec };
222
+ }
223
+ return undefined;
224
+ }
225
+
226
+ function sourceAttribution(source) {
227
+ return {
228
+ id: source.id,
229
+ name: source.name,
230
+ type: source.type,
231
+ ...(source.url ? { url: source.url } : {}),
232
+ };
233
+ }
234
+
235
+ export function normalizePlugin(record, source) {
236
+ if (!record || typeof record !== "object" || Array.isArray(record)) return undefined;
237
+ const identityInput = record.identity && typeof record.identity === "object" ? record.identity : {};
238
+ const packageName = (text(identityInput.package) ?? text(record.package) ?? text(record.packageName) ?? text(record.npm))?.slice(0, 214);
239
+ const repository = canonicalRepository(text(identityInput.repository ?? record.repository ?? record.repo)?.slice(0, 2_048));
240
+ const sourceSpecificId = (text(record.id) ?? text(record.slug) ?? text(record.name))?.slice(0, 200);
241
+ const name = (text(record.name) ?? packageName ?? repository?.split("/").pop() ?? sourceSpecificId)?.slice(0, 200);
242
+ if (!name || (!packageName && !repository && !sourceSpecificId)) return undefined;
243
+ const version = (text(record.version) ?? text(record.latestVersion))?.slice(0, 100);
244
+ const description = (text(record.description) ?? text(record.summary))?.slice(0, 4_000);
245
+ const install = normalizeInstall(record, packageName, repository, version);
246
+ const verified = typeof record.evidence?.verified === "boolean" ? record.evidence.verified
247
+ : typeof record.verified === "boolean" ? record.verified : undefined;
248
+ const stars = finiteNumber(record.evidence?.stars) ?? finiteNumber(record.stars) ?? finiteNumber(record.stargazers_count);
249
+ const downloads30d = finiteNumber(record.evidence?.downloads30d) ?? finiteNumber(record.downloads30d) ?? finiteNumber(record.downloads);
250
+ const rating = finiteNumber(record.evidence?.rating) ?? finiteNumber(record.rating);
251
+ const ratingCount = finiteNumber(record.evidence?.ratingCount) ?? finiteNumber(record.ratingCount) ?? finiteNumber(record.reviews);
252
+ const compatibility = text(record.evidence?.compatibility) ?? text(record.compatibility);
253
+ const dshCompatibility = text(record.evidence?.dshCompatibility) ?? text(record.dshCompatibility);
254
+ const releasedAt = isoDate(record.evidence?.releasedAt ?? record.releasedAt ?? record.publishedAt ?? record.date ?? record.updated);
255
+ const repositoryUpdatedAt = isoDate(record.evidence?.repositoryUpdatedAt ?? record.repositoryUpdatedAt ?? record.pushed_at ?? record.updated_at);
256
+ const maintenanceScore = finiteNumber(record.evidence?.maintenanceScore) ?? finiteNumber(record.maintenanceScore);
257
+ const installability = ["bundle", "not-bundle", "unknown"].includes(record.evidence?.installability) ? record.evidence.installability
258
+ : ["bundle", "not-bundle", "unknown"].includes(record.installability) ? record.installability : undefined;
259
+ const channel = text(record.evidence?.releaseChannel) ?? releaseChannel(version);
260
+ const tags = normalizeTags(record);
261
+ return {
262
+ identity: {
263
+ ...(packageName ? { package: packageName } : {}),
264
+ ...(repository ? { repository } : {}),
265
+ fallback: `${source.id}:${sourceSpecificId ?? name}`,
266
+ },
267
+ name,
268
+ ...(description ? { description } : {}),
269
+ ...(version ? { version } : {}),
270
+ versions: version ? [version] : [],
271
+ tags,
272
+ ...(install ? { install } : {}),
273
+ sources: [sourceAttribution(source)],
274
+ ...((verified !== undefined || stars !== undefined || downloads30d !== undefined || rating !== undefined || ratingCount !== undefined || compatibility || dshCompatibility || releasedAt || repositoryUpdatedAt || maintenanceScore !== undefined || installability || channel) ? {
275
+ evidence: {
276
+ ...(verified !== undefined ? { verified } : {}),
277
+ ...(stars !== undefined ? { stars } : {}),
278
+ ...(downloads30d !== undefined ? { downloads30d } : {}),
279
+ ...(rating !== undefined ? { rating } : {}),
280
+ ...(ratingCount !== undefined ? { ratingCount } : {}),
281
+ ...(compatibility ? { compatibility } : {}),
282
+ ...(dshCompatibility ? { dshCompatibility } : {}),
283
+ ...(releasedAt ? { releasedAt } : {}),
284
+ ...(repositoryUpdatedAt ? { repositoryUpdatedAt } : {}),
285
+ ...(maintenanceScore !== undefined ? { maintenanceScore } : {}),
286
+ ...(installability ? { installability } : {}),
287
+ ...(channel ? { releaseChannel: channel } : {}),
288
+ },
289
+ } : {}),
290
+ };
291
+ }
292
+
293
+ export function pluginIdentity(plugin) {
294
+ if (plugin.identity?.package) return `npm:${plugin.identity.package.toLowerCase()}`;
295
+ if (plugin.identity?.repository) return `git:${canonicalRepository(plugin.identity.repository)}`;
296
+ return `source:${plugin.identity?.fallback ?? plugin.name.toLowerCase()}`;
297
+ }
298
+
299
+ function preferInstall(current, incoming) {
300
+ if (!current) return incoming;
301
+ if (!incoming) return current;
302
+ if (current.type !== "npm" && incoming.type === "npm") return incoming;
303
+ return current;
304
+ }
305
+
306
+ function mergeEvidence(left, right) {
307
+ if (!left && !right) return undefined;
308
+ const stars = Math.max(left?.stars ?? -Infinity, right?.stars ?? -Infinity);
309
+ const downloads30d = Math.max(left?.downloads30d ?? -Infinity, right?.downloads30d ?? -Infinity);
310
+ const ratingSource = (right?.ratingCount ?? -1) > (left?.ratingCount ?? -1) ? right : left;
311
+ return {
312
+ ...((left?.verified === true || right?.verified === true) ? { verified: true } : {}),
313
+ ...(Number.isFinite(stars) ? { stars } : {}),
314
+ ...(Number.isFinite(downloads30d) ? { downloads30d } : {}),
315
+ ...(ratingSource?.rating !== undefined ? { rating: ratingSource.rating } : {}),
316
+ ...(ratingSource?.ratingCount !== undefined ? { ratingCount: ratingSource.ratingCount } : {}),
317
+ ...(left?.compatibility || right?.compatibility ? { compatibility: left?.compatibility ?? right?.compatibility } : {}),
318
+ ...(left?.dshCompatibility || right?.dshCompatibility ? { dshCompatibility: left?.dshCompatibility ?? right?.dshCompatibility } : {}),
319
+ ...(latestIsoDate(left?.releasedAt, right?.releasedAt) ? { releasedAt: latestIsoDate(left?.releasedAt, right?.releasedAt) } : {}),
320
+ ...(latestIsoDate(left?.repositoryUpdatedAt, right?.repositoryUpdatedAt) ? { repositoryUpdatedAt: latestIsoDate(left?.repositoryUpdatedAt, right?.repositoryUpdatedAt) } : {}),
321
+ ...(Math.max(left?.maintenanceScore ?? -Infinity, right?.maintenanceScore ?? -Infinity) > -Infinity ? { maintenanceScore: Math.max(left?.maintenanceScore ?? -Infinity, right?.maintenanceScore ?? -Infinity) } : {}),
322
+ ...(left?.releaseChannel || right?.releaseChannel ? { releaseChannel: left?.releaseChannel ?? right?.releaseChannel } : {}),
323
+ ...((left?.installability === "bundle" || right?.installability === "bundle")
324
+ ? { installability: "bundle" }
325
+ : (left?.installability === "not-bundle" || right?.installability === "not-bundle")
326
+ ? { installability: "not-bundle" }
327
+ : (left?.installability === "unknown" || right?.installability === "unknown")
328
+ ? { installability: "unknown" } : {}),
329
+ };
330
+ }
331
+
332
+ function identityAliases(plugin) {
333
+ const aliases = [];
334
+ if (plugin.identity?.package) aliases.push(`npm:${plugin.identity.package.toLowerCase()}`);
335
+ if (plugin.identity?.repository) aliases.push(`git:${canonicalRepository(plugin.identity.repository)}`);
336
+ if (aliases.length === 0) aliases.push(`source:${plugin.identity?.fallback ?? plugin.name.toLowerCase()}`);
337
+ return aliases;
338
+ }
339
+
340
+ function clonePlugin(plugin) {
341
+ return { ...plugin, sources: [...plugin.sources], versions: [...(plugin.versions ?? [])], tags: [...(plugin.tags ?? [])] };
342
+ }
343
+
344
+ function mergePluginRecords(current, plugin) {
345
+ const sources = new Map(current.sources.map((source) => [source.id, source]));
346
+ for (const source of plugin.sources) sources.set(source.id, source);
347
+ return {
348
+ ...current,
349
+ identity: {
350
+ ...(current.identity ?? {}),
351
+ ...(plugin.identity?.package && !current.identity?.package ? { package: plugin.identity.package } : {}),
352
+ ...(plugin.identity?.repository && !current.identity?.repository ? { repository: plugin.identity.repository } : {}),
353
+ },
354
+ name: current.name || plugin.name,
355
+ description: (plugin.description?.length ?? 0) > (current.description?.length ?? 0) ? plugin.description : current.description,
356
+ version: current.version ?? plugin.version,
357
+ versions: [...new Set([...(current.versions ?? []), ...(plugin.versions ?? [])])],
358
+ tags: [...new Set([...(current.tags ?? []), ...(plugin.tags ?? [])])].slice(0, 8),
359
+ install: preferInstall(current.install, plugin.install),
360
+ sources: [...sources.values()],
361
+ evidence: mergeEvidence(current.evidence, plugin.evidence),
362
+ };
363
+ }
364
+
365
+ export function dedupePlugins(plugins) {
366
+ const aliases = new Map();
367
+ const records = new Set();
368
+ for (const plugin of plugins) {
369
+ const keys = identityAliases(plugin);
370
+ const matches = [...new Set(keys.map((key) => aliases.get(key)).filter(Boolean))];
371
+ let target;
372
+ if (matches.length === 0) {
373
+ target = { plugin: clonePlugin(plugin), aliases: new Set(keys) };
374
+ records.add(target);
375
+ } else {
376
+ target = matches[0];
377
+ target.plugin = mergePluginRecords(target.plugin, plugin);
378
+ for (const other of matches.slice(1)) {
379
+ target.plugin = mergePluginRecords(target.plugin, other.plugin);
380
+ for (const key of other.aliases) target.aliases.add(key);
381
+ records.delete(other);
382
+ }
383
+ for (const key of keys) target.aliases.add(key);
384
+ }
385
+ for (const key of identityAliases(target.plugin)) target.aliases.add(key);
386
+ for (const key of target.aliases) aliases.set(key, target);
387
+ }
388
+ return [...records].map((record) => record.plugin).sort((a, b) => a.name.localeCompare(b.name));
389
+ }
390
+
391
+ export function searchPlugins(plugins, query) {
392
+ const needle = text(query)?.toLowerCase();
393
+ if (!needle) return plugins;
394
+ return plugins.filter((plugin) => [
395
+ plugin.name,
396
+ plugin.description,
397
+ plugin.identity?.package,
398
+ plugin.identity?.repository,
399
+ ...(plugin.tags ?? []),
400
+ plugin.evidence?.releaseChannel,
401
+ ...(plugin.sources ?? []).flatMap((source) => [source.name, source.type]),
402
+ ].some((value) => typeof value === "string" && value.toLowerCase().includes(needle)));
403
+ }
404
+
405
+ export function isPrivateAddress(address) {
406
+ try {
407
+ const parsed = ipaddr.process(address);
408
+ return parsed.range() !== "unicast";
409
+ } catch {
410
+ return true;
411
+ }
412
+ }
413
+
414
+ function abortable(promise, signal) {
415
+ if (signal.aborted) return Promise.reject(signal.reason ?? new Error("source request aborted"));
416
+ return new Promise((resolve, reject) => {
417
+ const abort = () => reject(signal.reason ?? new Error("source request aborted"));
418
+ signal.addEventListener("abort", abort, { once: true });
419
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
420
+ });
421
+ }
422
+
423
+ async function assertSafeUrl(url, resolveHost, signal, authenticated, allowPrivateNetwork) {
424
+ if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error("only http(s) catalog URLs are supported");
425
+ if (url.username || url.password) throw new Error("credentials in source URLs are not allowed");
426
+ if (authenticated && url.protocol !== "https:") throw new Error("authenticated sources require HTTPS");
427
+ const hostname = url.hostname.replace(/^\[|\]$/g, "");
428
+ if (hostname === "localhost" || hostname.endsWith(".localhost")) throw new Error("private network destinations are blocked");
429
+ const resolved = await abortable(Promise.resolve(resolveHost(hostname)), signal);
430
+ const addresses = resolved.map((row) => typeof row === "string" ? { address: row, family: isIP(row) } : row)
431
+ .filter((row) => typeof row?.address === "string" && (row.family === 4 || row.family === 6));
432
+ if (!addresses.length) throw new Error("source hostname resolved to no usable address");
433
+ if (!allowPrivateNetwork && addresses.some((row) => isPrivateAddress(row.address))) throw new Error("private network destinations are blocked");
434
+ return addresses[0];
435
+ }
436
+
437
+ async function defaultResolveHost(hostname) {
438
+ if (isIP(hostname)) return [{ address: hostname, family: isIP(hostname) }];
439
+ return dnsLookup(hostname, { all: true, verbatim: true });
440
+ }
441
+
442
+ function pinnedDispatcher(address, factory) {
443
+ if (factory) return factory(address);
444
+ return new Agent({ connect: { lookup(_hostname, options, callback) {
445
+ if (options?.all) callback(null, [address]);
446
+ else callback(null, address.address, address.family);
447
+ } } });
448
+ }
449
+
450
+ async function readResponseBytes(response, maxBytes) {
451
+ const declared = Number(response.headers.get("content-length"));
452
+ if (Number.isFinite(declared) && declared > maxBytes) throw new Error(`catalog response exceeds ${maxBytes} bytes`);
453
+ if (!response.body?.getReader) {
454
+ const bytes = new Uint8Array(await response.arrayBuffer());
455
+ if (bytes.byteLength > maxBytes) throw new Error(`catalog response exceeds ${maxBytes} bytes`);
456
+ return bytes;
457
+ }
458
+ const reader = response.body.getReader();
459
+ const chunks = [];
460
+ let total = 0;
461
+ while (true) {
462
+ const { done, value } = await reader.read();
463
+ if (done) break;
464
+ total += value.byteLength;
465
+ if (total > maxBytes) {
466
+ await reader.cancel();
467
+ throw new Error(`catalog response exceeds ${maxBytes} bytes`);
468
+ }
469
+ chunks.push(value);
470
+ }
471
+ const joined = new Uint8Array(total);
472
+ let offset = 0;
473
+ for (const chunk of chunks) { joined.set(chunk, offset); offset += chunk.byteLength; }
474
+ return joined;
475
+ }
476
+
477
+ export async function fetchJson(urlInput, source, options = {}) {
478
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
479
+ if (typeof fetchImpl !== "function") throw new Error("Host fetch is unavailable");
480
+ const resolveHost = options.resolveHost ?? defaultResolveHost;
481
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
482
+ const maxBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
483
+ const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
484
+ const authToken = text(options.authToken);
485
+ let url = new URL(urlInput);
486
+ const headers = { accept: "application/json", "user-agent": "dsh-plugin-sources/0.2", ...(authToken ? { authorization: `Bearer ${authToken}` } : {}) };
487
+ const controller = new AbortController();
488
+ let timedOut = false;
489
+ const timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
490
+ const forwardAbort = () => controller.abort(options.signal?.reason);
491
+ options.signal?.addEventListener("abort", forwardAbort, { once: true });
492
+ try {
493
+ for (let redirects = 0; redirects <= maxRedirects; redirects++) {
494
+ let dispatcher;
495
+ try {
496
+ const address = await assertSafeUrl(url, resolveHost, controller.signal, Boolean(authToken), options.allowPrivateNetwork === true);
497
+ dispatcher = options.fetchImpl && !options.dispatcherFactory ? undefined : pinnedDispatcher(address, options.dispatcherFactory);
498
+ let response;
499
+ try {
500
+ response = await fetchImpl(url, { method: "GET", headers, redirect: "manual", signal: controller.signal, dispatcher });
501
+ } catch (error) {
502
+ if (timedOut) throw new Error("source request timed out");
503
+ if (options.signal?.aborted) throw new Error("source request aborted");
504
+ throw new Error(`source request failed: ${String(error?.message ?? error)}`);
505
+ }
506
+ if ([301, 302, 303, 307, 308].includes(response.status)) {
507
+ const location = response.headers.get("location");
508
+ await response.body?.cancel?.();
509
+ if (!location) throw new Error(`source redirect ${response.status} has no Location header`);
510
+ if (redirects === maxRedirects) throw new Error("source redirected too many times");
511
+ const next = new URL(location, url);
512
+ if (authToken && next.origin !== url.origin) throw new Error("authenticated source cannot redirect to another origin");
513
+ url = next;
514
+ continue;
515
+ }
516
+ if (!response.ok) {
517
+ await response.body?.cancel?.();
518
+ throw new Error(`source returned HTTP ${response.status}`);
519
+ }
520
+ let bytes;
521
+ try {
522
+ bytes = await readResponseBytes(response, maxBytes);
523
+ } catch (error) {
524
+ if (timedOut) throw new Error("source request timed out");
525
+ if (options.signal?.aborted) throw new Error("source request aborted");
526
+ throw error;
527
+ }
528
+ try {
529
+ return JSON.parse(new TextDecoder().decode(bytes));
530
+ } catch {
531
+ throw new Error("source returned malformed JSON");
532
+ }
533
+ } finally {
534
+ await dispatcher?.close?.();
535
+ }
536
+ }
537
+ throw new Error("source redirected too many times");
538
+ } catch (error) {
539
+ if (timedOut) throw new Error("source request timed out");
540
+ if (options.signal?.aborted) throw new Error("source request aborted");
541
+ throw error;
542
+ } finally {
543
+ clearTimeout(timer);
544
+ options.signal?.removeEventListener("abort", forwardAbort);
545
+ }
546
+ }
547
+
548
+ function catalogRows(manifest) {
549
+ if (Array.isArray(manifest)) return manifest;
550
+ if (manifest && typeof manifest === "object") {
551
+ for (const key of ["plugins", "items", "results"]) if (Array.isArray(manifest[key])) return manifest[key];
552
+ }
553
+ throw new Error("catalog JSON must be an array or contain plugins/items/results");
554
+ }
555
+
556
+ function normalizeCatalog(manifest, source, maxPlugins) {
557
+ return catalogRows(manifest).slice(0, maxPlugins).map((row) => normalizePlugin(row, source)).filter(Boolean);
558
+ }
559
+
560
+ function npmKeywords(entry) {
561
+ const value = entry?.package?.keywords;
562
+ if (Array.isArray(value)) return value.map((keyword) => String(keyword).toLowerCase());
563
+ if (typeof value === "string") return value.split(/[\s,]+/u).map((keyword) => keyword.toLowerCase()).filter(Boolean);
564
+ return [];
565
+ }
566
+
567
+ function npmEntryIsDshPlugin(entry) {
568
+ const keywords = new Set(npmKeywords(entry));
569
+ return NPM_DISCOVERY_KEYWORDS.some((keyword) => keywords.has(keyword));
570
+ }
571
+
572
+ function npmRows(manifest, { requireDiscoveryKeyword = false } = {}) {
573
+ if (!manifest || !Array.isArray(manifest.objects)) throw new Error("npm search returned an invalid response");
574
+ return manifest.objects.filter((entry) => !requireDiscoveryKeyword || npmEntryIsDshPlugin(entry)).map((entry) => {
575
+ const pkg = entry?.package ?? {};
576
+ const links = pkg.links ?? {};
577
+ return {
578
+ id: pkg.name,
579
+ name: pkg.name,
580
+ description: pkg.description,
581
+ version: pkg.version,
582
+ package: pkg.name,
583
+ repository: links.repository,
584
+ keywords: pkg.keywords,
585
+ install: { type: "npm", spec: pkg.version ? `${pkg.name}@${pkg.version}` : pkg.name },
586
+ evidence: {
587
+ verified: entry?.flags?.unstable === false,
588
+ ...(typeof entry?.downloads?.monthly === "number" ? { downloads30d: entry.downloads.monthly } : {}),
589
+ ...(isoDate(pkg.date ?? entry?.updated) ? { releasedAt: isoDate(pkg.date ?? entry?.updated) } : {}),
590
+ ...(typeof entry?.score?.detail?.maintenance === "number" ? { maintenanceScore: entry.score.detail.maintenance } : {}),
591
+ },
592
+ };
593
+ });
594
+ }
595
+
596
+ function exactNpmPackageName(value) {
597
+ const needle = text(value);
598
+ if (!needle) return undefined;
599
+ const valid = /^(?:@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*|[a-z0-9][a-z0-9._~-]*)$/i.test(needle);
600
+ if (!valid) return undefined;
601
+ // A one-word search such as "schedule" is a discovery query, not a useful
602
+ // exact-registry probe. Scoped names and package-style hyphenated names are.
603
+ return needle.startsWith("@") || needle.includes("-") ? needle : undefined;
604
+ }
605
+
606
+ function npmRegistryBase(sourceUrl) {
607
+ const url = new URL(sourceUrl ?? "https://registry.npmjs.org");
608
+ if (/\/-\/v1\/search\/?$/u.test(url.pathname)) url.pathname = url.pathname.replace(/\/-\/v1\/search\/?$/u, "/");
609
+ if (!url.pathname.endsWith("/")) url.pathname += "/";
610
+ url.search = "";
611
+ url.hash = "";
612
+ return url;
613
+ }
614
+
615
+ function encodedNpmPackageName(packageName) {
616
+ return packageName.startsWith("@")
617
+ ? `@${encodeURIComponent(packageName.slice(1).split("/")[0])}%2F${encodeURIComponent(packageName.split("/")[1] ?? "")}`
618
+ : encodeURIComponent(packageName);
619
+ }
620
+
621
+ function npmPackumentUrl(source, packageName) {
622
+ const base = npmRegistryBase(source?.url);
623
+ return new URL(encodedNpmPackageName(packageName), base);
624
+ }
625
+
626
+ function latestManifestFromPackument(packument, packageName) {
627
+ if (!packument || typeof packument !== "object" || Array.isArray(packument)) throw new Error("npm packument returned an invalid response");
628
+ if (text(packument.name) && text(packument.version)) return packument;
629
+ const latest = text(packument?.["dist-tags"]?.latest);
630
+ const manifest = latest ? packument?.versions?.[latest] : undefined;
631
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) throw new Error("npm packument has no latest manifest");
632
+ return manifest;
633
+ }
634
+
635
+ function npmLatestRow(packument, packageName) {
636
+ const manifest = latestManifestFromPackument(packument, packageName);
637
+ const name = text(manifest.name) ?? text(packument?.name) ?? packageName;
638
+ const version = text(manifest.version);
639
+ if (!name || !version) throw new Error("npm latest manifest is missing name/version");
640
+ const repository = typeof manifest.repository === "string" ? manifest.repository : text(manifest.repository?.url);
641
+ const explicit = text(manifest?.peerDependencies?.["@deepseek-ai/dsh"])
642
+ ?? text(manifest?.engines?.dsh)
643
+ ?? text(manifest?.dsh?.compatibility);
644
+ return {
645
+ id: name,
646
+ name,
647
+ description: manifest.description,
648
+ version,
649
+ package: name,
650
+ ...(repository ? { repository } : {}),
651
+ keywords: manifest.keywords,
652
+ install: { type: "npm", spec: `${name}@${version}` },
653
+ evidence: {
654
+ ...manifestInstallability(manifest),
655
+ ...(explicit ? { dshCompatibility: explicit } : {}),
656
+ ...(isoDate(manifest.date ?? manifest.publishedAt) ? { releasedAt: isoDate(manifest.date ?? manifest.publishedAt) } : {}),
657
+ },
658
+ };
659
+ }
660
+
661
+ function githubRows(manifest) {
662
+ if (!manifest || !Array.isArray(manifest.items)) throw new Error("GitHub search returned an invalid response");
663
+ return manifest.items.map((repo) => ({
664
+ id: String(repo.id ?? repo.full_name ?? repo.name),
665
+ name: repo.name ?? repo.full_name,
666
+ description: repo.description,
667
+ repository: repo.html_url ?? repo.clone_url,
668
+ topics: repo.topics,
669
+ install: (repo.clone_url ?? repo.html_url) ? { type: "git", spec: repo.clone_url ?? repo.html_url } : undefined,
670
+ evidence: {
671
+ stars: repo.stargazers_count,
672
+ ...(isoDate(repo.pushed_at ?? repo.updated_at) ? { repositoryUpdatedAt: isoDate(repo.pushed_at ?? repo.updated_at) } : {}),
673
+ },
674
+ }));
675
+ }
676
+
677
+ export function createAdapter(sourceInput, options = {}) {
678
+ const source = normalizeSource(sourceInput);
679
+ if (!source) throw new Error("invalid plugin source configuration");
680
+ const maxPlugins = options.maxPlugins ?? DEFAULT_MAX_PLUGINS;
681
+ const authToken = options.authToken ?? options.resolveAuthToken?.(source);
682
+ const allowPrivateNetwork = options.allowPrivateNetwork === true || options.resolveAllowPrivateNetwork?.(source) === true;
683
+ const requestOptions = { ...options, ...(authToken ? { authToken } : {}), allowPrivateNetwork };
684
+ const request = (url) => fetchJson(url, source, requestOptions);
685
+ const customList = async () => {
686
+ if (!source.url) throw new Error(`${source.type} source requires a catalog URL`);
687
+ return normalizeCatalog(await request(source.url), source, maxPlugins);
688
+ };
689
+ let list;
690
+ let search;
691
+ if (["custom-json", "corporate", "dsh-plugin-shop", "dshplugin-app"].includes(source.type)) {
692
+ list = customList;
693
+ search = async (query) => searchPlugins(await customList(), query);
694
+ } else if (source.type === "npm") {
695
+ search = async (query = "") => {
696
+ const base = source.url && /\/-\/v1\/search\/?$/u.test(new URL(source.url).pathname)
697
+ ? source.url
698
+ : new URL("-/v1/search", npmRegistryBase(source.url)).toString();
699
+ const size = String(Math.min(maxPlugins, 250));
700
+ const needle = text(query);
701
+ const exactName = exactNpmPackageName(needle);
702
+ const keywordBatches = NPM_DISCOVERY_KEYWORDS.map(async (keyword) => {
703
+ const url = new URL(base);
704
+ url.searchParams.set("text", [`keywords:${keyword}`, needle].filter(Boolean).join(" "));
705
+ url.searchParams.set("size", size);
706
+ return normalizeCatalog(npmRows(await request(url)), source, maxPlugins);
707
+ });
708
+ const rawQueryBatch = needle ? [async () => {
709
+ const url = new URL(base);
710
+ url.searchParams.set("text", needle);
711
+ url.searchParams.set("size", size);
712
+ return normalizeCatalog(npmRows(await request(url), { requireDiscoveryKeyword: true }), source, maxPlugins);
713
+ }] : [];
714
+ const exactBatch = exactName ? [async () => {
715
+ try {
716
+ return normalizeCatalog([npmLatestRow(await request(npmPackumentUrl(source, exactName)), exactName)], source, 1);
717
+ } catch (error) {
718
+ if (/HTTP 404|status 404|Not Found/i.test(String(error?.message ?? error))) return [];
719
+ throw error;
720
+ }
721
+ }] : [];
722
+ const batches = await Promise.all([...keywordBatches, ...rawQueryBatch.map((load) => load()), ...exactBatch.map((load) => load())]);
723
+ return searchPlugins(dedupePlugins(batches.flat()), query).slice(0, maxPlugins);
724
+ };
725
+ list = () => search("");
726
+ } else if (source.type === "github") {
727
+ search = async (query = "") => {
728
+ const base = source.url ?? "https://api.github.com/search/repositories";
729
+ const perPage = String(Math.min(maxPlugins, 100));
730
+ const batches = await Promise.all(GITHUB_DISCOVERY_QUERIES.map(async (discoveryQuery) => {
731
+ const url = new URL(base);
732
+ url.searchParams.set("q", [discoveryQuery, text(query)].filter(Boolean).join(" "));
733
+ url.searchParams.set("per_page", perPage);
734
+ return normalizeCatalog(githubRows(await request(url)), source, maxPlugins);
735
+ }));
736
+ return searchPlugins(dedupePlugins(batches.flat()), query).slice(0, maxPlugins);
737
+ };
738
+ list = () => search("");
739
+ }
740
+ return {
741
+ source,
742
+ async health() {
743
+ const started = Date.now();
744
+ try {
745
+ let plugins = await list();
746
+ if ((source.type === "npm" || source.type === "github") && shouldVerifyRegistryBundles(options)) {
747
+ plugins = await enrichInstallability(plugins, options);
748
+ plugins = plugins.filter((plugin) => plugin.evidence?.installability === "bundle");
749
+ }
750
+ return { ok: true, latencyMs: Date.now() - started, count: plugins.length };
751
+ } catch (error) {
752
+ return { ok: false, latencyMs: Date.now() - started, error: String(error?.message ?? error) };
753
+ }
754
+ },
755
+ list,
756
+ search,
757
+ async get(id) { return (await list()).find((plugin) => pluginIdentity(plugin) === id || plugin.name === id); },
758
+ };
759
+ }
760
+
761
+ function validatedSources(sourceInputs, options) {
762
+ const maxSources = options.maxSources ?? 20;
763
+ if (!Array.isArray(sourceInputs) || sourceInputs.length > maxSources) throw new Error(`at most ${maxSources} plugin sources are allowed`);
764
+ const sources = sourceInputs.map(normalizeSource);
765
+ if (sources.some((source) => !source)) throw new Error("invalid plugin source configuration");
766
+ if (new Set(sources.map((source) => source.id)).size !== sources.length) throw new Error("plugin source ids must be unique");
767
+ return sources;
768
+ }
769
+
770
+ async function mapConcurrent(items, concurrency, worker) {
771
+ const output = new Array(items.length);
772
+ let cursor = 0;
773
+ async function run() {
774
+ while (true) {
775
+ const index = cursor++;
776
+ if (index >= items.length) return;
777
+ output[index] = await worker(items[index], index);
778
+ }
779
+ }
780
+ await Promise.all(Array.from({ length: Math.min(Math.max(1, concurrency), items.length) }, run));
781
+ return output;
782
+ }
783
+
784
+ export async function healthSources(sourceInputs, options = {}) {
785
+ const sources = validatedSources(sourceInputs, options);
786
+ return mapConcurrent(sources, options.concurrency ?? 4, async (source) => ({
787
+ source: sourceAttribution(source),
788
+ health: source.enabled ? await createAdapter(source, options).health() : { ok: false, disabled: true },
789
+ }));
790
+ }
791
+
792
+ async function enrichNpmDownloads(plugins, options = {}) {
793
+ const packages = [...new Set(plugins.map((plugin) => plugin.identity?.package).filter(Boolean))].slice(0, options.maxEvidencePackages ?? 100);
794
+ if (!packages.length || options.enrichDownloads === false) return plugins;
795
+ const source = { id: "npm-downloads", name: "npm downloads", type: "npm", enabled: true };
796
+ const requestOptions = { ...options, authToken: undefined, allowPrivateNetwork: false };
797
+ const counts = new Map();
798
+ const pending = [];
799
+ for (const name of packages) {
800
+ const cached = cachedEvidence(npmDownloadCache, name);
801
+ if (cached === undefined) pending.push(name);
802
+ else counts.set(name, cached);
803
+ }
804
+
805
+ // npm's downloads API supports bulk point queries for unscoped packages,
806
+ // but scoped packages are not supported in bulk queries. Query each scoped
807
+ // package separately instead of letting one invalid bulk request zero the
808
+ // whole result set.
809
+ const unscoped = pending.filter((name) => !name.startsWith("@"));
810
+ const scoped = pending.filter((name) => name.startsWith("@"));
811
+ const tasks = [];
812
+ for (let index = 0; index < unscoped.length; index += 64) tasks.push(unscoped.slice(index, index + 64));
813
+ for (const name of scoped) tasks.push([name]);
814
+
815
+ await mapConcurrent(tasks, Math.min(options.concurrency ?? 4, 6), async (chunk) => {
816
+ try {
817
+ const encoded = chunk.map((name) => encodeURIComponent(name)).join(",");
818
+ const value = await fetchJson(`https://api.npmjs.org/downloads/point/last-month/${encoded}`, source, requestOptions);
819
+ if (chunk.length === 1 && typeof value?.downloads === "number") {
820
+ counts.set(chunk[0], value.downloads);
821
+ cacheEvidence(npmDownloadCache, chunk[0], value.downloads);
822
+ return;
823
+ }
824
+ for (const name of chunk) {
825
+ const downloads = value?.[name]?.downloads;
826
+ if (typeof downloads !== "number") continue;
827
+ counts.set(name, downloads);
828
+ cacheEvidence(npmDownloadCache, name, downloads);
829
+ }
830
+ } catch {
831
+ // Popularity metadata is best-effort and must never hide catalog results.
832
+ }
833
+ });
834
+ return plugins.map((plugin) => {
835
+ const downloads30d = counts.get(plugin.identity?.package);
836
+ if (downloads30d === undefined) return plugin;
837
+ return { ...plugin, evidence: mergeEvidence(plugin.evidence, { downloads30d }) };
838
+ });
839
+ }
840
+
841
+ function freshnessTimestamp(plugin) {
842
+ return Date.parse(plugin.evidence?.releasedAt ?? plugin.evidence?.repositoryUpdatedAt ?? "") || 0;
843
+ }
844
+
845
+ function percentileScores(plugins, metric) {
846
+ const ranked = plugins.map((plugin, index) => ({ plugin, index, value: Number(metric(plugin)) || 0 }))
847
+ .sort((a, b) => a.value - b.value || a.index - b.index);
848
+ const scores = new Map();
849
+ const denominator = Math.max(1, ranked.length - 1);
850
+ let cursor = 0;
851
+ while (cursor < ranked.length) {
852
+ let end = cursor + 1;
853
+ while (end < ranked.length && ranked[end].value === ranked[cursor].value) end++;
854
+ const averageRank = ((cursor + end - 1) / 2) / denominator;
855
+ for (let index = cursor; index < end; index++) scores.set(ranked[index].plugin, averageRank);
856
+ cursor = end;
857
+ }
858
+ return scores;
859
+ }
860
+
861
+ function compositeScores(plugins, leftMetric, rightMetric) {
862
+ const left = percentileScores(plugins, leftMetric);
863
+ const right = percentileScores(plugins, rightMetric);
864
+ const scores = new Map();
865
+ for (const plugin of plugins) scores.set(plugin, ((left.get(plugin) ?? 0) + (right.get(plugin) ?? 0)) / 2);
866
+ return scores;
867
+ }
868
+
869
+
870
+ function manifestInstallability(manifest) {
871
+ const bundlePatch = text(manifest?.dsh?.bundle?.patch);
872
+ return {
873
+ installability: bundlePatch ? "bundle" : "not-bundle",
874
+ ...(bundlePatch ? { bundlePatch } : {}),
875
+ };
876
+ }
877
+
878
+ function githubRepositoryParts(repository) {
879
+ const canonical = canonicalRepository(repository);
880
+ if (!canonical) return undefined;
881
+ try {
882
+ const url = new URL(canonical);
883
+ if (url.hostname.toLowerCase() !== "github.com") return undefined;
884
+ const parts = url.pathname.replace(/^\/+|\/+$/g, "").split("/");
885
+ if (parts.length !== 2 || !parts[0] || !parts[1]) return undefined;
886
+ return { owner: parts[0], repo: parts[1].replace(/\.git$/i, "") };
887
+ } catch {
888
+ return undefined;
889
+ }
890
+ }
891
+
892
+ export async function githubRepositoryStats(repositories, options = {}) {
893
+ const values = Array.isArray(repositories) ? repositories : [];
894
+ if (values.length > 20) throw new RangeError("at most 20 GitHub repositories can be resolved at once");
895
+ const source = { id: "github", name: "GitHub", type: "github", enabled: true };
896
+ const authToken = options.authToken ?? options.resolveAuthToken?.(source);
897
+ const requestOptions = { ...options, ...(authToken ? { authToken } : {}), allowPrivateNetwork: false };
898
+ const normalized = [...new Set(values.map((repository) => canonicalRepository(repository)).filter(Boolean))];
899
+ const rows = await mapConcurrent(normalized, Math.min(3, Math.max(1, normalized.length)), async (repository) => {
900
+ const parts = githubRepositoryParts(repository);
901
+ if (!parts) return undefined;
902
+ const key = `${parts.owner.toLowerCase()}/${parts.repo.toLowerCase()}`;
903
+ const cached = cachedValue(githubStatsCache, key);
904
+ if (cached !== undefined) return { repository, ...cached };
905
+ try {
906
+ const value = await fetchJson(
907
+ `https://api.github.com/repos/${encodeURIComponent(parts.owner)}/${encodeURIComponent(parts.repo)}`,
908
+ source,
909
+ requestOptions,
910
+ );
911
+ const stats = {
912
+ ...(finiteNumber(value?.stargazers_count) !== undefined ? { stars: finiteNumber(value.stargazers_count) } : {}),
913
+ ...(isoDate(value?.pushed_at ?? value?.updated_at) ? { repositoryUpdatedAt: isoDate(value.pushed_at ?? value.updated_at) } : {}),
914
+ };
915
+ cacheValue(githubStatsCache, key, stats);
916
+ return { repository, ...stats };
917
+ } catch {
918
+ return { repository };
919
+ }
920
+ });
921
+ return rows.filter(Boolean);
922
+ }
923
+
924
+ export async function githubPluginDetails(repository, options = {}) {
925
+ const parts = githubRepositoryParts(repository);
926
+ if (!parts) return { installability: "unknown" };
927
+ const cacheKey = `${parts.owner.toLowerCase()}/${parts.repo.toLowerCase()}`;
928
+ const cached = cachedValue(githubManifestCache, cacheKey);
929
+ if (cached !== undefined) return cached;
930
+
931
+ const source = { id: "github-manifest", name: "GitHub manifest", type: "github", enabled: true };
932
+ const requestOptions = { ...options, authToken: undefined, allowPrivateNetwork: false };
933
+ const rawUrl = `https://raw.githubusercontent.com/${encodeURIComponent(parts.owner)}/${encodeURIComponent(parts.repo)}/HEAD/package.json`;
934
+ let details;
935
+ try {
936
+ const manifest = await fetchJson(rawUrl, source, requestOptions);
937
+ details = {
938
+ ...manifestInstallability(manifest),
939
+ ...(text(manifest?.name) ? { packageName: text(manifest.name) } : {}),
940
+ ...(text(manifest?.version) ? { version: text(manifest.version) } : {}),
941
+ ...(manifest?.private === true ? { private: true } : {}),
942
+ ...(text(manifest?.peerDependencies?.["@deepseek-ai/dsh"]) ? { dshCompatibility: text(manifest.peerDependencies["@deepseek-ai/dsh"]) } : {}),
943
+ };
944
+ } catch (error) {
945
+ const message = String(error?.message ?? error);
946
+ details = /HTTP 404|status 404|Not Found/i.test(message)
947
+ ? { installability: "not-bundle", reason: "No root package.json" }
948
+ : { installability: "unknown", reason: message };
949
+ }
950
+ cacheValue(githubManifestCache, cacheKey, details);
951
+ return details;
952
+ }
953
+
954
+ function shouldVerifyRegistryBundles(options = {}) {
955
+ return options.verifyInstallability === true
956
+ || (options.verifyInstallability !== false && options.fetchImpl === undefined);
957
+ }
958
+
959
+ function requiresRegistryVerification(plugin) {
960
+ return (plugin.sources ?? []).some((source) => source.type === "npm" || source.type === "github");
961
+ }
962
+
963
+ async function enrichInstallability(plugins, options = {}) {
964
+ if (!shouldVerifyRegistryBundles(options) || plugins.length === 0) return plugins;
965
+ const verificationConcurrency = Math.min(Math.max(1, options.verificationConcurrency ?? 12), 16);
966
+
967
+ return mapConcurrent(plugins, verificationConcurrency, async (plugin) => {
968
+ if (plugin.evidence?.installability === "bundle" || plugin.evidence?.installability === "not-bundle") return plugin;
969
+ let details;
970
+ try {
971
+ if (plugin.identity?.package && plugin.version) {
972
+ details = await npmPluginDetails(plugin.identity.package, plugin.version, options);
973
+ } else if (plugin.identity?.repository && plugin.sources?.some((source) => source.type === "github")) {
974
+ details = await githubPluginDetails(plugin.identity.repository, options);
975
+ } else {
976
+ return plugin;
977
+ }
978
+ } catch {
979
+ details = { installability: "unknown" };
980
+ }
981
+ return { ...plugin, evidence: mergeEvidence(plugin.evidence, { installability: details?.installability ?? "unknown" }) };
982
+ });
983
+ }
984
+
985
+ export async function npmPluginDetails(packageName, version, options = {}) {
986
+ const name = text(packageName);
987
+ const requestedVersion = text(version);
988
+ if (!name || !requestedVersion || !safeInstallSpec("npm", `${name}@${requestedVersion}`)) throw new Error("invalid npm package details request");
989
+ const cacheKey = `${name}@${requestedVersion}`;
990
+ const cached = cachedValue(npmManifestCache, cacheKey);
991
+ if (cached !== undefined) return cached;
992
+
993
+ const source = { id: "npm-manifest", name: "npm registry", type: "npm", enabled: true };
994
+ const requestOptions = { ...options, authToken: undefined, allowPrivateNetwork: false };
995
+ const encodedName = name.startsWith("@")
996
+ ? `@${encodeURIComponent(name.slice(1).split("/")[0])}%2F${encodeURIComponent(name.split("/")[1] ?? "")}`
997
+ : encodeURIComponent(name);
998
+ const manifest = await fetchJson(`https://registry.npmjs.org/${encodedName}/${encodeURIComponent(requestedVersion)}`, source, requestOptions);
999
+ const explicit = text(manifest?.peerDependencies?.["@deepseek-ai/dsh"])
1000
+ ?? text(manifest?.engines?.dsh)
1001
+ ?? text(manifest?.dsh?.compatibility);
1002
+ const peerEntries = Object.entries(manifest?.peerDependencies ?? {})
1003
+ .filter(([dependency, range]) => dependency.startsWith("@deepseek-ai/dsh-") && typeof range === "string")
1004
+ .map(([dependency, range]) => ({ dependency, range }));
1005
+ const details = {
1006
+ ...manifestInstallability(manifest),
1007
+ ...(explicit ? { dshCompatibility: explicit } : {}),
1008
+ ...(peerEntries.length ? { dshPeers: peerEntries.slice(0, 12) } : {}),
1009
+ ...(manifest?.deprecated ? { deprecated: String(manifest.deprecated) } : {}),
1010
+ ...(manifest?.private === true ? { private: true } : {}),
1011
+ };
1012
+ cacheValue(npmManifestCache, cacheKey, details);
1013
+ return details;
1014
+ }
1015
+
1016
+
1017
+ function applyBrowseFilters(plugins, options) {
1018
+ let filtered = plugins;
1019
+ const freshnessDays = Number(options.freshnessDays) || 0;
1020
+ if (freshnessDays > 0) {
1021
+ const cutoff = Date.now() - freshnessDays * 86_400_000;
1022
+ filtered = filtered.filter((plugin) => freshnessTimestamp(plugin) >= cutoff);
1023
+ }
1024
+ const tag = text(options.tag)?.toLowerCase();
1025
+ if (tag && FILTER_TAGS.includes(tag)) filtered = filtered.filter((plugin) => plugin.tags?.includes(tag));
1026
+ const dshMetadata = options.dshMetadata ?? "any";
1027
+ if (dshMetadata === "declared") filtered = filtered.filter((plugin) => Boolean(plugin.evidence?.dshCompatibility));
1028
+ else if (dshMetadata === "unknown") filtered = filtered.filter((plugin) => !plugin.evidence?.dshCompatibility);
1029
+ return filtered;
1030
+ }
1031
+
1032
+ const SORT_KEYS = ["stars", "downloads", "freshness", "name"];
1033
+
1034
+ function normalizedSorts(options = {}) {
1035
+ const input = Array.isArray(options.sorts) ? options.sorts : [];
1036
+ const seen = new Set();
1037
+ const normalized = [];
1038
+ for (const item of input) {
1039
+ const key = text(item?.key);
1040
+ const direction = item?.direction === "asc" ? "asc" : item?.direction === "desc" ? "desc" : undefined;
1041
+ if (!SORT_KEYS.includes(key) || !direction || seen.has(key)) continue;
1042
+ seen.add(key);
1043
+ normalized.push({ key, direction });
1044
+ if (normalized.length >= SORT_KEYS.length) break;
1045
+ }
1046
+ if (normalized.length) return normalized;
1047
+
1048
+ // Accept the 0.3.x single-sort payload during upgrades.
1049
+ const legacy = options.sort;
1050
+ if (legacy === "stars") return [{ key: "stars", direction: "desc" }];
1051
+ if (legacy === "downloads") return [{ key: "downloads", direction: "desc" }];
1052
+ if (legacy === "freshness") return [{ key: "freshness", direction: "desc" }];
1053
+ if (legacy === "name") return [{ key: "name", direction: "asc" }];
1054
+ if (legacy === "stars-downloads") return [{ key: "stars", direction: "desc" }, { key: "downloads", direction: "desc" }];
1055
+ if (legacy === "downloads-freshness") return [{ key: "downloads", direction: "desc" }, { key: "freshness", direction: "desc" }];
1056
+ return [];
1057
+ }
1058
+
1059
+ function compareSortValue(a, b, key) {
1060
+ if (key === "stars") return (a.evidence?.stars ?? 0) - (b.evidence?.stars ?? 0);
1061
+ if (key === "downloads") return (a.evidence?.downloads30d ?? 0) - (b.evidence?.downloads30d ?? 0);
1062
+ if (key === "freshness") return freshnessTimestamp(a) - freshnessTimestamp(b);
1063
+ if (key === "name") return a.name.localeCompare(b.name);
1064
+ return 0;
1065
+ }
1066
+
1067
+ function sortPlugins(plugins, sorts) {
1068
+ if (!Array.isArray(sorts) || sorts.length === 0) return plugins;
1069
+ return [...plugins].sort((a, b) => {
1070
+ for (const criterion of sorts) {
1071
+ const compared = compareSortValue(a, b, criterion.key);
1072
+ if (compared !== 0) return criterion.direction === "asc" ? compared : -compared;
1073
+ }
1074
+ return a.name.localeCompare(b.name);
1075
+ });
1076
+ }
1077
+
1078
+
1079
+ function sourceDiscoveryQuery(source, query) {
1080
+ return source.type === "npm" || source.type === "github" ? "" : query;
1081
+ }
1082
+
1083
+ function discoveryCacheKey(sources, query) {
1084
+ return JSON.stringify({
1085
+ sources: sources.map((source) => ({ id: source.id, name: source.name, type: source.type, url: source.url ?? "", enabled: source.enabled !== false })),
1086
+ query: sources.some((source) => source.type !== "npm" && source.type !== "github")
1087
+ ? String(query ?? "").trim().toLowerCase()
1088
+ : "",
1089
+ });
1090
+ }
1091
+
1092
+ function cachedDiscoveryRows(key, refreshRevision = 0) {
1093
+ const entry = browseDiscoveryCache.get(key);
1094
+ if (!entry) return undefined;
1095
+ if (entry.expiresAt <= Date.now()) {
1096
+ browseDiscoveryCache.delete(key);
1097
+ return undefined;
1098
+ }
1099
+ if ((entry.refreshRevision ?? 0) < refreshRevision) return undefined;
1100
+ return entry.rows;
1101
+ }
1102
+
1103
+ function cacheDiscoveryRows(key, rows, refreshRevision = 0) {
1104
+ browseDiscoveryCache.set(key, { rows, refreshRevision, expiresAt: Date.now() + BROWSE_DISCOVERY_TTL_MS });
1105
+ }
1106
+
1107
+
1108
+ function registrySearchVariants(query) {
1109
+ const needle = text(query)?.toLowerCase();
1110
+ if (!needle) return [];
1111
+ const variants = [needle];
1112
+ const unscoped = needle.startsWith("@") && needle.includes("/") ? needle.slice(needle.indexOf("/") + 1) : needle;
1113
+ if (unscoped !== needle) variants.push(unscoped);
1114
+
1115
+ let shortened = unscoped;
1116
+ for (const prefix of ["dsh-client-ui-", "dsh-ui-", "dsh-plugin-"]) {
1117
+ if (shortened.startsWith(prefix) && shortened.length > prefix.length) {
1118
+ shortened = shortened.slice(prefix.length);
1119
+ variants.push(shortened);
1120
+ break;
1121
+ }
1122
+ }
1123
+ if (shortened.startsWith("dsh-") && shortened.length > 4) variants.push(shortened.slice(4));
1124
+ const parts = shortened.split("-").filter(Boolean);
1125
+ if (parts.length >= 2) variants.push(parts.slice(-2).join("-"));
1126
+ return [...new Set(variants.filter((value) => value.length >= 3))].slice(0, 3);
1127
+ }
1128
+
1129
+ function searchPluginsWithVariants(plugins, query) {
1130
+ const variants = registrySearchVariants(query);
1131
+ if (!variants.length) return plugins;
1132
+ const matches = new Set();
1133
+ for (const variant of variants) {
1134
+ for (const plugin of searchPlugins(plugins, variant)) matches.add(plugin);
1135
+ }
1136
+ return [...matches];
1137
+ }
1138
+
1139
+ function queryDiscoveryCacheKey(sources, query) {
1140
+ return `query:${JSON.stringify({
1141
+ sources: sources.map((source) => ({ id: source.id, type: source.type, url: source.url ?? "", enabled: source.enabled !== false })),
1142
+ query: String(query ?? "").trim().toLowerCase(),
1143
+ })}`;
1144
+ }
1145
+
1146
+ async function queryRegistrySources(sources, query, options, refreshRevision, useDiscoveryCache) {
1147
+ const registrySources = sources.filter((source) => source.type === "npm" || source.type === "github");
1148
+ if (!registrySources.length || !text(query)) return [];
1149
+ const cacheKey = queryDiscoveryCacheKey(registrySources, query);
1150
+ const cached = useDiscoveryCache ? cachedDiscoveryRows(cacheKey, refreshRevision) : undefined;
1151
+ if (cached) return cached;
1152
+
1153
+ const variants = registrySearchVariants(query);
1154
+ const rows = await mapConcurrent(registrySources, options.concurrency ?? 4, async (source) => {
1155
+ const started = Date.now();
1156
+ try {
1157
+ const sourceVariants = source.type === "github" ? variants : variants.slice(0, 1);
1158
+ const batches = await mapConcurrent(sourceVariants, Math.min(2, sourceVariants.length || 1), async (variant) => {
1159
+ try { return await createAdapter(source, options).search(variant); }
1160
+ catch { return []; }
1161
+ });
1162
+ return { source: sourceAttribution(source), health: { ok: true, latencyMs: Date.now() - started }, plugins: dedupePlugins(batches.flat()) };
1163
+ } catch (error) {
1164
+ return { source: sourceAttribution(source), health: { ok: false, latencyMs: Date.now() - started, error: String(error?.message ?? error) }, plugins: [] };
1165
+ }
1166
+ });
1167
+ if (useDiscoveryCache) cacheDiscoveryRows(cacheKey, rows, refreshRevision);
1168
+ return rows;
1169
+ }
1170
+
1171
+ export async function browseSources(sourceInputs, query = "", options = {}) {
1172
+ const enabled = validatedSources(sourceInputs, options).filter((source) => source.enabled);
1173
+ const refreshRevision = Number.isInteger(options.refreshRevision) && options.refreshRevision >= 0 ? options.refreshRevision : 0;
1174
+ const useDiscoveryCache = options.cacheDiscovery === true || (options.cacheDiscovery !== false && options.fetchImpl === undefined);
1175
+ const cacheKey = discoveryCacheKey(enabled, query);
1176
+ let rows = useDiscoveryCache ? cachedDiscoveryRows(cacheKey, refreshRevision) : undefined;
1177
+ if (!rows) {
1178
+ rows = await mapConcurrent(enabled, options.concurrency ?? 4, async (source) => {
1179
+ const started = Date.now();
1180
+ try {
1181
+ const plugins = await createAdapter(source, options).search(sourceDiscoveryQuery(source, query));
1182
+ return { source: sourceAttribution(source), health: { ok: true, latencyMs: Date.now() - started, count: plugins.length }, plugins };
1183
+ } catch (error) {
1184
+ return { source: sourceAttribution(source), health: { ok: false, latencyMs: Date.now() - started, error: String(error?.message ?? error) }, plugins: [] };
1185
+ }
1186
+ });
1187
+ if (useDiscoveryCache) cacheDiscoveryRows(cacheKey, rows, refreshRevision);
1188
+ }
1189
+
1190
+ let discovered = dedupePlugins(rows.flatMap((row) => row.plugins));
1191
+
1192
+ // Canonical discovery is intentionally query-independent and therefore can be
1193
+ // truncated by registry ranking. Merge query-specific registry discovery back in
1194
+ // so low-ranked but relevant plugins are still reachable.
1195
+ const queryRows = await queryRegistrySources(enabled, query, options, refreshRevision, useDiscoveryCache);
1196
+ discovered = dedupePlugins([...discovered, ...queryRows.flatMap((row) => row.plugins)]);
1197
+
1198
+ let matched = searchPluginsWithVariants(discovered, query).slice(0, options.maxTotalPlugins ?? 5_000);
1199
+ if (options.stableOnly === true) matched = matched.filter((plugin) => plugin.evidence?.releaseChannel === "stable");
1200
+ matched = applyBrowseFilters(matched, options);
1201
+
1202
+ // npm/GitHub search results are discovery candidates, not installable plugins.
1203
+ // DSH activates a profile layer only for packages declaring dsh.bundle.patch,
1204
+ // so verify the complete candidate set before totals, paging, or rendering.
1205
+ if (shouldVerifyRegistryBundles(options)) {
1206
+ matched = await enrichInstallability(matched, options);
1207
+ matched = matched.filter((plugin) => !requiresRegistryVerification(plugin) || plugin.evidence?.installability === "bundle");
1208
+ }
1209
+
1210
+ const requestedPageSize = Number(options.pageSize);
1211
+ const pageSize = [20, 50, 100].includes(requestedPageSize) ? requestedPageSize : 20;
1212
+ const total = matched.length;
1213
+ const pageCount = Math.max(1, Math.ceil(total / pageSize));
1214
+ const requestedPage = Number.isInteger(options.page) && options.page > 0 ? options.page : 1;
1215
+ const page = Math.min(requestedPage, pageCount);
1216
+ const sorts = normalizedSorts(options);
1217
+
1218
+ // Download ordering needs real download evidence before the page is sliced.
1219
+ // The npm bulk endpoint plus the evidence cache keeps subsequent sorts/pages fast.
1220
+ if (sorts.some((criterion) => criterion.key === "downloads")) {
1221
+ matched = await enrichNpmDownloads(matched, { ...options, maxEvidencePackages: Math.min(matched.length, 1000) });
1222
+ }
1223
+
1224
+ matched = sortPlugins(matched, sorts);
1225
+
1226
+ const offset = (page - 1) * pageSize;
1227
+ let plugins = matched.slice(offset, offset + pageSize);
1228
+ // When downloads are not a sort criterion, enrich only the visible page.
1229
+ if (!sorts.some((criterion) => criterion.key === "downloads")) {
1230
+ plugins = await enrichNpmDownloads(plugins, { ...options, maxEvidencePackages: pageSize });
1231
+ }
1232
+ return {
1233
+ plugins,
1234
+ total,
1235
+ page,
1236
+ pageSize,
1237
+ pageCount,
1238
+ sources: rows.map(({ source, health }) => ({ ...source, health })),
1239
+ generatedAt: new Date().toISOString(),
1240
+ };
1241
+ }