dsh-plugin-discovery 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,947 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import z from "@deepseek-ai/schemastery";
5
+ import { Service } from "@deepseek-ai/cordis";
6
+ import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
7
+ import { defineTool } from "@deepseek-ai/dsh-tools";
8
+ import { markRemoteMethod } from "./mark-remote.js";
9
+
10
+ const USER_AGENT = "dsh-plugin-discovery/1.1";
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+
13
+ const DEFAULT_CATALOG_GITEE_URL =
14
+ "https://gitee.com/hjhcos/dsh-plugin-discovery/raw/main/data/recommended.json";
15
+ const DEFAULT_CATALOG_GITHUB_URL =
16
+ "https://raw.githubusercontent.com/hjhcos/dsh-plugin-discovery/main/data/recommended.json";
17
+
18
+ function emptyCatalog(error) {
19
+ return {
20
+ version: 0,
21
+ updatedAt: "",
22
+ categories: [],
23
+ plugins: [],
24
+ origin: "none",
25
+ error: error ? String(error) : undefined,
26
+ };
27
+ }
28
+
29
+ function normalizeCatalog(data, origin = "bundled") {
30
+ if (!data || typeof data !== "object") return emptyCatalog("invalid catalog");
31
+ return {
32
+ version: Number(data.version) || 0,
33
+ updatedAt: data.updatedAt || "",
34
+ categories: Array.isArray(data.categories) ? data.categories : [],
35
+ plugins: Array.isArray(data.plugins) ? data.plugins : [],
36
+ origin,
37
+ };
38
+ }
39
+
40
+ function loadBundledCatalog() {
41
+ try {
42
+ const raw = readFileSync(join(__dirname, "data", "recommended.json"), "utf8");
43
+ return normalizeCatalog(JSON.parse(raw), "bundled");
44
+ } catch (error) {
45
+ return emptyCatalog(error?.message || error);
46
+ }
47
+ }
48
+
49
+ function resolveCatalogCachePath() {
50
+ const home = String(process.env.DSH_HOME || "").trim();
51
+ const base = home || join(__dirname, ".cache");
52
+ return join(base, "cache", "dsh-plugin-discovery", "recommended.json");
53
+ }
54
+
55
+ function loadCachedCatalog() {
56
+ try {
57
+ const raw = readFileSync(resolveCatalogCachePath(), "utf8");
58
+ return normalizeCatalog(JSON.parse(raw), "cache");
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ function saveCachedCatalog(catalog) {
65
+ const path = resolveCatalogCachePath();
66
+ mkdirSync(dirname(path), { recursive: true });
67
+ const payload = {
68
+ version: catalog.version,
69
+ updatedAt: catalog.updatedAt,
70
+ note: catalog.note,
71
+ categories: catalog.categories,
72
+ plugins: catalog.plugins,
73
+ _cachedAt: new Date().toISOString(),
74
+ _origin: catalog.origin,
75
+ };
76
+ writeFileSync(path, JSON.stringify(payload, null, 2), "utf8");
77
+ }
78
+
79
+ function pickNewerCatalog(...candidates) {
80
+ let best = null;
81
+ for (const row of candidates) {
82
+ if (!row || !Array.isArray(row.plugins)) continue;
83
+ if (!best || (row.version || 0) > (best.version || 0)) best = row;
84
+ else if ((row.version || 0) === (best.version || 0) && row.origin !== "bundled" && best.origin === "bundled") {
85
+ best = row;
86
+ }
87
+ }
88
+ return best || emptyCatalog();
89
+ }
90
+
91
+ async function fetchCatalogFromUrl(url, timeoutMs = 12000) {
92
+ const trimmed = String(url || "").trim();
93
+ if (!trimmed) return null;
94
+ const controller = new AbortController();
95
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
96
+ try {
97
+ const response = await fetch(trimmed, {
98
+ headers: {
99
+ Accept: "application/json,text/plain,*/*",
100
+ "User-Agent": USER_AGENT,
101
+ },
102
+ signal: controller.signal,
103
+ });
104
+ if (!response.ok) {
105
+ throw new Error(`HTTP ${response.status}`);
106
+ }
107
+ const text = await response.text();
108
+ const data = JSON.parse(text);
109
+ if (!Array.isArray(data.plugins)) {
110
+ throw new Error("catalog missing plugins[]");
111
+ }
112
+ const host = (() => {
113
+ try {
114
+ return new URL(trimmed).hostname;
115
+ } catch {
116
+ return "";
117
+ }
118
+ })();
119
+ const origin = /gitee\.com/i.test(host) ? "gitee" : /githubusercontent\.com|github\.com/i.test(host) ? "github" : "remote";
120
+ return normalizeCatalog(data, origin);
121
+ } finally {
122
+ clearTimeout(timer);
123
+ }
124
+ }
125
+
126
+ function mapCatalogPlugin(row) {
127
+ return {
128
+ source: row.source || "npm",
129
+ spec: row.spec,
130
+ installSpec: row.spec,
131
+ title: row.title || row.spec,
132
+ description: row.description || "",
133
+ url: row.url || `https://www.npmjs.com/package/${row.spec}`,
134
+ stars: null,
135
+ bundle: null,
136
+ categories: Array.isArray(row.categories) ? row.categories : [],
137
+ tags: Array.isArray(row.tags) ? row.tags : [],
138
+ featured: row.featured === true,
139
+ rank: typeof row.rank === "number" ? row.rank : 0,
140
+ related: Array.isArray(row.related) ? row.related : [],
141
+ catalogId: row.id || row.spec,
142
+ reason: row.reason || null,
143
+ };
144
+ }
145
+
146
+ function filterCatalogPlugins(catalog, { category, query, personalize, installedNames }) {
147
+ const installed = new Set(
148
+ (Array.isArray(installedNames) ? installedNames : []).map((name) => String(name || "").trim()).filter(Boolean),
149
+ );
150
+ const allPlugins = Array.isArray(catalog?.plugins) ? catalog.plugins : [];
151
+ let rows = allPlugins.slice();
152
+
153
+ if (category && category !== "all") {
154
+ if (category === "featured") {
155
+ rows = rows.filter((row) => row.featured === true || (row.categories || []).includes("featured"));
156
+ } else {
157
+ rows = rows.filter((row) => (row.categories || []).includes(category));
158
+ }
159
+ }
160
+
161
+ const q = String(query || "")
162
+ .trim()
163
+ .toLowerCase();
164
+ if (q) {
165
+ rows = rows.filter((row) => {
166
+ const hay = [row.spec, row.title, row.description, ...(row.tags || []), ...(row.categories || [])]
167
+ .join(" ")
168
+ .toLowerCase();
169
+ return hay.includes(q);
170
+ });
171
+ }
172
+
173
+ if (personalize) {
174
+ const categoryScore = new Map();
175
+ const installedSpecs = new Set();
176
+ for (const row of allPlugins) {
177
+ if (!installed.has(row.spec) && !installed.has(row.title) && !installed.has(row.id)) continue;
178
+ installedSpecs.add(row.spec);
179
+ for (const cat of row.categories || []) {
180
+ if (cat === "featured") continue;
181
+ categoryScore.set(cat, (categoryScore.get(cat) || 0) + 2);
182
+ }
183
+ for (const rel of row.related || []) {
184
+ categoryScore.set(`rel:${rel}`, (categoryScore.get(`rel:${rel}`) || 0) + 5);
185
+ }
186
+ }
187
+
188
+ const scored = rows
189
+ .filter((row) => !installed.has(row.spec) && !installedSpecs.has(row.spec))
190
+ .map((row) => {
191
+ let score = (row.rank || 0) + (row.featured ? 12 : 0);
192
+ const reasons = [];
193
+ for (const cat of row.categories || []) {
194
+ if (cat === "featured") continue;
195
+ const boost = categoryScore.get(cat) || 0;
196
+ if (boost) {
197
+ score += boost * 3;
198
+ reasons.push(cat);
199
+ }
200
+ }
201
+ const relBoost = categoryScore.get(`rel:${row.spec}`) || 0;
202
+ if (relBoost) {
203
+ score += relBoost;
204
+ reasons.push("related");
205
+ }
206
+ if (!reasons.length && row.featured) reasons.push("featured");
207
+ return { ...row, reason: reasons[0] || (row.featured ? "featured" : "popular"), _score: score };
208
+ });
209
+
210
+ scored.sort((a, b) => b._score - a._score || String(a.spec).localeCompare(String(b.spec)));
211
+ return scored.map(({ _score, ...rest }) => rest);
212
+ }
213
+
214
+ rows.sort((a, b) => (b.rank || 0) - (a.rank || 0) || String(a.spec).localeCompare(String(b.spec)));
215
+ return rows;
216
+ }
217
+
218
+ function githubHeaders(token) {
219
+ const headers = {
220
+ Accept: "application/vnd.github+json",
221
+ "User-Agent": USER_AGENT,
222
+ "X-GitHub-Api-Version": "2022-11-28",
223
+ };
224
+ if (token?.trim()) headers.Authorization = `Bearer ${token.trim()}`;
225
+ return headers;
226
+ }
227
+
228
+ function parseGithubRepoUrl(input) {
229
+ const trimmed = input.trim();
230
+ try {
231
+ const url = new URL(trimmed);
232
+ if (!/github\.com$/i.test(url.hostname.replace(/^www\./, ""))) return null;
233
+ const parts = url.pathname.split("/").filter(Boolean);
234
+ if (parts.length < 2) return null;
235
+ const owner = parts[0];
236
+ const repo = parts[1].replace(/\.git$/, "");
237
+ return { spec: `github:${owner}/${repo}`, owner, repo };
238
+ } catch {
239
+ return null;
240
+ }
241
+ }
242
+
243
+ function parseNpmPackageUrl(input) {
244
+ const trimmed = input.trim();
245
+ if (/^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/i.test(trimmed)) {
246
+ return { spec: trimmed };
247
+ }
248
+ try {
249
+ const url = new URL(trimmed);
250
+ if (!/npmjs\.com$/i.test(url.hostname.replace(/^www\./, ""))) return null;
251
+ const match = url.pathname.match(/^\/package\/((?:@[^/]+\/)?[^/]+)/);
252
+ if (!match) return null;
253
+ return { spec: decodeURIComponent(match[1]) };
254
+ } catch {
255
+ return null;
256
+ }
257
+ }
258
+
259
+ function parseGithubShortRepo(input) {
260
+ const trimmed = input.trim();
261
+ const match = trimmed.match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/);
262
+ if (!match) return null;
263
+ return { spec: `github:${match[1]}/${match[2]}`, owner: match[1], repo: match[2] };
264
+ }
265
+
266
+ function resolveInputToSpec(input) {
267
+ const trimmed = input.trim();
268
+ if (!trimmed) return null;
269
+ if (/^(github:|gitlab:|bitbucket:)/i.test(trimmed)) return { spec: trimmed, source: "git" };
270
+ const gh = parseGithubRepoUrl(trimmed);
271
+ if (gh) return { ...gh, source: "github" };
272
+ const short = parseGithubShortRepo(trimmed);
273
+ if (short) return { ...short, source: "github" };
274
+ const npm = parseNpmPackageUrl(trimmed);
275
+ if (npm) return { ...npm, source: "npm" };
276
+ return null;
277
+ }
278
+
279
+ async function fetchJson(url, init = {}) {
280
+ const response = await fetch(url, init);
281
+ if (!response.ok) {
282
+ const text = await response.text().catch(() => "");
283
+ throw new Error(`HTTP ${response.status}: ${text.slice(0, 200)}`);
284
+ }
285
+ return response.json();
286
+ }
287
+
288
+ async function searchNpm(query, limit) {
289
+ const params = new URLSearchParams({
290
+ text: `${query} dsh`,
291
+ size: String(Math.min(limit, 50)),
292
+ });
293
+ const data = await fetchJson(`https://registry.npmjs.org/-/v1/search?${params}`);
294
+ const items = Array.isArray(data.objects) ? data.objects : [];
295
+ return items.map((row) => {
296
+ const pkg = row.package || {};
297
+ return {
298
+ source: "npm",
299
+ spec: pkg.name,
300
+ title: pkg.name,
301
+ description: pkg.description || "",
302
+ url: pkg.links?.npm || `https://www.npmjs.com/package/${pkg.name}`,
303
+ stars: null,
304
+ };
305
+ });
306
+ }
307
+
308
+ function mapGithubRepos(items) {
309
+ return (Array.isArray(items) ? items : []).map((repo) => ({
310
+ source: "github",
311
+ spec: `github:${repo.full_name}`,
312
+ title: repo.full_name,
313
+ description: repo.description || "",
314
+ url: repo.html_url,
315
+ stars: repo.stargazers_count ?? null,
316
+ }));
317
+ }
318
+
319
+ async function githubSearchOnce(q, limit, token) {
320
+ const params = new URLSearchParams({
321
+ q,
322
+ sort: "updated",
323
+ order: "desc",
324
+ per_page: String(Math.min(limit, 30)),
325
+ });
326
+ const data = await fetchJson(`https://api.github.com/search/repositories?${params}`, {
327
+ headers: githubHeaders(token),
328
+ });
329
+ return mapGithubRepos(data.items);
330
+ }
331
+
332
+ async function searchGithub(query, limit, token) {
333
+ const trimmed = String(query || "").trim();
334
+ // 与 GitHub 网页搜索对齐:先用原关键词;再用 topic 收窄社区插件
335
+ const queries = trimmed
336
+ ? [trimmed, `${trimmed} topic:dsh-plugin`, `${trimmed} topic:dsh`]
337
+ : ["topic:dsh-plugin", "topic:dsh"];
338
+ const seen = new Set();
339
+ const merged = [];
340
+ let lastError = null;
341
+ for (const q of queries) {
342
+ if (merged.length >= limit) break;
343
+ try {
344
+ const batch = await githubSearchOnce(q, limit, token);
345
+ for (const row of batch) {
346
+ if (seen.has(row.spec)) continue;
347
+ seen.add(row.spec);
348
+ merged.push(row);
349
+ if (merged.length >= limit) break;
350
+ }
351
+ } catch (error) {
352
+ lastError = error;
353
+ // 无 token 时未认证配额很低;已有结果则返回,否则抛出给上层提示
354
+ if (!merged.length && /rate limit|API rate limit/i.test(String(error && error.message))) {
355
+ throw new Error("GitHub API 速率受限,请在插件配置里填写 githubToken,或稍后再试");
356
+ }
357
+ if (!merged.length) continue;
358
+ break;
359
+ }
360
+ }
361
+ if (!merged.length && lastError) throw lastError;
362
+ return merged;
363
+ }
364
+
365
+ function cacheKey(query, source) {
366
+ return `${source}:${query.trim().toLowerCase()}`;
367
+ }
368
+
369
+ class PluginDiscoveryService extends TypertRemoteService {
370
+ static inject = ["tools", "pluginManager"];
371
+ static Config = z.object({
372
+ enabled: z.boolean().default(true),
373
+ searchCacheTtlMs: z.number().min(0).max(86400000).default(300000),
374
+ maxCandidates: z.number().min(1).max(50).default(20),
375
+ maxInspect: z.number().min(0).max(30).default(10),
376
+ githubToken: z.string().default(""),
377
+ openPanelOnLoad: z.boolean().default(false),
378
+ /** 是否从远程 raw JSON 自动刷新推荐目录(优先 Gitee) */
379
+ catalogRemoteEnabled: z.boolean().default(true),
380
+ catalogGiteeUrl: z.string().default(DEFAULT_CATALOG_GITEE_URL),
381
+ catalogGithubUrl: z.string().default(DEFAULT_CATALOG_GITHUB_URL),
382
+ /** 远程检查间隔;期间复用内存/磁盘缓存 */
383
+ catalogRefreshTtlMs: z.number().min(0).max(86400000 * 7).default(3600000),
384
+ });
385
+
386
+ constructor(ctx, config) {
387
+ super(ctx, "pluginDiscovery");
388
+ this.config = config;
389
+ this.cache = new Map();
390
+ this.catalog = pickNewerCatalog(loadBundledCatalog(), loadCachedCatalog());
391
+ this.catalogCheckedAt = 0;
392
+ this.catalogRefreshPromise = null;
393
+ this.ctx.effect(() => this.registerTools());
394
+ // 启动后后台拉一次,不阻塞启动
395
+ this.ctx.effect(() => {
396
+ void this.refreshCatalog({ force: false }).catch(() => {});
397
+ return () => {};
398
+ });
399
+ }
400
+
401
+ getCatalog() {
402
+ return this.catalog || loadBundledCatalog();
403
+ }
404
+
405
+ catalogMeta() {
406
+ const catalog = this.getCatalog();
407
+ return {
408
+ version: catalog.version || 0,
409
+ updatedAt: catalog.updatedAt || "",
410
+ origin: catalog.origin || "bundled",
411
+ checkedAt: this.catalogCheckedAt || null,
412
+ };
413
+ }
414
+
415
+ remoteCatalogUrls() {
416
+ const urls = [];
417
+ const gitee = String(this.config.catalogGiteeUrl || "").trim();
418
+ const github = String(this.config.catalogGithubUrl || "").trim();
419
+ if (gitee) urls.push({ url: gitee, label: "gitee" });
420
+ if (github) urls.push({ url: github, label: "github" });
421
+ return urls;
422
+ }
423
+
424
+ async refreshCatalog(options = {}) {
425
+ const force = options.force === true;
426
+ if (!this.config.catalogRemoteEnabled) {
427
+ return { ok: true, skipped: true, reason: "remote disabled", catalog: this.catalogMeta() };
428
+ }
429
+ const ttl = this.config.catalogRefreshTtlMs ?? 3600000;
430
+ if (!force && this.catalogCheckedAt && Date.now() - this.catalogCheckedAt < ttl) {
431
+ return { ok: true, skipped: true, reason: "ttl", catalog: this.catalogMeta() };
432
+ }
433
+ if (this.catalogRefreshPromise) return this.catalogRefreshPromise;
434
+
435
+ this.catalogRefreshPromise = (async () => {
436
+ const errors = [];
437
+ let remote = null;
438
+ for (const entry of this.remoteCatalogUrls()) {
439
+ try {
440
+ remote = await fetchCatalogFromUrl(entry.url);
441
+ if (remote) {
442
+ remote.origin = entry.label || remote.origin;
443
+ break;
444
+ }
445
+ } catch (error) {
446
+ errors.push(`${entry.label}: ${error instanceof Error ? error.message : String(error)}`);
447
+ }
448
+ }
449
+ this.catalogCheckedAt = Date.now();
450
+ if (!remote) {
451
+ return {
452
+ ok: false,
453
+ message: errors.join("; ") || "remote catalog unavailable",
454
+ catalog: this.catalogMeta(),
455
+ };
456
+ }
457
+ const current = this.getCatalog();
458
+ if ((remote.version || 0) > (current.version || 0)) {
459
+ this.catalog = remote;
460
+ try {
461
+ saveCachedCatalog(remote);
462
+ } catch (error) {
463
+ this.ctx.logger?.warn?.("[plugin-discovery] save catalog cache failed", error);
464
+ }
465
+ this.ctx.logger?.info?.(
466
+ `[plugin-discovery] catalog updated ${current.version} -> ${remote.version} via ${remote.origin}`,
467
+ );
468
+ return {
469
+ ok: true,
470
+ updated: true,
471
+ from: current.version,
472
+ to: remote.version,
473
+ origin: remote.origin,
474
+ catalog: this.catalogMeta(),
475
+ };
476
+ }
477
+ // 同版本也刷新缓存时间戳,便于离线复用最近一次成功源
478
+ if ((remote.version || 0) === (current.version || 0) && remote.origin !== "bundled") {
479
+ this.catalog = { ...current, origin: remote.origin, updatedAt: remote.updatedAt || current.updatedAt };
480
+ try {
481
+ saveCachedCatalog(this.catalog);
482
+ } catch {
483
+ /* ignore */
484
+ }
485
+ }
486
+ return {
487
+ ok: true,
488
+ updated: false,
489
+ origin: remote.origin,
490
+ catalog: this.catalogMeta(),
491
+ };
492
+ })().finally(() => {
493
+ this.catalogRefreshPromise = null;
494
+ });
495
+
496
+ return this.catalogRefreshPromise;
497
+ }
498
+
499
+ getFromCache(key) {
500
+ const hit = this.cache.get(key);
501
+ if (!hit) return null;
502
+ if (Date.now() - hit.at > this.config.searchCacheTtlMs) {
503
+ this.cache.delete(key);
504
+ return null;
505
+ }
506
+ return hit.payload;
507
+ }
508
+
509
+ setCache(key, payload) {
510
+ this.cache.set(key, { at: Date.now(), payload });
511
+ }
512
+
513
+ async inspectCandidate(spec, signal) {
514
+ try {
515
+ const inspection = await this.ctx.pluginManager.inspect(spec, {}, signal);
516
+ if (inspection.status !== "accepted") {
517
+ return {
518
+ spec,
519
+ accepted: false,
520
+ bundle: false,
521
+ reason: inspection.status === "refused" ? inspection.reason : "refused",
522
+ };
523
+ }
524
+ return {
525
+ spec,
526
+ accepted: true,
527
+ bundle: inspection.bundle === true,
528
+ name: inspection.name,
529
+ version: inspection.version,
530
+ description: inspection.description,
531
+ };
532
+ } catch (error) {
533
+ return {
534
+ spec,
535
+ accepted: false,
536
+ bundle: false,
537
+ reason: error instanceof Error ? error.message : String(error),
538
+ };
539
+ }
540
+ }
541
+
542
+ async enrichCandidates(candidates, signal) {
543
+ const limit = this.config.maxInspect;
544
+ const slice = candidates.slice(0, limit);
545
+ const verified = [];
546
+ for (const row of slice) {
547
+ if (signal?.aborted) break;
548
+ const meta = await this.inspectCandidate(row.spec, signal);
549
+ if (meta.bundle) {
550
+ verified.push({
551
+ ...row,
552
+ ...meta,
553
+ installSpec: row.spec,
554
+ });
555
+ }
556
+ }
557
+ return verified;
558
+ }
559
+
560
+ async listCatalog(options) {
561
+ if (!this.config.enabled) {
562
+ return { ok: false, message: "发现插件已停用", categories: [], results: [], total: 0 };
563
+ }
564
+ const opts = options && typeof options === "object" ? options : {};
565
+ const mode = opts.mode === "category" || opts.mode === "all" ? opts.mode : "recommend";
566
+ const category = typeof opts.category === "string" ? opts.category.trim() : "";
567
+ const query = typeof opts.query === "string" ? opts.query : "";
568
+ let installedNames = Array.isArray(opts.installedNames) ? opts.installedNames : null;
569
+ if (!installedNames) {
570
+ const listed = await this.listInstalled();
571
+ installedNames = listed.ok ? listed.names : [];
572
+ }
573
+
574
+ // 打开推荐/分类时尝试按 TTL 拉远程;失败静默回退本地
575
+ await this.refreshCatalog({ force: opts.refresh === true }).catch(() => {});
576
+
577
+ const catalog = this.getCatalog();
578
+ const personalize = mode === "recommend";
579
+ const effectiveCategory = mode === "category" ? category || "featured" : mode === "all" ? "" : "";
580
+ const rows = filterCatalogPlugins(catalog, {
581
+ category: effectiveCategory,
582
+ query,
583
+ personalize,
584
+ installedNames,
585
+ });
586
+ const results = rows.map(mapCatalogPlugin);
587
+ return {
588
+ ok: true,
589
+ mode: personalize ? "recommend" : mode === "all" ? "all" : "category",
590
+ category: effectiveCategory || null,
591
+ updatedAt: catalog.updatedAt,
592
+ catalogVersion: catalog.version || 0,
593
+ catalogOrigin: catalog.origin || "bundled",
594
+ categories: catalog.categories,
595
+ results,
596
+ total: results.length,
597
+ };
598
+ }
599
+
600
+ async search(query, options) {
601
+ if (!this.config.enabled) {
602
+ return { ok: false, message: "发现插件已停用", results: [], total: 0 };
603
+ }
604
+ const opts = options && typeof options === "object" ? options : {};
605
+ const q = String(query || "").trim() || "dsh";
606
+ const source =
607
+ opts.source === "npm" || opts.source === "github" || opts.source === "catalog"
608
+ ? opts.source
609
+ : "all";
610
+ const verify = opts.verify === true;
611
+
612
+ if (source === "catalog") {
613
+ await this.refreshCatalog({ force: false }).catch(() => {});
614
+ const listed = await this.listInstalled();
615
+ const catalog = this.getCatalog();
616
+ const rows = filterCatalogPlugins(catalog, {
617
+ category: opts.category || "",
618
+ query: q === "dsh" ? "" : q,
619
+ personalize: opts.personalize === true,
620
+ installedNames: listed.ok ? listed.names : [],
621
+ });
622
+ const results = rows.map(mapCatalogPlugin);
623
+ return {
624
+ ok: true,
625
+ cached: false,
626
+ query: q,
627
+ source,
628
+ verify: false,
629
+ results,
630
+ total: results.length,
631
+ catalogVersion: catalog.version || 0,
632
+ catalogOrigin: catalog.origin || "bundled",
633
+ };
634
+ }
635
+
636
+ const key = `${cacheKey(q, source)}:v${verify ? 1 : 0}`;
637
+ const cached = this.getFromCache(key);
638
+ if (cached && !opts.skipCache) {
639
+ return { ok: true, cached: true, query: q, source, verify, results: cached, total: cached.length };
640
+ }
641
+
642
+ const limit = this.config.maxCandidates;
643
+ const token = opts.githubToken ?? this.config.githubToken;
644
+ let raw = [];
645
+ try {
646
+ if (source === "npm" || source === "all") {
647
+ raw = raw.concat(await searchNpm(q, limit));
648
+ }
649
+ if (source === "github" || source === "all") {
650
+ raw = raw.concat(await searchGithub(q, limit, token));
651
+ }
652
+ } catch (error) {
653
+ this.ctx.logger.warn("[plugin-discovery] search failed", error);
654
+ return {
655
+ ok: false,
656
+ message: error instanceof Error ? error.message : String(error),
657
+ results: [],
658
+ total: 0,
659
+ };
660
+ }
661
+
662
+ const seen = new Set();
663
+ const deduped = [];
664
+ for (const row of raw) {
665
+ if (!row.spec || seen.has(row.spec)) continue;
666
+ seen.add(row.spec);
667
+ deduped.push({
668
+ ...row,
669
+ installSpec: row.spec,
670
+ bundle: null,
671
+ });
672
+ if (deduped.length >= limit) break;
673
+ }
674
+
675
+ let results = deduped;
676
+ if (verify) {
677
+ const controller = new AbortController();
678
+ const timeout = setTimeout(() => controller.abort(), 120000);
679
+ try {
680
+ results = await this.enrichCandidates(deduped, controller.signal);
681
+ } finally {
682
+ clearTimeout(timeout);
683
+ }
684
+ }
685
+
686
+ this.setCache(key, results);
687
+ return { ok: true, cached: false, query: q, source, verify, results, total: results.length };
688
+ }
689
+
690
+ async resolve(input) {
691
+ if (!this.config.enabled) {
692
+ return { ok: false, message: "发现插件已停用" };
693
+ }
694
+ const parsed = resolveInputToSpec(input);
695
+ if (!parsed?.spec) {
696
+ return {
697
+ ok: false,
698
+ message:
699
+ "解析安装需要完整链接或包名(如 https://github.com/user/repo、user/repo、npm 包名)。关键词请用上方「搜索」。",
700
+ };
701
+ }
702
+ const controller = new AbortController();
703
+ const timeout = setTimeout(() => controller.abort(), 90000);
704
+ let meta;
705
+ try {
706
+ meta = await this.inspectCandidate(parsed.spec, controller.signal);
707
+ } finally {
708
+ clearTimeout(timeout);
709
+ }
710
+ return {
711
+ ok: meta.bundle,
712
+ spec: parsed.spec,
713
+ source: parsed.source,
714
+ inspection: meta,
715
+ message: meta.bundle ? "可安装的 DSH Bundle" : meta.reason || "不是 DSH Bundle 或 inspect 失败",
716
+ };
717
+ }
718
+
719
+ async listInstalled() {
720
+ if (!this.config.enabled) {
721
+ return { ok: false, message: "发现插件已停用", names: [], bundles: [] };
722
+ }
723
+ try {
724
+ const [bundles, plugins] = await Promise.all([
725
+ this.ctx.pluginManager.listBundles(),
726
+ this.ctx.pluginManager.listPlugins(),
727
+ ]);
728
+ const rows = (Array.isArray(bundles) ? bundles : []).map((bundle) => ({
729
+ name: bundle.name,
730
+ version: bundle.version,
731
+ enabled: bundle.enabled === true,
732
+ installed: bundle.installed === true,
733
+ }));
734
+ const names = new Set(rows.map((row) => row.name).filter(Boolean));
735
+ for (const plugin of Array.isArray(plugins) ? plugins : []) {
736
+ const moduleName = plugin && plugin.moduleName;
737
+ if (!moduleName) continue;
738
+ names.add(moduleName);
739
+ }
740
+ return {
741
+ ok: true,
742
+ names: [...names],
743
+ bundles: rows,
744
+ };
745
+ } catch (error) {
746
+ return {
747
+ ok: false,
748
+ message: error instanceof Error ? error.message : String(error),
749
+ names: [],
750
+ bundles: [],
751
+ };
752
+ }
753
+ }
754
+
755
+ async findExistingBundleName(candidates) {
756
+ const listed = await this.listInstalled();
757
+ if (!listed.ok) return { existing: null, listed };
758
+ const names = new Set(listed.names);
759
+ // 仅精确匹配包名/spec,避免 dsh-pet 与 @scope/dsh-pet 互相误判为已安装
760
+ for (const candidate of candidates) {
761
+ if (!candidate) continue;
762
+ if (names.has(candidate)) return { existing: candidate, listed };
763
+ }
764
+ return { existing: null, listed };
765
+ }
766
+
767
+ async installPlugin(spec, options) {
768
+ if (!this.config.enabled) {
769
+ return { ok: false, message: "发现插件已停用" };
770
+ }
771
+ const trimmed = String(spec || "").trim();
772
+ if (!trimmed) {
773
+ return { ok: false, message: "安装 spec 为空" };
774
+ }
775
+ const opts = options && typeof options === "object" ? options : {};
776
+ const requestId =
777
+ typeof opts.requestId === "string" && opts.requestId.trim()
778
+ ? opts.requestId.trim()
779
+ : globalThis.crypto?.randomUUID?.() || `pd-${Date.now()}-${Math.random().toString(16).slice(2)}`;
780
+ const pre = await this.inspectCandidate(trimmed);
781
+ if (!pre.bundle) {
782
+ return { ok: false, requestId, message: pre.reason || "该 spec 不是可安装的 DSH Bundle" };
783
+ }
784
+ const { existing } = await this.findExistingBundleName([pre.name, trimmed]);
785
+ if (existing) {
786
+ return {
787
+ ok: false,
788
+ alreadyInstalled: true,
789
+ packageName: existing,
790
+ requestId,
791
+ message: `「${existing}」已在 DSH 插件列表中,无需重复安装。请到「插件」页查看。`,
792
+ };
793
+ }
794
+ try {
795
+ const result = await this.ctx.pluginManager.installBundle(trimmed, {
796
+ requestId,
797
+ enabled: true,
798
+ });
799
+ const packageName = result?.bundle || pre.name || trimmed;
800
+ const application = result?.application;
801
+ const failed = application === "failed" || application === "cancelled";
802
+ return {
803
+ ok: !failed,
804
+ alreadyInstalled: false,
805
+ packageName,
806
+ requestId,
807
+ application,
808
+ stage: result?.stage,
809
+ message: failed
810
+ ? result?.error?.message || `安装失败(${application || "unknown"})`
811
+ : `「${packageName}」安装完成(${application || "applied"} / ${result?.stage || "install"})`,
812
+ result,
813
+ };
814
+ } catch (error) {
815
+ return {
816
+ ok: false,
817
+ requestId,
818
+ message: error instanceof Error ? error.message : String(error),
819
+ };
820
+ }
821
+ }
822
+
823
+ async waitInstall(requestId) {
824
+ const id = String(requestId || "").trim();
825
+ if (!id) return { ok: false, message: "requestId 为空", settled: null };
826
+ try {
827
+ const settled = await this.ctx.pluginManager.waitForInstall(id);
828
+ if (settled == null) {
829
+ return { ok: true, settled: null, pending: true, message: "安装仍在进行或已丢失" };
830
+ }
831
+ const failed = settled.application === "failed" || settled.application === "cancelled";
832
+ return {
833
+ ok: !failed,
834
+ settled,
835
+ pending: false,
836
+ packageName: settled.bundle,
837
+ application: settled.application,
838
+ stage: settled.stage,
839
+ message: failed
840
+ ? settled.error?.message || `安装失败(${settled.application})`
841
+ : `安装完成(${settled.application} / ${settled.stage})`,
842
+ };
843
+ } catch (error) {
844
+ return {
845
+ ok: false,
846
+ settled: null,
847
+ message: error instanceof Error ? error.message : String(error),
848
+ };
849
+ }
850
+ }
851
+
852
+ registerTools() {
853
+ const service = this;
854
+ const disposers = [];
855
+ const present = (title) => ({ title, kind: "read" });
856
+ const toolOutput = {
857
+ schema: { type: "object", additionalProperties: true },
858
+ render: (_args, value) => [{ type: "text", text: JSON.stringify(value, null, 2) }],
859
+ };
860
+ disposers.push(
861
+ this.ctx.tools.register(
862
+ defineTool({
863
+ name: "plugin_discovery_search",
864
+ description:
865
+ "Search GitHub and npm for community DSH plugin bundles (online only). Returns verified bundle install specs. Does not install.",
866
+ parameters: {
867
+ query: { type: "string", required: true, description: "Search keywords" },
868
+ source: {
869
+ type: "string",
870
+ enum: ["all", "npm", "github", "catalog"],
871
+ description: "Limit search source",
872
+ },
873
+ },
874
+ output: toolOutput,
875
+ async execute(args) {
876
+ return service.search(args.query, { source: args.source || "all" });
877
+ },
878
+ presentCall: (_args, partial) =>
879
+ present(`Search DSH plugins: ${partial?.query ?? "…"}`),
880
+ }),
881
+ ),
882
+ );
883
+ disposers.push(
884
+ this.ctx.tools.register(
885
+ defineTool({
886
+ name: "plugin_discovery_catalog",
887
+ description:
888
+ "List curated DSH plugin recommendations and categories bundled with dsh-plugin-discovery.",
889
+ parameters: {
890
+ mode: {
891
+ type: "string",
892
+ enum: ["recommend", "category", "all"],
893
+ description: "recommend = personalized; category = filter by category; all = full catalog",
894
+ },
895
+ category: {
896
+ type: "string",
897
+ description: "Category id when mode=category (pet, theme, im, memory, ...)",
898
+ },
899
+ },
900
+ output: toolOutput,
901
+ async execute(args) {
902
+ return service.listCatalog({
903
+ mode: args.mode || "recommend",
904
+ category: args.category || "",
905
+ });
906
+ },
907
+ presentCall: (_args, partial) =>
908
+ present(`DSH plugin catalog: ${partial?.mode || "recommend"}`),
909
+ }),
910
+ ),
911
+ );
912
+ disposers.push(
913
+ this.ctx.tools.register(
914
+ defineTool({
915
+ name: "plugin_discovery_resolve",
916
+ description: "Resolve an npm or GitHub URL/package name and inspect whether it is a DSH bundle.",
917
+ parameters: {
918
+ input: {
919
+ type: "string",
920
+ required: true,
921
+ description: "npm URL, GitHub repo URL, or package name",
922
+ },
923
+ },
924
+ output: toolOutput,
925
+ async execute(args) {
926
+ return service.resolve(args.input);
927
+ },
928
+ presentCall: () => present("Resolve plugin URL"),
929
+ }),
930
+ ),
931
+ );
932
+ return () => {
933
+ for (const dispose of disposers.reverse()) dispose();
934
+ };
935
+ }
936
+
937
+ async [Service.init]() {}
938
+ }
939
+
940
+ markRemoteMethod(PluginDiscoveryService.prototype, "search");
941
+ markRemoteMethod(PluginDiscoveryService.prototype, "listCatalog");
942
+ markRemoteMethod(PluginDiscoveryService.prototype, "resolve");
943
+ markRemoteMethod(PluginDiscoveryService.prototype, "installPlugin");
944
+ markRemoteMethod(PluginDiscoveryService.prototype, "listInstalled");
945
+ markRemoteMethod(PluginDiscoveryService.prototype, "waitInstall");
946
+
947
+ export default PluginDiscoveryService;