dsh-plugin-discovery 1.1.1 → 1.2.4

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 CHANGED
@@ -14,6 +14,90 @@ const DEFAULT_CATALOG_GITEE_URL =
14
14
  "https://gitee.com/hjhcos/dsh-plugin-discovery/raw/main/data/recommended.json";
15
15
  const DEFAULT_CATALOG_GITHUB_URL =
16
16
  "https://raw.githubusercontent.com/hjhcos/dsh-plugin-discovery/main/data/recommended.json";
17
+ const SELF_PACKAGE_NAME = "dsh-plugin-discovery";
18
+ const DEFAULT_SELF_PACKAGE_GITEE_URL =
19
+ "https://gitee.com/hjhcos/dsh-plugin-discovery/raw/main/package.json";
20
+ const DEFAULT_SELF_PACKAGE_GITHUB_URL =
21
+ "https://raw.githubusercontent.com/hjhcos/dsh-plugin-discovery/main/package.json";
22
+
23
+ function readLocalPackageMeta() {
24
+ try {
25
+ const raw = readFileSync(join(__dirname, "package.json"), "utf8");
26
+ const data = JSON.parse(raw);
27
+ return {
28
+ name: data.name || SELF_PACKAGE_NAME,
29
+ version: String(data.version || "0.0.0"),
30
+ };
31
+ } catch {
32
+ return { name: SELF_PACKAGE_NAME, version: "0.0.0" };
33
+ }
34
+ }
35
+
36
+ function parseSemverParts(version) {
37
+ const cleaned = String(version || "0")
38
+ .trim()
39
+ .replace(/^v/i, "")
40
+ .split("-")[0]
41
+ .split("+")[0];
42
+ return cleaned.split(".").map((part) => {
43
+ const n = Number.parseInt(part, 10);
44
+ return Number.isFinite(n) ? n : 0;
45
+ });
46
+ }
47
+
48
+ /** @returns {number} 1 if a>b, -1 if a<b, 0 if equal */
49
+ function compareSemver(a, b) {
50
+ const pa = parseSemverParts(a);
51
+ const pb = parseSemverParts(b);
52
+ const len = Math.max(pa.length, pb.length);
53
+ for (let i = 0; i < len; i += 1) {
54
+ const x = pa[i] || 0;
55
+ const y = pb[i] || 0;
56
+ if (x > y) return 1;
57
+ if (x < y) return -1;
58
+ }
59
+ return 0;
60
+ }
61
+
62
+ async function fetchJsonWithTimeout(url, timeoutMs = 12000) {
63
+ const trimmed = String(url || "").trim();
64
+ if (!trimmed) return null;
65
+ const controller = new AbortController();
66
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
67
+ try {
68
+ const response = await fetch(trimmed, {
69
+ headers: {
70
+ Accept: "application/json,text/plain,*/*",
71
+ "User-Agent": USER_AGENT,
72
+ },
73
+ signal: controller.signal,
74
+ });
75
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
76
+ return await response.json();
77
+ } finally {
78
+ clearTimeout(timer);
79
+ }
80
+ }
81
+
82
+ async function fetchLatestSelfVersion(urls) {
83
+ const errors = [];
84
+ for (const entry of urls) {
85
+ try {
86
+ if (entry.kind === "npm") {
87
+ const data = await fetchJsonWithTimeout(entry.url);
88
+ const version = data?.version || data?.["dist-tags"]?.latest;
89
+ if (version) return { version: String(version), origin: "npm", url: entry.url };
90
+ } else {
91
+ const data = await fetchJsonWithTimeout(entry.url);
92
+ const version = data?.version;
93
+ if (version) return { version: String(version), origin: entry.label || "remote", url: entry.url };
94
+ }
95
+ } catch (error) {
96
+ errors.push(`${entry.label || entry.kind}: ${error instanceof Error ? error.message : String(error)}`);
97
+ }
98
+ }
99
+ return { version: null, origin: null, url: null, error: errors.join("; ") || "latest version unavailable" };
100
+ }
17
101
 
18
102
  function emptyCatalog(error) {
19
103
  return {
@@ -305,6 +389,171 @@ async function searchNpm(query, limit) {
305
389
  });
306
390
  }
307
391
 
