dsh-armory 2.0.0 → 2.2.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.
@@ -1,4 +1,4 @@
1
- // awesome-dsh-plugins 市场数据层(T01,docs/system-design-awesome.md)。
1
+ // awesome-dsh-plugins 市场数据层(T01)。
2
2
  //
3
3
  // 与既有 codebuddy 插件市场(lib/wb-plugin-market.js 的 zip 管线)完全解耦:
4
4
  // awesome 源是单文件裸 JSON(~13.4MB,全量 5 千余条,其中 unverified/broken 占
@@ -186,7 +186,7 @@ async function fetchChain(etag) {
186
186
  if (!data || typeof data !== "object" || Array.isArray(data) || !Array.isArray(data.plugins)) {
187
187
  throw new Error("响应缺少 plugins 数组");
188
188
  }
189
- return { ok: true, data, etag: response.headers.get("etag") || "" };
189
+ return { ok: true, data, etag: response.headers.get("etag") || "", source: sourceUrl };
190
190
  } catch (error) {
191
191
  lastError = `${sourceUrl} → ${error.message}`;
192
192
  continue;
@@ -196,13 +196,15 @@ async function fetchChain(etag) {
196
196
  }
197
197
 
198
198
  /** 瘦身结果 + 元信息原子落盘(slim.json + meta.json),返回 counts。
199
- * 同时刷新进程内缓存层,后续调用免掉 readFile + JSON.parse。 */
200
- async function persistSlim(entries, etag, updated, options = {}) {
199
+ * 同时刷新进程内缓存层,后续调用免掉 readFile + JSON.parse。
200
+ * source 记录「这份缓存是从哪个源拿到的」:探测时优先回同一源比对 ETag,
201
+ * 避免主源挂掉走备用源取到缓存后、探测却只问主源而误报失败。 */
202
+ async function persistSlim(entries, etag, updated, options = {}, source = "") {
201
203
  const counts = countsOf(entries);
202
204
  const fetchedAt = Date.now();
203
205
  await writeAtomic(slimPathOf(options), JSON.stringify(entries));
204
- await writeAtomic(metaPathOf(options), JSON.stringify({ etag, fetchedAt, updated, counts }));
205
- setMemSlim(options, entries, { etag, fetchedAt, updated, counts });
206
+ await writeAtomic(metaPathOf(options), JSON.stringify({ etag, fetchedAt, updated, counts, source }));
207
+ setMemSlim(options, entries, { etag, fetchedAt, updated, counts, source });
206
208
  return counts;
207
209
  }
208
210
 
@@ -317,7 +319,7 @@ function catalogResult(entries, meta, loadedFrom, extra = {}) {
317
319
  /** 拉取链成功后的统一走线:瘦身 → 原子落盘 → 组装 network 响应。 */
318
320
  async function refreshFromNetwork(chain, options = {}) {
319
321
  const entries = slimEntries(chain.data.plugins);
320
- const counts = await persistSlim(entries, chain.etag, typeof chain.data.updated === "string" ? chain.data.updated : "", options);
322
+ const counts = await persistSlim(entries, chain.etag, typeof chain.data.updated === "string" ? chain.data.updated : "", options, typeof chain.source === "string" ? chain.source : "");
321
323
  return {
322
324
  ok: true,
323
325
  loadedFrom: "network",
@@ -392,12 +394,43 @@ export async function wbAwesomeDetail(name, options = {}) {
392
394
  }
393
395
 
394
396
  /**
395
- * ETag 条件探测(wbPluginUpdateCheck 同款语义,源固定取 AWESOME_SOURCES[0]):
396
- * **绝不落盘**,只取 headers。reason 五枚举:no-source / probe-failed / no-cache /
397
- * up-to-date / update-available —— 本实现源是模块常量恒有源,no-source 分支不出现,
398
- * 保留枚举位仅为与 UI 侧契约对齐(wb-update-hint 的 reason 映射表)。
397
+ * 单源 ETag 条件探测:HEAD(被 405/501 拒绝则降级 GET 并立刻 cancel 响应体)。
398
+ * 绝不落盘,只取 headers。返回 { ok: true, remoteEtag } 或 { ok: false, error }。
399
+ */
400
+ async function probeEtagAt(sourceUrl, localEtag) {
401
+ const headers = { "user-agent": USER_AGENT, "if-none-match": localEtag };
402
+ let response;
403
+ try {
404
+ response = await fetch(sourceUrl, { method: "HEAD", headers, signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
405
+ if (response.status === 405 || response.status === 501) {
406
+ // HEAD 不被支持 → 降级 GET,立刻 cancel() 丢弃响应体,不读进内存。
407
+ response.body?.cancel?.();
408
+ response = await fetch(sourceUrl, { method: "GET", headers, signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
409
+ response.body?.cancel?.();
410
+ }
411
+ } catch (error) {
412
+ return { ok: false, error: `${sourceUrl} → ${error.message}` };
413
+ }
414
+ if (response.status === 304) return { ok: true, remoteEtag: localEtag };
415
+ if (response.ok) {
416
+ const remoteEtag = response.headers.get("etag") || "";
417
+ // 探测只信 ETag:响应没有 ETag 就无法判定,宁可报探测失败也不误报「有更新」。
418
+ if (!remoteEtag) return { ok: false, error: `${sourceUrl} → 响应缺少 ETag` };
419
+ return { ok: true, remoteEtag };
420
+ }
421
+ return { ok: false, error: `${sourceUrl} → HTTP ${response.status}` };
422
+ }
423
+
424
+ /**
425
+ * ETag 条件探测(wbPluginUpdateCheck 同款语义):**绝不落盘**,只取 headers。
426
+ * reason 五枚举:no-source / probe-failed / no-cache / up-to-date / update-available。
399
427
  * 无缓存(meta 无 etag)→ hasUpdate: false + no-cache(与插件市场不同:awesome 首次
400
428
  * 由 catalog 链兜底加载,探测层不引导更新)。
429
+ *
430
+ * 探测与目录拉取必须走同一条源链:过去只问 AWESOME_SOURCES[0],于是「主源不通、
431
+ * 目录靠备用源成功」的机器上目录能正常加载、探测却每次 probe-failed —— 这正是
432
+ * 「DSH 社区目录更新探测失败」的来源。现在按「缓存实际来源 → 全部源」顺序逐个试,
433
+ * 首个拿到 ETag 的源胜出;全部失败才报 probe-failed。
401
434
  */
402
435
  export async function wbAwesomeUpdateCheck(options = {}) {
403
436
  try {
@@ -406,34 +439,20 @@ export async function wbAwesomeUpdateCheck(options = {}) {
406
439
  if (!localEtag) {
407
440
  return { ok: true, hasUpdate: false, reason: "no-cache", localEtag: "", remoteEtag: "", checkedAt: Date.now() };
408
441
  }
409
- const sourceUrl = AWESOME_SOURCES[0];
410
- const headers = { "user-agent": USER_AGENT, "if-none-match": localEtag };
411
- let response;
412
- try {
413
- response = await fetch(sourceUrl, { method: "HEAD", headers, signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
414
- if (response.status === 405 || response.status === 501) {
415
- // HEAD 不被支持 → 降级 GET,立刻 cancel() 丢弃响应体,不读进内存。
416
- response.body?.cancel?.();
417
- response = await fetch(sourceUrl, { method: "GET", headers, signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
418
- response.body?.cancel?.();
419
- }
420
- } catch (error) {
421
- return { ok: false, hasUpdate: false, reason: "probe-failed", localEtag, remoteEtag: "", checkedAt: Date.now(), error: `更新探测失败:${error.message}` };
422
- }
423
- if (response.status === 304) {
424
- // 304:ETag 命中,服务端不回显 ETag;回显 localEtag 便于客户端展示。
425
- return { ok: true, hasUpdate: false, reason: "up-to-date", localEtag, remoteEtag: localEtag, checkedAt: Date.now() };
426
- }
427
- if (response.ok) {
428
- const remoteEtag = response.headers.get("etag") || "";
429
- if (!remoteEtag) {
430
- // 探测只信 ETag:响应没有 ETag 就无法判定,宁可报探测失败也不误报「有更新」。
431
- return { ok: false, hasUpdate: false, reason: "probe-failed", localEtag, remoteEtag: "", checkedAt: Date.now(), error: "更新探测失败:响应缺少 ETag" };
442
+ // 先问拿到这份缓存的源(ETag 同源可比),再兜底其余源。
443
+ const cachedSource = typeof meta.source === "string" ? meta.source : "";
444
+ const candidates = [...new Set([cachedSource, ...AWESOME_SOURCES].filter(Boolean))];
445
+ let lastError = "";
446
+ for (const sourceUrl of candidates) {
447
+ const probe = await probeEtagAt(sourceUrl, localEtag);
448
+ if (!probe.ok) {
449
+ lastError = probe.error;
450
+ continue;
432
451
  }
433
- const hasUpdate = remoteEtag !== localEtag;
434
- return { ok: true, hasUpdate, reason: hasUpdate ? "update-available" : "up-to-date", localEtag, remoteEtag, checkedAt: Date.now() };
452
+ const hasUpdate = probe.remoteEtag !== localEtag;
453
+ return { ok: true, hasUpdate, reason: hasUpdate ? "update-available" : "up-to-date", localEtag, remoteEtag: probe.remoteEtag, checkedAt: Date.now() };
435
454
  }
436
- return { ok: false, hasUpdate: false, reason: "probe-failed", localEtag, remoteEtag: "", checkedAt: Date.now(), error: `awesome 市场更新探测失败:HTTP ${response.status}` };
455
+ return { ok: false, hasUpdate: false, reason: "probe-failed", localEtag, remoteEtag: "", checkedAt: Date.now(), error: `awesome 市场更新探测失败:${lastError}` };
437
456
  } catch (error) {
438
457
  return { ok: false, hasUpdate: false, reason: "probe-failed", localEtag: "", remoteEtag: "", checkedAt: Date.now(), error: `更新探测异常:${error.message}` };
439
458
  }
@@ -451,3 +470,252 @@ export async function wbAwesomeCachedInfo(options = {}) {
451
470
  updated: typeof meta.updated === "string" ? meta.updated : "",
452
471
  };
453
472
  }
473
+
474
+ // ── README 预览(按需拉取 + 落盘缓存,纯只读)────────────────────────────────
475
+ //
476
+ // 为什么单独一条 RPC:目录 slim 里不放 README(13MB 全量本身就只留 13 个字段,
477
+ // 塞进 README 会把瘦身缓存撑爆)。README 只在用户点开某张卡片时按需取一次,
478
+ // 取到后按「npm 包名或仓库」落盘缓存,同一插件再点就秒开、也不怕断网。
479
+
480
+ /** 预览是用户点击触发的一次短请求,不能像目录那样等 120s。 */
481
+ const README_TIMEOUT_MS = 20_000;
482
+ /** 正文上限:超长的 CHANGELOG 类 README 截断,别把几 MB 文本塞进浏览器。 */
483
+ const README_MAX_CHARS = 160_000;
484
+ /** 磁盘缓存 TTL:README 变动不频繁,7 天足够,过期再取一次。 */
485
+ const README_CACHE_TTL_MS = 7 * 24 * 3600_000;
486
+ /** GitHub raw 里按顺序尝试的文件名(大小写在不同仓库里真的不一样)。 */
487
+ const README_FILES = ["README.md", "readme.md", "Readme.md", "README.MD", "README.markdown", "README_EN.md"];
488
+ const NPM_REGISTRY = "https://registry.npmjs.org";
489
+
490
+ function readmeCacheDir(options = {}) {
491
+ return join(awesomeMarketDir(options), "readme");
492
+ }
493
+
494
+ /** 缓存文件名:来源标识压成安全短名(不用 crypto,避免多一个依赖)。 */
495
+ function readmeCachePath(key, options = {}) {
496
+ const safe = String(key || "").toLowerCase().replace(/[^a-z0-9._-]+/g, "_").slice(0, 96) || "item";
497
+ return join(readmeCacheDir(options), `${safe}.json`);
498
+ }
499
+
500
+ /**
501
+ * 从各种形态的仓库字段里提取 `owner/repo`;不是 GitHub 或提取不到返回 ""。
502
+ * 吃到过的形态:`owner/repo`、`https://github.com/o/r`、`git+https://github.com/o/r.git`、
503
+ * `git@github.com:o/r.git`、带 `#readme` / `/tree/main` 尾巴的。
504
+ */
505
+ export function parseGithubRepo(value) {
506
+ if (typeof value !== "string") return "";
507
+ let text = value.trim();
508
+ if (!text) return "";
509
+ const ssh = /^git@github\.com:([^/\s]+)\/([^/\s#]+?)(?:\.git)?$/i.exec(text);
510
+ if (ssh) return `${ssh[1]}/${ssh[2].replace(/\.git$/i, "")}`;
511
+ text = text.replace(/^git\+/i, "");
512
+ const http = /^https?:\/\/(?:www\.)?github\.com\/([^/\s]+)\/([^/\s#?]+)/i.exec(text);
513
+ if (http) return `${http[1]}/${http[2].replace(/\.git$/i, "")}`;
514
+ const bare = /^([A-Za-z0-9][A-Za-z0-9._-]*)\/([A-Za-z0-9._-]+)$/.exec(text);
515
+ if (bare) return `${bare[1]}/${bare[2].replace(/\.git$/i, "")}`;
516
+ return "";
517
+ }
518
+
519
+ /** 正文规整:去 BOM、统一换行、按字符上限截断。 */
520
+ function normalizeReadme(text) {
521
+ let out = String(text || "").replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
522
+ if (out.length > README_MAX_CHARS) {
523
+ return { markdown: out.slice(0, README_MAX_CHARS), truncated: true };
524
+ }
525
+ return { markdown: out, truncated: false };
526
+ }
527
+
528
+ /** 单次文本 GET(带超时);非 2xx 也把 body 读掉,避免连接悬挂。 */
529
+ async function fetchReadmeText(url, accept) {
530
+ const controller = new AbortController();
531
+ const timer = setTimeout(() => controller.abort(), README_TIMEOUT_MS);
532
+ try {
533
+ const response = await fetch(url, {
534
+ headers: { "user-agent": USER_AGENT, accept },
535
+ signal: controller.signal,
536
+ redirect: "follow",
537
+ });
538
+ if (!response.ok) {
539
+ try {
540
+ await response.text();
541
+ } catch {
542
+ // 忽略:只为释放连接
543
+ }
544
+ return { ok: false, reachable: true, status: response.status, error: `HTTP ${response.status}` };
545
+ }
546
+ return { ok: true, reachable: true, status: response.status, text: await response.text(), url: response.url || url };
547
+ } catch (error) {
548
+ const reason = error && error.name === "AbortError" ? `超时(${README_TIMEOUT_MS}ms)` : String((error && error.message) || error);
549
+ return { ok: false, reachable: false, status: 0, error: reason };
550
+ } finally {
551
+ clearTimeout(timer);
552
+ }
553
+ }
554
+
555
+ /** npm 侧取 README:先打 `/<pkg>/latest`(小),没有再退全量 packument。 */
556
+ async function readmeFromNpm(npmName) {
557
+ const encoded = String(npmName).replace(/\//g, "%2F");
558
+ const latest = await fetchReadmeText(`${NPM_REGISTRY}/${encoded}/latest`, "application/json");
559
+ if (latest.ok) {
560
+ let doc = null;
561
+ try {
562
+ doc = JSON.parse(latest.text);
563
+ } catch {
564
+ doc = null;
565
+ }
566
+ if (doc && typeof doc.readme === "string" && doc.readme.trim()) {
567
+ return { ok: true, markdown: doc.readme, repo: parseGithubRepo(doc.repository), file: doc.readmeFilename || "README.md" };
568
+ }
569
+ }
570
+ const full = await fetchReadmeText(`${NPM_REGISTRY}/${encoded}`, "application/json");
571
+ if (!full.ok) {
572
+ return { ok: false, error: full.error };
573
+ }
574
+ let packument = null;
575
+ try {
576
+ packument = JSON.parse(full.text);
577
+ } catch {
578
+ return { ok: false, error: "npm 返回的不是合法 JSON" };
579
+ }
580
+ const markdown = packument && typeof packument.readme === "string" ? packument.readme : "";
581
+ if (!markdown.trim()) {
582
+ return { ok: false, error: latest.ok ? "npm 包没有 README" : `${latest.error};npm 包没有 README` };
583
+ }
584
+ return { ok: true, markdown, repo: parseGithubRepo(packument.repository), file: packument.readmeFilename || "README.md" };
585
+ }
586
+
587
+ /**
588
+ * GitHub 侧 README 的候选基底,按「先直连、后被墙可用」排序。
589
+ * raw.githubusercontent.com 在部分网络(含国内常见环境)会直接 DNS 失败,
590
+ * 因此补 jsDelivr 的 GitHub 镜像(@main / @master 两个默认分支都试)。
591
+ * 每个基底各自独立:谁先拿到正文,就用谁的目录当相对图片的解析基准。
592
+ */
593
+ function readmeGithubBases(repo) {
594
+ return [
595
+ `https://raw.githubusercontent.com/${repo}/HEAD/`,
596
+ `https://cdn.jsdelivr.net/gh/${repo}@main/`,
597
+ `https://cdn.jsdelivr.net/gh/${repo}@master/`,
598
+ `https://fastly.jsdelivr.net/gh/${repo}@main/`,
599
+ `https://fastly.jsdelivr.net/gh/${repo}@master/`,
600
+ ];
601
+ }
602
+
603
+ /**
604
+ * GitHub 侧取 README:按 README_FILES 顺序在候选基底上试。
605
+ * 一旦某个基底返回过任何 HTTP 响应,就认定网络可达:此时把该基底的
606
+ * 文件名全试完即可收工(不再换基底,避免对一个没 README 的仓库打 30 次请求)。
607
+ */
608
+ async function readmeFromGithub(repo) {
609
+ let lastError = "";
610
+ let sawReachable = false;
611
+ for (const base of readmeGithubBases(repo)) {
612
+ for (const file of README_FILES) {
613
+ const attempt = await fetchReadmeText(`${base}${file}`, "text/plain");
614
+ if (attempt.ok && attempt.text.trim()) {
615
+ return { ok: true, markdown: attempt.text, base, file };
616
+ }
617
+ if (attempt.ok) {
618
+ lastError = "文件为空";
619
+ sawReachable = true;
620
+ continue;
621
+ }
622
+ if (attempt.reachable) {
623
+ lastError = attempt.error;
624
+ sawReachable = true;
625
+ continue;
626
+ }
627
+ lastError = attempt.error;
628
+ break; // 该基底整体不可达(DNS/超时),换下一个
629
+ }
630
+ if (sawReachable) break;
631
+ }
632
+ return { ok: false, error: lastError || "未找到 README" };
633
+ }
634
+
635
+ /**
636
+ * 读取某个市场条目的 README,供客户端详情弹窗预览。
637
+ *
638
+ * 入参:`{ name, npm, repo, refresh }`(`npm` 可传字符串或 `{name}`)。
639
+ * 返回:`{ ok, reason: "cache" | "network" | "no-source" | "readme-unavailable",
640
+ * markdown, truncated, source: "npm" | "github", base, repo, npm, fetchedAt }`。
641
+ *
642
+ * `base` 是相对图片/链接的解析基准(GitHub raw 目录),客户端据此把 README 里的
643
+ * 相对路径 `assets/a.png` 还原成可加载的绝对 URL —— 不还原的话插图全是坏图。
644
+ */
645
+ export async function wbAwesomeReadme(payload = {}) {
646
+ const name = typeof payload.name === "string" ? payload.name.trim() : "";
647
+ const npmName =
648
+ typeof payload.npm === "string"
649
+ ? payload.npm.trim()
650
+ : payload.npm && typeof payload.npm === "object" && typeof payload.npm.name === "string"
651
+ ? payload.npm.name.trim()
652
+ : "";
653
+ let repo = parseGithubRepo(payload.repo);
654
+ const wantRefresh = payload.refresh === true;
655
+ if (!npmName && !repo) {
656
+ return { ok: false, reason: "no-source", name, npm: "", repo: "", markdown: "", truncated: false, source: "", base: "", fetchedAt: 0, error: "该条目没有 npm 包名或 GitHub 仓库地址,无法读取 README" };
657
+ }
658
+ const cacheKey = npmName || repo;
659
+ const cachePath = readmeCachePath(cacheKey, payload);
660
+ if (!wantRefresh) {
661
+ const cached = await readJsonAt(cachePath);
662
+ if (
663
+ cached &&
664
+ typeof cached.markdown === "string" &&
665
+ cached.markdown &&
666
+ Date.now() - Number(cached.fetchedAt || 0) < README_CACHE_TTL_MS
667
+ ) {
668
+ return {
669
+ ok: true,
670
+ reason: "cache",
671
+ name,
672
+ npm: npmName,
673
+ repo: typeof cached.repo === "string" ? cached.repo : repo,
674
+ markdown: cached.markdown,
675
+ truncated: Boolean(cached.truncated),
676
+ source: typeof cached.source === "string" ? cached.source : "",
677
+ base: typeof cached.base === "string" ? cached.base : "",
678
+ fetchedAt: Number(cached.fetchedAt || 0),
679
+ };
680
+ }
681
+ }
682
+ let markdown = "";
683
+ let source = "";
684
+ let base = "";
685
+ const errors = [];
686
+ if (npmName) {
687
+ const fromNpm = await readmeFromNpm(npmName);
688
+ if (fromNpm.ok) {
689
+ markdown = fromNpm.markdown;
690
+ source = "npm";
691
+ repo = repo || parseGithubRepo(fromNpm.repo);
692
+ base = repo ? `https://raw.githubusercontent.com/${repo}/HEAD/` : "";
693
+ } else {
694
+ errors.push(`npm:${fromNpm.error}`);
695
+ }
696
+ }
697
+ if (!markdown && repo) {
698
+ const fromGithub = await readmeFromGithub(repo);
699
+ if (fromGithub.ok) {
700
+ markdown = fromGithub.markdown;
701
+ source = "github";
702
+ base = fromGithub.base;
703
+ } else {
704
+ errors.push(`github:${fromGithub.error}`);
705
+ }
706
+ }
707
+ if (!markdown.trim()) {
708
+ return { ok: false, reason: "readme-unavailable", name, npm: npmName, repo, markdown: "", truncated: false, source: "", base: "", fetchedAt: 0, error: `读取 README 失败:${errors.join(";") || "没有可用来源"}` };
709
+ }
710
+ const normalized = normalizeReadme(markdown);
711
+ const fetchedAt = Date.now();
712
+ try {
713
+ await writeAtomic(
714
+ cachePath,
715
+ JSON.stringify({ name, npm: npmName, repo, markdown: normalized.markdown, truncated: normalized.truncated, source, base, fetchedAt }),
716
+ );
717
+ } catch {
718
+ // 缓存写不进去不影响这次预览
719
+ }
720
+ return { ok: true, reason: "network", name, npm: npmName, repo, markdown: normalized.markdown, truncated: normalized.truncated, source, base, fetchedAt };
721
+ }
@@ -1,6 +1,6 @@
1
1
  // 本地安装状态 + 来源锁(R8)—— ~/.dsh/armory/install-state.json 的读写层。
2
2
  //
3
- // 职责(设计文档 docs/system_design.md 8.3 / 类图 InstallStateStore):
3
+ // 职责(对应设计 §8.3,类图 InstallStateStore):
4
4
  // - record(item):按 kind+key 定位,已存在则覆盖并刷新 lastUpdated;
5
5
  // 首次写入补 installedAt。connector 不记 filesDigest,plugin/skill 记关键文件摘要。
6
6
  // - forget(kind, key):删除一条登记,不存在时为无害空操作。
package/lib/wb-market.js CHANGED
@@ -420,8 +420,23 @@ export async function wbConnectorCachedCount() {
420
420
  // 文件:/api/v1/skills/:slug/files?namespace=<handle>[&version=];/file 同形 + &path=
421
421
  // namespace 用 namespace.handle(列表项里是对象), 不是 canonicalName。
422
422
  const SKILLHUB_SORTS = new Set(["downloads", "installs", "stars", "score"]);
423
- async function skillhubJson(path) {
424
- const buffer = await fetchWithLimits(`${SKILLHUB_ORIGIN}${path}`);
423
+ // 2026-10 实测(api.skillhub.cn):`sortBy=downloads` 已不再是可用值——TCP 能连上、
424
+ // 但服务端不回任何字节,一直挂到客户端 AbortSignal 超时;installs / stars / score /
425
+ // updated 仍正常,非法值(download、hot、new…)会立刻 400 而不是挂起。
426
+ // 所以下载量排序不再发给上游,改成本地按当前页排序(见 wbSkillCatalog 尾部)。
427
+ const SKILLHUB_REMOTE_SORTS = new Set(["installs", "stars", "score"]);
428
+ async function skillhubJson(path, { timeoutMs = 15_000 } = {}) {
429
+ let buffer;
430
+ try {
431
+ buffer = await fetchWithLimits(`${SKILLHUB_ORIGIN}${path}`, { timeoutMs });
432
+ } catch (e) {
433
+ // 超时是上游最常见的一种失败(挂起而非 4xx),把 Node 的英文原文换成能看懂的中文。
434
+ const name = e && e.name;
435
+ if (name === "TimeoutError" || name === "AbortError") {
436
+ throw new Error(`SkillHub 在 ${Math.round(timeoutMs / 1000)} 秒内没有响应,已放弃本次请求;稍后重试即可。`);
437
+ }
438
+ throw e;
439
+ }
425
440
  const body = JSON.parse(buffer.toString("utf8"));
426
441
  // SkillHub 的业务错误也在 200 里(code !== 0)。
427
442
  if (body && typeof body.code === "number" && body.code !== 0) throw new Error(`SkillHub 返回错误:${body.message || body.code}`);
@@ -431,13 +446,38 @@ export async function wbSkillCatalog(payload = {}) {
431
446
  const page = Math.max(1, Math.min(200, Number(payload.page) || 1));
432
447
  const pageSize = Math.max(1, Math.min(60, Number(payload.pageSize) || 20));
433
448
  const sort = SKILLHUB_SORTS.has(payload.sort) ? payload.sort : "downloads";
434
- const params = new URLSearchParams({ sortBy: sort, page: String(page), pageSize: String(pageSize) });
449
+ const remoteSort = SKILLHUB_REMOTE_SORTS.has(sort) ? sort : "";
450
+ // 只把上游确认可用的排序发出去:`sortBy=downloads` 会挂起(见 SKILLHUB_REMOTE_SORTS)。
451
+ // 不带 sortBy 时上游按自己的默认序返回,参数缺失不会 400(已实测)。
452
+ const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
453
+ if (remoteSort) params.set("sortBy", remoteSort);
435
454
  const keyword = String(payload.keyword || "").trim();
436
455
  if (keyword) params.set("keyword", keyword.slice(0, 64));
437
456
  const category = String(payload.category || "").trim();
438
457
  if (category) params.set("category", category.slice(0, 64));
439
458
  const body = await skillhubJson(`/api/skills?${params.toString()}`);
440
459
  const skills = Array.isArray(body?.data?.skills) ? body.data.skills : [];
460
+ const rows = skills.map((skill) => ({
461
+ slug: String(skill.slug || ""),
462
+ name: String(skill.name || skill.slug || ""),
463
+ namespace: String(skill.namespace?.handle || ""),
464
+ canonical: String(skill.namespace?.canonicalName || ""),
465
+ description: String(skill.description_zh || skill.description || ""),
466
+ descriptionEn: String(skill.description_en || ""),
467
+ downloads: Number(skill.downloads) || 0,
468
+ installs: Number(skill.installs) || 0,
469
+ stars: Number(skill.stars) || 0,
470
+ score: Number(skill.score) || 0,
471
+ version: String(skill.version || ""),
472
+ verified: !!skill.verified,
473
+ updatedAt: Number(skill.updated_at) || 0,
474
+ category: String(skill.category || ""),
475
+ tags: Array.isArray(skill.tags) ? skill.tags.slice(0, 6).map(String) : [],
476
+ upstreamUrl: String(skill.upstream_url || skill.homepage || ""),
477
+ })).filter((skill) => skill.slug && skill.namespace);
478
+ // 下载量排序上游已不可用,退化为「当前页内按下载量降序」:跨页顺序仍是上游默认序,
479
+ // 所以这是页内排序而不是全库排序(UI 侧「最多下载」的含义随之收窄,见下)。
480
+ if (!remoteSort) rows.sort((a, b) => b.downloads - a.downloads);
441
481
  return {
442
482
  page,
443
483
  pageSize,
@@ -445,24 +485,7 @@ export async function wbSkillCatalog(payload = {}) {
445
485
  keyword,
446
486
  category,
447
487
  total: Number(body?.data?.total) || 0,
448
- skills: skills.map((skill) => ({
449
- slug: String(skill.slug || ""),
450
- name: String(skill.name || skill.slug || ""),
451
- namespace: String(skill.namespace?.handle || ""),
452
- canonical: String(skill.namespace?.canonicalName || ""),
453
- description: String(skill.description_zh || skill.description || ""),
454
- descriptionEn: String(skill.description_en || ""),
455
- downloads: Number(skill.downloads) || 0,
456
- installs: Number(skill.installs) || 0,
457
- stars: Number(skill.stars) || 0,
458
- score: Number(skill.score) || 0,
459
- version: String(skill.version || ""),
460
- verified: !!skill.verified,
461
- updatedAt: Number(skill.updated_at) || 0,
462
- category: String(skill.category || ""),
463
- tags: Array.isArray(skill.tags) ? skill.tags.slice(0, 6).map(String) : [],
464
- upstreamUrl: String(skill.upstream_url || skill.homepage || ""),
465
- })).filter((skill) => skill.slug && skill.namespace),
488
+ skills: rows,
466
489
  };
467
490
  }
468
491
  export async function wbSkillDetail(payload = {}) {
@@ -1,6 +1,6 @@
1
1
  // 多市场注册表(R4)—— ~/.dsh/armory/marketplaces.json 的读写层。
2
2
  //
3
- // 职责(设计文档 docs/system_design.md 8.3 / 类图 MarketRegistry):
3
+ // 职责(对应设计 §8.3,类图 MarketRegistry):
4
4
  // - 注册表文件不存在或损坏时,回落到**内置默认两条**:连接器源(与 lib/wb-market.js:27
5
5
  // 的 CONNECTOR_ZIP_URL 保持同一现值,禁止在此发明第二处硬编码真相)与
6
6
  // codebuddy-plugins-official 插件源;首读时把默认项落盘。
package/lib/whats-new.js CHANGED
@@ -370,4 +370,50 @@ export const WHATS_NEW = [
370
370
  },
371
371
  ],
372
372
  },
373
+ {
374
+ version: '2.1.0',
375
+ items: [
376
+ {
377
+ text: '**社区插件能看 README 了**:点开 DSH 社区里的任意一张卡片,就地把该仓库的 README 拉下来渲染成可读正文(标题、段落、列表、代码块、表格、引用),不用跳浏览器。只在点开时按需取一次(20s 超时 / 16 万字符上限),取到后按「仓库或 npm 包名」落盘缓存 7 天,同一插件再点秒开、断网也能看。图片走只读代理绕开防盗链;渲染器是自写的,**不碰 `dangerouslySetInnerHTML`**,`javascript:` 这类伪协议链接一律降级成纯文本',
378
+ where: '插件 → DSH 社区 → 点开任意卡片',
379
+ },
380
+ {
381
+ text: '**社区目录不再卡在旧快照上**:首屏先渲染 localStorage 快照(0 网络 0 IPC),但真实目录结果回来后必须覆盖快照。之前快照一渲染,真实结果就被丢掉,目录于是一直停在旧快照上——现在两条路径都落到同一份状态,秒开与新鲜兼得',
382
+ where: '插件 → DSH 社区',
383
+ },
384
+ {
385
+ text: '**「更新探测失败」修好了**:过去探测只问主源,主源不通、目录靠备用源加载成功的机器上,目录能开、探测却每次都报失败。现在探测与目录走同一条源链——先问这份缓存实际来自哪个源(ETag 同源才可比),再兜底其余源,首个拿到 ETag 的源胜出,全部失败才报 `probe-failed`',
386
+ where: '插件 → DSH 社区 → 检查更新',
387
+ },
388
+ ],
389
+ },
390
+ {
391
+ version: '2.2.0',
392
+ items: [
393
+ {
394
+ text: '**自定义套件**:把常一起用的 skill 打包成一套,召唤菜单里点一次,就按成员顺序展开成多个 `/name` 插进草稿。Skill 看板工具栏「套件」里新建 / 编辑:点 + 或把技能拖进来添加,拖拽排序,删除要就地二次确认;保存是整集合覆盖,落 `~/.dsh/armory/suites.json`(staging + rename 原子写)。上限 100 个套件 / 每套 40 个成员',
395
+ where: '技能 →「套件」;输入框召唤菜单 →「技能」→ 套件分组',
396
+ },
397
+ {
398
+ text: '**技能看板能卸载了**:用户级 / 项目级目录里的 skill 可在详情里直接卸载(就地二次确认,永久删除、不进回收站);随 DSH 分发、运行时注册与自定义目录的 skill 仍只读。卸载走本插件的 `removeSkill`,带来源白名单、根目录包含性、符号链接只删链接等校验',
399
+ where: '技能 → 任一本机 skill → 详情',
400
+ },
401
+ {
402
+ text: '**装 / 卸即时刷新**:在市场里安装或卸载技能后,顶部 **Skill** 数量角标与技能看板列表立即对齐新集合,不用切页或重开面板;异步重取带序号守卫,快速连点也不会被旧回包回滚',
403
+ where: '市场 ↔ 技能',
404
+ },
405
+ {
406
+ text: '**工具裁缝补上时间维度**:按天采样 `request/header` 里**真实下发**的工具清单,算出线上真实税,并给出「窗口内清单变过几次」「最近一次变化时间」;与注册表快照不一致时直接说明是「改了没生效」还是「线上多发了工具」。本机日志跨度短于所选窗口、或有调用缺时间戳时,也会在说明栏明示',
407
+ where: '工具裁缝 → 指标卡下方的税趋势',
408
+ },
409
+ {
410
+ text: '**SkillHub「下载量」排序不再挂起**:上游 `sortBy=downloads` 现在收下请求却不回包,插件改为不把它发给上游、在当前页内按下载量降序;其余排序照常走远程。请求 15 秒无响应即放弃,并把 Node 的英文超时换成中文说明',
411
+ where: '市场 → 技能 → 排序',
412
+ },
413
+ {
414
+ text: '工具裁缝账本缓存行改为只读:删掉「清空缓存」按钮(实测既不回收空间、也不修任何事,还会一直停在「正在清空缓存…」)',
415
+ where: '工具裁缝 → 账本信息',
416
+ },
417
+ ],
418
+ },
373
419
  ]
@@ -310,7 +310,7 @@ function releaseSharedConnection(ctx, entry) {
310
310
  // 上游 @modelcontextprotocol/client 的 stdio transport 里,`close()` 等的是子进程的 `'close'`
311
311
  // 事件;孙进程(npx/uvx 包装器、server 内部的 helper)继承并持有 stdio 管道时,`'close'`
312
312
  // 永不触发,回收就永久挂在等待上。既无法从这侧打断,就只能把「已经等了多久、有没有超阈、
313
- // 上次花了多久」变成可见事实,让人自己决定重启宿主。上游提案见 docs/host-proposal-mcp-teardown.md。
313
+ // 上次花了多久」变成可见事实,让人自己决定重启宿主。上游提案的结论见 .github/release-notes/v1.10.0.md。
314
314
  //
315
315
  // OPT-15:每次 teardown 的耗时落档,按 app root 分桶,桶内按 connKey 只留最近一条。
316
316
  // 只留一条的理由:面板要回答的是「这条连接现在还卡不卡」,不是审计历史;留全量会让
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-armory",
3
- "version": "2.0.0",
4
- "description": "Unified Skill + MCP + Expert + Plugin board for DeepSeek Harness (desktop app and Web GUI) — global panel page, JSON import, WorkBuddy connector & SkillHub & Expert & CodeBuddy plugin & awesome-dsh-plugins market installs, MCP OAuth authorization, tool-usage tailor, and profile-backed Host Remote management.",
3
+ "version": "2.2.0",
4
+ "description": "Unified Skill + MCP + Expert + Plugin board for DeepSeek Harness (desktop app and Web GUI) — custom skill suites, skill install/uninstall with live refresh, global panel page, JSON import, WorkBuddy connector & SkillHub & Expert & CodeBuddy plugin & awesome-dsh-plugins market installs, MCP OAuth authorization, tool-usage tailor with wire-tax trends, and profile-backed Host Remote management.",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/2026heshao/dsh-armory.git"