392
+ /** 在线搜索时把精选目录命中置顶,避免同名冷门包(如 @dsh-ssh/dsh-ssh)盖过已验证的 @linxin666/dsh-ssh。 */
393
+ function catalogHitsForQuery(catalog, query, limit) {
394
+ const rows = filterCatalogPlugins(catalog, {
395
+ category: "",
396
+ query,
397
+ personalize: false,
398
+ installedNames: [],
399
+ });
400
+ return rows.slice(0, Math.max(0, limit)).map((row) => ({
401
+ ...mapCatalogPlugin(row),
402
+ reason: "catalog",
403
+ }));
404
+ }
405
+
406
+ function suggestCatalogAlternatives(catalog, failedSpec, limit = 3) {
407
+ const failed = String(failedSpec || "").trim().toLowerCase();
408
+ if (!failed) return [];
409
+ const tokens = failed
410
+ .replace(/^@/, "")
411
+ .split(/[/@._-]+/)
412
+ .map((part) => part.trim().toLowerCase())
413
+ .filter((part) => part.length >= 3 && part !== "dsh" && part !== "plugin");
414
+ const uniq = [];
415
+ const seen = new Set([failed]);
416
+ for (const token of tokens.length ? tokens : [failed]) {
417
+ for (const row of catalogHitsForQuery(catalog, token, 8)) {
418
+ const spec = String(row.spec || "").trim();
419
+ if (!spec || seen.has(spec.toLowerCase())) continue;
420
+ seen.add(spec.toLowerCase());
421
+ uniq.push(spec);
422
+ if (uniq.length >= limit) return uniq;
423
+ }
424
+ }
425
+ return uniq;
426
+ }
427
+
428
+ /** ManagementError 没有 message 字段,只有 code / diagnostic;再拼上 packageResult 输出尾部。 */
429
+ function formatChangeFailure(result, fallbackApplication = "failed") {
430
+ const parts = [];
431
+ const err = result?.error;
432
+ if (err && typeof err === "object") {
433
+ if (err.diagnostic) parts.push(String(err.diagnostic).trim());
434
+ if (err.code) parts.push(`错误码:${err.code}`);
435
+ if (Array.isArray(err.incompatible) && err.incompatible.length) {
436
+ const names = err.incompatible
437
+ .map((row) => `${row?.name || "?"}@${row?.version || "?"}`)
438
+ .filter(Boolean);
439
+ if (names.length) parts.push(`不兼容:${names.join("、")}`);
440
+ }
441
+ } else if (typeof err === "string" && err.trim()) {
442
+ parts.push(err.trim());
443
+ } else if (err?.message) {
444
+ parts.push(String(err.message).trim());
445
+ }
446
+
447
+ const pkg = result?.packageResult;
448
+ if (pkg && typeof pkg === "object") {
449
+ if (pkg.kind) parts.push(`失败类型:${pkg.kind}`);
450
+ if (pkg.timedOut) parts.push("安装超时(长时间无输出)");
451
+ if (typeof pkg.exitCode === "number") parts.push(`退出码:${pkg.exitCode}`);
452
+ const output = String(pkg.output || "").trim();
453
+ if (output) {
454
+ const lines = output
455
+ .split(/\r?\n/)
456
+ .map((line) => line.trim())
457
+ .filter(Boolean);
458
+ const tail = lines.slice(-8).join("\n");
459
+ if (tail) parts.push(tail);
460
+ }
461
+ if (pkg.logPath) parts.push(`完整日志:${pkg.logPath}`);
462
+ }
463
+
464
+ if (result?.failedAt) parts.push(`失败位置:${result.failedAt}`);
465
+ if (Array.isArray(result?.pendingBuilds) && result.pendingBuilds.length) {
466
+ parts.push(`待批准构建脚本:${result.pendingBuilds.join("、")}`);
467
+ }
468
+ if (Array.isArray(result?.warnings) && result.warnings.length) {
469
+ parts.push(`警告:${result.warnings.slice(0, 3).join(";")}`);
470
+ }
471
+
472
+ const unique = [];
473
+ const seen = new Set();
474
+ for (const part of parts) {
475
+ const text = String(part || "").trim();
476
+ if (!text || seen.has(text)) continue;
477
+ seen.add(text);
478
+ unique.push(text);
479
+ }
480
+ if (unique.length) return unique.join("\n");
481
+ const application = result?.application || fallbackApplication;
482
+ return `安装失败(${application})。插件管理器未返回 diagnostic / 安装日志。`;
483
+ }
484
+
485
+ function extractIncompatibility(result) {
486
+ const code = result?.error?.code || null;
487
+ const incompatible = Array.isArray(result?.error?.incompatible)
488
+ ? result.error.incompatible
489
+ .map((row) => ({
490
+ name: String(row?.name || "").trim(),
491
+ version: String(row?.version || "").trim(),
492
+ runtimeVersion: String(row?.runtimeVersion || "").trim(),
493
+ peers: row?.peers && typeof row.peers === "object" ? row.peers : {},
494
+ }))
495
+ .filter((row) => row.name && row.version)
496
+ : [];
497
+ return {
498
+ code,
499
+ incompatible,
500
+ needsRiskExemption: code === "incompatible-version" && incompatible.length > 0,
501
+ };
502
+ }
503
+
504
+ /** 安装失败时从精选目录推荐相近/同标签插件,优先给出可替代的当前生态包。 */
505
+ function buildFailureRecommendations(catalog, failedSpec, limit = 4) {
506
+ const failed = String(failedSpec || "").trim();
507
+ const seen = new Set([failed.toLowerCase()]);
508
+ const out = [];
509
+ const pushRow = (row, reason) => {
510
+ const spec = String(row?.spec || "").trim();
511
+ if (!spec || seen.has(spec.toLowerCase())) return;
512
+ seen.add(spec.toLowerCase());
513
+ out.push({
514
+ spec,
515
+ title: row.title || spec,
516
+ description: row.description || "",
517
+ source: "catalog",
518
+ installSpec: spec,
519
+ catalogId: row.id || row.spec,
520
+ reason: reason || "similar",
521
+ featured: row.featured === true,
522
+ rank: typeof row.rank === "number" ? row.rank : 0,
523
+ });
524
+ };
525
+
526
+ const plugins = Array.isArray(catalog?.plugins) ? catalog.plugins : [];
527
+ const failedRow = plugins.find((row) => String(row.spec || "").toLowerCase() === failed.toLowerCase());
528
+ if (failedRow) {
529
+ for (const rel of failedRow.related || []) {
530
+ const hit = plugins.find((row) => String(row.spec || "") === rel || String(row.id || "") === rel);
531
+ if (hit) pushRow(hit, "related");
532
+ if (out.length >= limit) return out;
533
+ }
534
+ for (const tag of failedRow.tags || []) {
535
+ for (const row of plugins) {
536
+ if ((row.tags || []).includes(tag)) pushRow(row, "tag");
537
+ if (out.length >= limit) return out;
538
+ }
539
+ }
540
+ for (const cat of failedRow.categories || []) {
541
+ if (cat === "featured") continue;
542
+ for (const row of plugins) {
543
+ if ((row.categories || []).includes(cat)) pushRow(row, "category");
544
+ if (out.length >= limit) return out;
545
+ }
546
+ }
547
+ }
548
+
549
+ for (const spec of suggestCatalogAlternatives(catalog, failed, limit)) {
550
+ const hit = plugins.find((row) => String(row.spec || "") === spec) || { spec, title: spec };
551
+ pushRow(hit, "similar");
552
+ if (out.length >= limit) break;
553
+ }
554
+ return out.slice(0, limit);
555
+ }
556
+
308
557
  function mapGithubRepos(items) {
309
558
  return (Array.isArray(items) ? items : []).map((repo) => ({
310
559
  source: "github",
@@ -381,6 +630,14 @@ class PluginDiscoveryService extends TypertRemoteService {
381
630
  catalogGithubUrl: z.string().default(DEFAULT_CATALOG_GITHUB_URL),
382
631
  /** 远程检查间隔;期间复用内存/磁盘缓存 */
383
632
  catalogRefreshTtlMs: z.number().min(0).max(86400000 * 7).default(3600000),
633
+ /** 启动与打开面板时检查本插件是否有新版本 */
634
+ selfUpdateCheckEnabled: z.boolean().default(true),
635
+ /** 发现新版本后自动执行更新(file: 本地开发安装会跳过自动更新) */
636
+ selfUpdateAuto: z.boolean().default(true),
637
+ selfUpdateCheckTtlMs: z.number().min(0).max(86400000 * 7).default(21600000),
638
+ selfPackageName: z.string().default(SELF_PACKAGE_NAME),
639
+ selfPackageGiteeUrl: z.string().default(DEFAULT_SELF_PACKAGE_GITEE_URL),
640
+ selfPackageGithubUrl: z.string().default(DEFAULT_SELF_PACKAGE_GITHUB_URL),
384
641
  });
385
642
 
386
643
  constructor(ctx, config) {
@@ -390,14 +647,206 @@ class PluginDiscoveryService extends TypertRemoteService {
390
647
  this.catalog = pickNewerCatalog(loadBundledCatalog(), loadCachedCatalog());
391
648
  this.catalogCheckedAt = 0;
392
649
  this.catalogRefreshPromise = null;
650
+ this.selfUpdateCheckedAt = 0;
651
+ this.selfUpdateState = null;
652
+ this.selfUpdatePromise = null;
393
653
  this.ctx.effect(() => this.registerTools());
394
654
  // 启动后后台拉一次,不阻塞启动
395
655
  this.ctx.effect(() => {
396
656
  void this.refreshCatalog({ force: false }).catch(() => {});
657
+ void this.checkSelfUpdate({ force: false, auto: this.config.selfUpdateAuto === true }).catch(() => {});
397
658
  return () => {};
398
659
  });
399
660
  }
400
661
 
662
+ getLocalInstallMeta() {
663
+ const local = readLocalPackageMeta();
664
+ const real = (() => {
665
+ try {
666
+ return join(__dirname);
667
+ } catch {
668
+ return "";
669
+ }
670
+ })();
671
+ const isFileInstall = /[\\/]bundles[\\/]dsh-plugin-discovery$/i.test(real.replace(/\\/g, "/"));
672
+ return {
673
+ name: this.config.selfPackageName || local.name || SELF_PACKAGE_NAME,
674
+ version: local.version,
675
+ isFileInstall,
676
+ };
677
+ }
678
+
679
+ selfUpdateSourceUrls() {
680
+ const name = this.config.selfPackageName || SELF_PACKAGE_NAME;
681
+ const urls = [
682
+ {
683
+ kind: "npm",
684
+ label: "npm",
685
+ url: `https://registry.npmjs.org/${encodeURIComponent(name)}/latest`,
686
+ },
687
+ ];
688
+ const gitee = String(this.config.selfPackageGiteeUrl || "").trim();
689
+ const github = String(this.config.selfPackageGithubUrl || "").trim();
690
+ if (gitee) urls.push({ kind: "json", label: "gitee", url: gitee });
691
+ if (github) urls.push({ kind: "json", label: "github", url: github });
692
+ return urls;
693
+ }
694
+
695
+ async resolveInstalledSelfVersion() {
696
+ const local = this.getLocalInstallMeta();
697
+ try {
698
+ const bundles = await this.ctx.pluginManager.listBundles();
699
+ const row = (Array.isArray(bundles) ? bundles : []).find((item) => item && item.name === local.name);
700
+ if (row?.version) {
701
+ return { currentVersion: String(row.version), source: "bundle", isFileInstall: local.isFileInstall, name: local.name };
702
+ }
703
+ } catch {
704
+ /* ignore */
705
+ }
706
+ return {
707
+ currentVersion: local.version,
708
+ source: "package",
709
+ isFileInstall: local.isFileInstall,
710
+ name: local.name,
711
+ };
712
+ }
713
+
714
+ async checkSelfUpdate(options = {}) {
715
+ if (!this.config.enabled) {
716
+ return { ok: false, message: "发现插件已停用" };
717
+ }
718
+ if (this.config.selfUpdateCheckEnabled === false) {
719
+ return { ok: true, skipped: true, reason: "check disabled", update: this.selfUpdateState };
720
+ }
721
+ const force = options.force === true;
722
+ const auto = options.auto === true;
723
+ const ttl = this.config.selfUpdateCheckTtlMs ?? 21600000;
724
+ if (!force && this.selfUpdateCheckedAt && Date.now() - this.selfUpdateCheckedAt < ttl && this.selfUpdateState) {
725
+ if (auto && this.selfUpdateState.updateAvailable && !this.selfUpdateState.isFileInstall) {
726
+ return this.updateSelf({ latestVersion: this.selfUpdateState.latestVersion });
727
+ }
728
+ return { ok: true, skipped: true, reason: "ttl", update: this.selfUpdateState };
729
+ }
730
+ if (this.selfUpdatePromise) return this.selfUpdatePromise;
731
+
732
+ this.selfUpdatePromise = (async () => {
733
+ const installed = await this.resolveInstalledSelfVersion();
734
+ const latestInfo = await fetchLatestSelfVersion(this.selfUpdateSourceUrls());
735
+ this.selfUpdateCheckedAt = Date.now();
736
+ if (!latestInfo.version) {
737
+ this.selfUpdateState = {
738
+ ...installed,
739
+ latestVersion: null,
740
+ updateAvailable: false,
741
+ origin: null,
742
+ checkedAt: this.selfUpdateCheckedAt,
743
+ error: latestInfo.error,
744
+ };
745
+ return { ok: false, message: latestInfo.error || "无法获取最新版本", update: this.selfUpdateState };
746
+ }
747
+ const updateAvailable = compareSemver(latestInfo.version, installed.currentVersion) > 0;
748
+ this.selfUpdateState = {
749
+ name: installed.name,
750
+ currentVersion: installed.currentVersion,
751
+ latestVersion: latestInfo.version,
752
+ updateAvailable,
753
+ origin: latestInfo.origin,
754
+ isFileInstall: installed.isFileInstall,
755
+ checkedAt: this.selfUpdateCheckedAt,
756
+ };
757
+ if (auto && updateAvailable && !installed.isFileInstall && this.config.selfUpdateAuto !== false) {
758
+ const updated = await this.updateSelf({ latestVersion: latestInfo.version });
759
+ return { ...updated, update: this.selfUpdateState };
760
+ }
761
+ return {
762
+ ok: true,
763
+ update: this.selfUpdateState,
764
+ message: updateAvailable
765
+ ? `发现新版本 ${latestInfo.version}(当前 ${installed.currentVersion})`
766
+ : `已是最新版本 ${installed.currentVersion}`,
767
+ };
768
+ })().finally(() => {
769
+ this.selfUpdatePromise = null;
770
+ });
771
+
772
+ return this.selfUpdatePromise;
773
+ }
774
+
775
+ async updateSelf(options = {}) {
776
+ if (!this.config.enabled) {
777
+ return { ok: false, message: "发现插件已停用" };
778
+ }
779
+ const installed = await this.resolveInstalledSelfVersion();
780
+ if (installed.isFileInstall) {
781
+ return {
782
+ ok: false,
783
+ skipped: true,
784
+ message: "当前为本地 file: 开发安装,已跳过自动更新。发布版用户会从 npm 升级。",
785
+ update: this.selfUpdateState,
786
+ };
787
+ }
788
+ let latestVersion = typeof options.latestVersion === "string" ? options.latestVersion.trim() : "";
789
+ if (!latestVersion) {
790
+ const checked = await this.checkSelfUpdate({ force: true, auto: false });
791
+ latestVersion = checked?.update?.latestVersion || "";
792
+ if (!checked?.update?.updateAvailable) {
793
+ return {
794
+ ok: true,
795
+ updated: false,
796
+ message: checked?.message || "没有可更新版本",
797
+ update: checked?.update || this.selfUpdateState,
798
+ };
799
+ }
800
+ }
801
+ const name = installed.name || SELF_PACKAGE_NAME;
802
+ const spec = `${name}@${latestVersion}`;
803
+ const requestId =
804
+ globalThis.crypto?.randomUUID?.() || `pd-self-${Date.now()}-${Math.random().toString(16).slice(2)}`;
805
+ try {
806
+ const result = await this.ctx.pluginManager.installBundle(spec, {
807
+ requestId,
808
+ enabled: true,
809
+ });
810
+ const application = result?.application;
811
+ const failed = application === "failed" || application === "cancelled";
812
+ if (!failed) {
813
+ this.selfUpdateState = {
814
+ ...(this.selfUpdateState || {}),
815
+ name,
816
+ currentVersion: latestVersion,
817
+ latestVersion,
818
+ updateAvailable: false,
819
+ origin: this.selfUpdateState?.origin || "npm",
820
+ isFileInstall: false,
821
+ checkedAt: Date.now(),
822
+ };
823
+ }
824
+ return {
825
+ ok: !failed,
826
+ updated: !failed,
827
+ spec,
828
+ requestId,
829
+ application,
830
+ packageName: name,
831
+ version: latestVersion,
832
+ message: failed
833
+ ? result?.error?.message || `更新失败(${application || "unknown"})`
834
+ : `已更新到 ${latestVersion},请刷新页面或重启 dsh web 使界面生效`,
835
+ update: this.selfUpdateState,
836
+ result,
837
+ };
838
+ } catch (error) {
839
+ return {
840
+ ok: false,
841
+ updated: false,
842
+ spec,
843
+ requestId,
844
+ message: error instanceof Error ? error.message : String(error),
845
+ update: this.selfUpdateState,
846
+ };
847
+ }
848
+ }
849
+
401
850
  getCatalog() {
402
851
  return this.catalog || loadBundledCatalog();
403
852
  }
@@ -573,6 +1022,14 @@ class PluginDiscoveryService extends TypertRemoteService {
573
1022
 
574
1023
  // 打开推荐/分类时尝试按 TTL 拉远程;失败静默回退本地
575
1024
  await this.refreshCatalog({ force: opts.refresh === true }).catch(() => {});
1025
+ const selfUpdate = await this.checkSelfUpdate({
1026
+ force: opts.refresh === true,
1027
+ auto: this.config.selfUpdateAuto === true,
1028
+ }).catch((error) => ({
1029
+ ok: false,
1030
+ message: error instanceof Error ? error.message : String(error),
1031
+ update: this.selfUpdateState,
1032
+ }));
576
1033
 
577
1034
  const catalog = this.getCatalog();
578
1035
  const personalize = mode === "recommend";
@@ -594,6 +1051,9 @@ class PluginDiscoveryService extends TypertRemoteService {
594
1051
  categories: catalog.categories,
595
1052
  results,
596
1053
  total: results.length,
1054
+ selfUpdate: selfUpdate?.update || this.selfUpdateState,
1055
+ selfUpdateMessage: selfUpdate?.message || null,
1056
+ selfUpdated: selfUpdate?.updated === true,
597
1057
  };
598
1058
  }
599
1059
 
@@ -641,6 +1101,9 @@ class PluginDiscoveryService extends TypertRemoteService {
641
1101
 
642
1102
  const limit = this.config.maxCandidates;
643
1103
  const token = opts.githubToken ?? this.config.githubToken;
1104
+ await this.refreshCatalog({ force: false }).catch(() => {});
1105
+ const catalog = this.getCatalog();
1106
+ const catalogHits = catalogHitsForQuery(catalog, q === "dsh" ? "" : q, limit);
644
1107
  let raw = [];
645
1108
  try {
646
1109
  if (source === "npm" || source === "all") {
@@ -651,23 +1114,27 @@ class PluginDiscoveryService extends TypertRemoteService {
651
1114
  }
652
1115
  } catch (error) {
653
1116
  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
- };
1117
+ if (!catalogHits.length) {
1118
+ return {
1119
+ ok: false,
1120
+ message: error instanceof Error ? error.message : String(error),
1121
+ results: [],
1122
+ total: 0,
1123
+ };
1124
+ }
1125
+ // 在线源失败时仍返回精选命中,避免「搜得到却全空」
1126
+ raw = [];
660
1127
  }
661
1128
 
662
1129
  const seen = new Set();
663
1130
  const deduped = [];
664
- for (const row of raw) {
1131
+ for (const row of [...catalogHits, ...raw]) {
665
1132
  if (!row.spec || seen.has(row.spec)) continue;
666
1133
  seen.add(row.spec);
667
1134
  deduped.push({
668
1135
  ...row,
669
1136
  installSpec: row.spec,
670
- bundle: null,
1137
+ bundle: row.bundle ?? null,
671
1138
  });
672
1139
  if (deduped.length >= limit) break;
673
1140
  }
@@ -791,6 +1258,17 @@ class PluginDiscoveryService extends TypertRemoteService {
791
1258
  message: `「${existing}」已在 DSH 插件列表中,无需重复安装。请到「插件」页查看。`,
792
1259
  };
793
1260
  }
1261
+ const withInstallHint = (message) => {
1262
+ const text = String(message || "").trim() || "安装失败";
1263
+ const looksNetwork =
1264
+ /ERR_PNPM_META_FETCH_FAIL|fetch failed|ECONNRESET|ETIMEDOUT|ENOTFOUND|network|镜像/i.test(text);
1265
+ if (!looksNetwork) return text;
1266
+ const alts = suggestCatalogAlternatives(this.getCatalog(), trimmed);
1267
+ if (!alts.length) {
1268
+ return `${text}。若持续失败,可改用「分类浏览 / 为你推荐」里的精选包,或稍后重试。`;
1269
+ }
1270
+ return `${text}。可改试精选目录中的相近插件:${alts.join("、")}`;
1271
+ };
794
1272
  try {
795
1273
  const result = await this.ctx.pluginManager.installBundle(trimmed, {
796
1274
  requestId,
@@ -799,6 +1277,18 @@ class PluginDiscoveryService extends TypertRemoteService {
799
1277
  const packageName = result?.bundle || pre.name || trimmed;
800
1278
  const application = result?.application;
801
1279
  const failed = application === "failed" || application === "cancelled";
1280
+ const rawMessage = failed
1281
+ ? formatChangeFailure(result, application || "failed")
1282
+ : `「${packageName}」安装完成(${application || "applied"} / ${result?.stage || "install"})`;
1283
+ const incompat = failed ? extractIncompatibility(result) : {};
1284
+ const recommendations =
1285
+ failed && (incompat.needsRiskExemption || incompat.code === "incompatible-version")
1286
+ ? buildFailureRecommendations(this.getCatalog(), trimmed, 4)
1287
+ : undefined;
1288
+ let message = failed ? withInstallHint(rawMessage) : rawMessage;
1289
+ if (failed && recommendations && recommendations.length) {
1290
+ message = `${message}\n推荐改试:${recommendations.map((row) => row.spec).join("、")}`;
1291
+ }
802
1292
  return {
803
1293
  ok: !failed,
804
1294
  alreadyInstalled: false,
@@ -806,15 +1296,65 @@ class PluginDiscoveryService extends TypertRemoteService {
806
1296
  requestId,
807
1297
  application,
808
1298
  stage: result?.stage,
809
- message: failed
810
- ? result?.error?.message || `安装失败(${application || "unknown"})`
811
- : `「${packageName}」安装完成(${application || "applied"} / ${result?.stage || "install"})`,
1299
+ message,
1300
+ detail: failed ? rawMessage : undefined,
1301
+ logPath: failed ? result?.packageResult?.logPath : undefined,
1302
+ code: incompat.code || undefined,
1303
+ incompatible: incompat.incompatible || undefined,
1304
+ needsRiskExemption: incompat.needsRiskExemption === true,
1305
+ recommendations,
1306
+ installSpec: trimmed,
812
1307
  result,
813
1308
  };
814
1309
  } catch (error) {
815
1310
  return {
816
1311
  ok: false,
817
1312
  requestId,
1313
+ message: withInstallHint(error instanceof Error ? error.message : String(error)),
1314
+ installSpec: trimmed,
1315
+ };
1316
+ }
1317
+ }
1318
+
1319
+ /** Typert Remote:参数必须是独立形参,不能解构、不能默认值、不能 rest。 */
1320
+ async grantVersionExemption(packageVersion, runtimeVersion, acceptRisk) {
1321
+ if (!this.config.enabled) {
1322
+ return { ok: false, message: "发现插件已停用" };
1323
+ }
1324
+ const pkg = String(packageVersion == null ? "" : packageVersion).trim();
1325
+ const runtime = String(runtimeVersion == null ? "" : runtimeVersion).trim();
1326
+ const accepted = acceptRisk === true;
1327
+ if (!pkg || !runtime) {
1328
+ return { ok: false, message: "缺少 packageVersion 或 runtimeVersion" };
1329
+ }
1330
+ if (!accepted) {
1331
+ return {
1332
+ ok: false,
1333
+ message: "未确认风险。授予版本豁免必须 acceptRisk=true,并先明确告知可能崩溃或数据丢失。",
1334
+ };
1335
+ }
1336
+ if (typeof this.ctx.pluginManager?.setVersionExemption !== "function") {
1337
+ return { ok: false, message: "当前 DSH 不支持 setVersionExemption" };
1338
+ }
1339
+ try {
1340
+ const result = await this.ctx.pluginManager.setVersionExemption(pkg, runtime, true, true);
1341
+ const application = result?.application;
1342
+ const failed = application === "failed" || application === "cancelled";
1343
+ return {
1344
+ ok: !failed,
1345
+ packageVersion: pkg,
1346
+ runtimeVersion: runtime,
1347
+ application,
1348
+ message: failed
1349
+ ? formatChangeFailure(result, application || "failed")
1350
+ : `已为 ${pkg} 在 DSH ${runtime} 上授予精确版本豁免,可重新安装。`,
1351
+ result,
1352
+ };
1353
+ } catch (error) {
1354
+ return {
1355
+ ok: false,
1356
+ packageVersion: pkg,
1357
+ runtimeVersion: runtime,
818
1358
  message: error instanceof Error ? error.message : String(error),
819
1359
  };
820
1360
  }
@@ -829,6 +1369,22 @@ class PluginDiscoveryService extends TypertRemoteService {
829
1369
  return { ok: true, settled: null, pending: true, message: "安装仍在进行或已丢失" };
830
1370
  }
831
1371
  const failed = settled.application === "failed" || settled.application === "cancelled";
1372
+ const failText = failed ? formatChangeFailure(settled, settled.application) : "";
1373
+ const incompat = failed ? extractIncompatibility(settled) : {};
1374
+ const failedSpec =
1375
+ (Array.isArray(incompat.incompatible) && incompat.incompatible[0]
1376
+ ? `${incompat.incompatible[0].name}@${incompat.incompatible[0].version}`
1377
+ : "") || settled.bundle || "";
1378
+ const recommendations =
1379
+ failed && (incompat.needsRiskExemption || incompat.code === "incompatible-version")
1380
+ ? buildFailureRecommendations(this.getCatalog(), failedSpec, 4)
1381
+ : undefined;
1382
+ let message = failed
1383
+ ? failText
1384
+ : `安装完成(${settled.application} / ${settled.stage})`;
1385
+ if (failed && recommendations && recommendations.length) {
1386
+ message = `${message}\n推荐改试:${recommendations.map((row) => row.spec).join("、")}`;
1387
+ }
832
1388
  return {
833
1389
  ok: !failed,
834
1390
  settled,
@@ -836,9 +1392,14 @@ class PluginDiscoveryService extends TypertRemoteService {
836
1392
  packageName: settled.bundle,
837
1393
  application: settled.application,
838
1394
  stage: settled.stage,
839
- message: failed
840
- ? settled.error?.message || `安装失败(${settled.application})`
841
- : `安装完成(${settled.application} / ${settled.stage})`,
1395
+ message,
1396
+ detail: failed ? failText : undefined,
1397
+ logPath: failed ? settled?.packageResult?.logPath : undefined,
1398
+ code: incompat.code || undefined,
1399
+ incompatible: incompat.incompatible || undefined,
1400
+ needsRiskExemption: incompat.needsRiskExemption === true,
1401
+ recommendations,
1402
+ installSpec: failedSpec || undefined,
842
1403
  };
843
1404
  } catch (error) {
844
1405
  return {
@@ -929,6 +1490,28 @@ class PluginDiscoveryService extends TypertRemoteService {
929
1490
  }),
930
1491
  ),
931
1492
  );
1493
+ disposers.push(
1494
+ this.ctx.tools.register(
1495
+ defineTool({
1496
+ name: "plugin_discovery_check_update",
1497
+ description: "Check whether dsh-plugin-discovery has a newer npm/Gitee version and optionally update.",
1498
+ parameters: {
1499
+ autoUpdate: {
1500
+ type: "boolean",
1501
+ description: "If true and a newer version exists, install it now",
1502
+ },
1503
+ },
1504
+ output: toolOutput,
1505
+ async execute(args) {
1506
+ return service.checkSelfUpdate({
1507
+ force: true,
1508
+ auto: args.autoUpdate === true,
1509
+ });
1510
+ },
1511
+ presentCall: () => present("Check dsh-plugin-discovery update"),
1512
+ }),
1513
+ ),
1514
+ );
932
1515
  return () => {
933
1516
  for (const dispose of disposers.reverse()) dispose();
934
1517
  };
@@ -939,8 +1522,11 @@ class PluginDiscoveryService extends TypertRemoteService {
939
1522
 
940
1523
  markRemoteMethod(PluginDiscoveryService.prototype, "search");
941
1524
  markRemoteMethod(PluginDiscoveryService.prototype, "listCatalog");
1525
+ markRemoteMethod(PluginDiscoveryService.prototype, "checkSelfUpdate");
1526
+ markRemoteMethod(PluginDiscoveryService.prototype, "updateSelf");
942
1527
  markRemoteMethod(PluginDiscoveryService.prototype, "resolve");
943
1528
  markRemoteMethod(PluginDiscoveryService.prototype, "installPlugin");
1529
+ markRemoteMethod(PluginDiscoveryService.prototype, "grantVersionExemption");
944
1530
  markRemoteMethod(PluginDiscoveryService.prototype, "listInstalled");
945
1531
  markRemoteMethod(PluginDiscoveryService.prototype, "waitInstall");
946
1532