@noob-stupid/dsh-plugin-console 0.3.48 → 0.3.50

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/README.md CHANGED
@@ -308,7 +308,7 @@ MIT
308
308
 
309
309
  The layered-refactor preview lives in its **own repository**: [**`Noob-stupid/dsh-plugin-hub-refactor`**](https://github.com/Noob-stupid/dsh-plugin-hub-refactor)
310
310
 
311
- - **What it is**: the monolithic `lib/index.js` (7637 lines) split into layers — `lib/index.js` **142 lines** + `lib/server/**` **36 modules**; **feature-equivalent to `0.3.48`**, maintainability only;
311
+ - **What it is**: the monolithic `lib/index.js` (7637 lines) split into layers — `lib/index.js` **142 lines** + `lib/server/**` **36 modules**; **feature-equivalent to `0.3.50`**, maintainability only;
312
312
  - **Verified**: 18 test suites green · 8 architecture-guard assertions · **47/47 routes identical** (`status` + response fields) against the stable build;
313
313
  - **Not yet exercised**: real framework upgrade / rollback, restart guardian, real install/uninstall, component start/stop, real AI run, Gitee OAuth (~10–25% long-tail risk, subjective);
314
- - **For everyday use** stick to npm `@noob-stupid/dsh-plugin-console@0.3.48`, or `main` of this repo.
314
+ - **For everyday use** stick to npm `@noob-stupid/dsh-plugin-console@0.3.50`, or `main` of this repo.
package/README.zh.md CHANGED
@@ -275,8 +275,8 @@ MIT
275
275
 
276
276
  分层重构预览线在**独立仓库**:[**`Noob-stupid/dsh-plugin-hub-refactor`**](https://github.com/Noob-stupid/dsh-plugin-hub-refactor)
277
277
 
278
- - **它是什么**:把单体 `lib/index.js`(7637 行)拆成分层结构 —— `lib/index.js` **142 行** + `lib/server/**` **36 个模块**,**功能与 `0.3.48` 等价**,只为可维护性;
278
+ - **它是什么**:把单体 `lib/index.js`(7637 行)拆成分层结构 —— `lib/index.js` **142 行** + `lib/server/**` **36 个模块**,**功能与 `0.3.50` 等价**,只为可维护性;
279
279
  - **已验证**:18 套测试全绿 · 8 条架构守卫断言 · 与稳定版逐条对打 **47 条路由,`status` + 响应字段 47/47 一致**;
280
280
  - **尚未实测**:真框架升级 / 真回滚、重启守护链路、真装真卸、组件进程启停、AI 真跑、Gitee OAuth 回调(长尾风险主观估计 **10%~25%**);
281
- - **日常使用请继续用**:npm `@noob-stupid/dsh-plugin-console@0.3.48`,或本仓库 `main`。
281
+ - **日常使用请继续用**:npm `@noob-stupid/dsh-plugin-console@0.3.50`,或本仓库 `main`。
282
282
  - 请多反馈问题
package/lib/client.js CHANGED
@@ -338,6 +338,9 @@ window.__ModuleLoader__.load({
338
338
  gitUrlPlaceholder: "如 https://gitee.com/{owner}/{repo}.git",
339
339
  gitSourceBtn: "Git 源",
340
340
  marketIndexOffline: "离线数据 · 缓存于 {time}(索引源不可达,已回退上次成功结果)",
341
+ marketIndexStale: "市场索引未加载:搜索只覆盖 GitHub 实时结果(仓库名/描述/topics),收录条目与本地索引模糊匹配都用不上",
342
+ marketIndexRetry: "重试加载索引",
343
+ searchLoginHint: "未登录 GitHub:代码搜索不可用(按子包名搜索需要登录);仓库搜索仍可用",
341
344
  marketIndexFrom: "索引来源:{source}",
342
345
  marketIndexFailed: "市场索引加载失败",
343
346
  unknown: "未知",
@@ -709,6 +712,9 @@ window.__ModuleLoader__.load({
709
712
  gitUrlPlaceholder: "e.g. https://gitee.com/{owner}/{repo}.git",
710
713
  gitSourceBtn: "Git sources",
711
714
  marketIndexOffline: "Offline data · cached {time} (index sources unreachable, showing last successful result)",
715
+ marketIndexStale: "Market index not loaded: search only covers live GitHub results (name/description/topics); curated entries and local fuzzy matching are unavailable",
716
+ marketIndexRetry: "Retry loading index",
717
+ searchLoginHint: "GitHub not signed in: code search is unavailable (searching by subpackage name needs sign-in); repository search still works",
712
718
  marketIndexFrom: "Index source: {source}",
713
719
  marketIndexFailed: "Failed to load market index",
714
720
  unknown: "unknown",
@@ -1152,6 +1158,8 @@ window.__ModuleLoader__.load({
1152
1158
  const [updateCheck, setUpdateCheck] = react.useState(null);
1153
1159
  // 静态插件索引(dsh-plugin topic 全量浏览):null=未加载 / {items, skills, loaded}
1154
1160
  const [indexData, setIndexData] = react.useState(null);
1161
+ // 索引加载失败原因:null = 还没失败过(用于市场页顶部"索引未加载 + 重试"提示)
1162
+ const [indexError, setIndexError] = react.useState(null);
1155
1163
  // 索引浏览当前展示条数(分页展示,避免一次渲染 500 条)
1156
1164
  const [indexShown, setIndexShown] = react.useState(50);
1157
1165
  // 已补标(官方/聚合标记)到的索引位置:随「加载更多」增量推进,避免一次性发上千请求
@@ -1725,11 +1733,15 @@ window.__ModuleLoader__.load({
1725
1733
  () => {},
1726
1734
  );
1727
1735
  }, []);
1728
- // 挂载时加载静态插件索引(GitHub 源空查询时秒开浏览,零 API 调用)
1729
- react.useEffect(() => {
1736
+ // 加载静态插件索引(GitHub 源空查询时秒开浏览,零 API 调用)。
1737
+ // 失败时记下原因:市场页顶部给出"索引未加载 → 只能搜 GitHub 实时结果"的提示 + 重试按钮
1738
+ //(2026-09-20 事故:另一位用户索引源全挂 + 未登录,三条检索路全断,搜 web-all 搜不到 dsh-web,
1739
+ // 而界面上只有一句"市场索引加载失败",看不出后果也没有重试入口)
1740
+ const loadMarketIndex = () => {
1730
1741
  call("/plugin-console/market-index", {}).then(
1731
1742
  (data) => {
1732
1743
  if (data && Array.isArray(data.items)) {
1744
+ setIndexError(null);
1733
1745
  setIndexData({
1734
1746
  items: data.items,
1735
1747
  skills: Array.isArray(data.skills) ? data.skills : [],
@@ -1748,8 +1760,15 @@ window.__ModuleLoader__.load({
1748
1760
  }
1749
1761
  }
1750
1762
  },
1751
- (error) => setMessage(t("marketIndexFailed") + ":" + (error?.message ?? t("unknown"))),
1763
+ (error) => {
1764
+ const text = error?.message ?? t("unknown");
1765
+ setIndexError(text);
1766
+ setMessage(t("marketIndexFailed") + ":" + text);
1767
+ },
1752
1768
  );
1769
+ };
1770
+ react.useEffect(() => {
1771
+ loadMarketIndex();
1753
1772
  // 市场状态记忆:关闭前搜索过 → 重开时自动恢复该搜索(query 已从 localStorage 初始化)
1754
1773
  if (query !== "") {
1755
1774
  search();
@@ -1994,6 +2013,36 @@ window.__ModuleLoader__.load({
1994
2013
  scored.sort((a, b) => b.s - a.s);
1995
2014
  return scored.slice(0, 12).map((x) => x.it);
1996
2015
  };
2016
+ /** 合并服务端"增量检索"结果(npm 包名映射 / in:readme 重查 / 代码搜索子包):
2017
+ * npm 命中置顶,其余按增量顺序追加,最后接原有直连结果,全部按 fullName 去重。
2018
+ * 背景(2026-09-20):浏览器直连 GitHub 搜索成功时不会走服务端路由,而未登录用户恰恰只能走直连
2019
+ * →「只存在于 npm 包名 / README / 仓库文件里」的名字(如 web-all)永远补不上。 */
2020
+ function mergeSearchExtras(base, extras) {
2021
+ const seen = new Set();
2022
+ const npm = [];
2023
+ const rest = [];
2024
+ for (const it of extras) {
2025
+ const key = it.fullName ?? JSON.stringify(it);
2026
+ if (seen.has(key)) continue;
2027
+ seen.add(key);
2028
+ if (it.npmPackage === true) npm.push(it); else rest.push(it);
2029
+ }
2030
+ const out = [...npm, ...rest];
2031
+ for (const it of base) {
2032
+ const key = it.fullName ?? JSON.stringify(it);
2033
+ if (seen.has(key)) continue;
2034
+ seen.add(key);
2035
+ out.push(it);
2036
+ }
2037
+ return out;
2038
+ }
2039
+ /** 用 enrich 结果**就地更新**市场列表(按 fullName 打补丁),而不是整表替换——
2040
+ * 否则并行补进来的「增量检索」条目(npm 包名/子包)会被 enrich 回调冲掉。 */
2041
+ const applyEnriched = (enriched) => setMarket((m) => {
2042
+ if (m === null || m.status !== "ready" || !Array.isArray(enriched)) return m;
2043
+ const byName = new Map(enriched.map((x) => [x.fullName, x]));
2044
+ return { ...m, data: m.data.map((x) => byName.get(x.fullName) ?? x) };
2045
+ });
1997
2046
  const search = () => {
1998
2047
  setMarket({ status: "loading" });
1999
2048
  setRepoInfo(null);
@@ -2044,7 +2093,7 @@ window.__ModuleLoader__.load({
2044
2093
  (data) => {
2045
2094
  setMarket({ status: "ready", data: data.items, direct: false, source: searchSource });
2046
2095
  enrichOfficialBundle(data.items).then((enriched) => {
2047
- setMarket((m) => (m !== null && m.status === "ready" ? { ...m, data: enriched } : m));
2096
+ applyEnriched(enriched);
2048
2097
  });
2049
2098
  },
2050
2099
  (error) => setMarket({ status: "error", error: friendlyGithubError(error) }),
@@ -2058,20 +2107,32 @@ window.__ModuleLoader__.load({
2058
2107
  // 并行补标记:
2059
2108
  // ① 客户端浏览器直连读根包 package.json(快,官方/聚合标记立即可筛)
2060
2109
  enrichOfficialBundle(data).then((enriched) => {
2061
- setMarket((m) => (m !== null && m.status === "ready" ? { ...m, data: enriched } : m));
2110
+ applyEnriched(enriched);
2062
2111
  });
2063
2112
  // ② 服务端 curl 双通道 enrich(聚合子包 dsh.bundle 检查 → aggregateInstallable),后台补更精确标记
2064
2113
  call("/plugin-console/enrich", { items: data }).then(
2065
- (r) => { if (r && Array.isArray(r.items)) setMarket((m) => (m !== null && m.status === "ready" ? { ...m, data: r.items } : m)); },
2114
+ (r) => { if (r && Array.isArray(r.items)) applyEnriched(r.items); },
2066
2115
  () => {},
2067
2116
  );
2117
+ // ③ 服务端"增量检索"(并行、不阻塞首屏):npm 包名映射 + in:readme 重查 + 代码搜索子包。
2118
+ // 直连搜索只覆盖 仓库名/描述/topics,包名与 README/文件里的名字补不上(2026-09-20 事故)
2119
+ if (query.trim() !== "") {
2120
+ call("/plugin-console/search", { q: query, page: 1, extras: true, all: repoLandOn === true }).then(
2121
+ (r) => {
2122
+ if (r !== null && r.codeSearchSkipped === true) setMessage(t("searchLoginHint"));
2123
+ if (r === null || !Array.isArray(r.items) || r.items.length === 0) return;
2124
+ setMarket((m) => (m !== null && m.status === "ready" ? { ...m, data: mergeSearchExtras(m.data, r.items) } : m));
2125
+ },
2126
+ () => {},
2127
+ );
2128
+ }
2068
2129
  },
2069
2130
  () => call("/plugin-console/search", { q: query, page: 1 }).then(
2070
2131
  (data) => {
2071
2132
  const items = data.items;
2072
2133
  setMarket({ status: "ready", data: items, direct: false });
2073
2134
  enrichOfficialBundle(items).then((enriched) => {
2074
- setMarket((m) => (m !== null && m.status === "ready" ? { ...m, data: enriched } : m));
2135
+ applyEnriched(enriched);
2075
2136
  });
2076
2137
  },
2077
2138
  (error) => setMarket({ status: "error", error: friendlyGithubError(error) }),
@@ -2561,7 +2622,7 @@ window.__ModuleLoader__.load({
2561
2622
  startSuiteJob(item);
2562
2623
  return;
2563
2624
  }
2564
- if (item.subpackagePath && typeof item.packageName === "string" && item.packageName !== "") {
2625
+ if ((item.subpackagePath || item.npmPackage === true) && typeof item.packageName === "string" && item.packageName !== "") {
2565
2626
  // monorepo 子包:搜索结果带 packageName,按 npm 包名安装
2566
2627
  startJob(item.fullName, item.packageName, item.source ?? "github");
2567
2628
  return;
@@ -3796,6 +3857,17 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
3796
3857
  t("marketIndexOffline").replace("{time}", indexData.cachedAt !== null ? new Date(indexData.cachedAt).toLocaleString() : "—")
3797
3858
  + (indexData.sourceName !== null ? " · " + t("marketIndexFrom").replace("{source}", indexData.sourceName) : ""))
3798
3859
  : null,
3860
+ // 索引彻底没加载成功:说清后果(只剩 GitHub 实时结果)+ 给重试入口。
3861
+ // 2026-09-20 事故:这种状态下"包名/README/子包里的名字"搜不到,而界面上只有一句加载失败
3862
+ indexData === null && indexError !== null
3863
+ ? el("p", { className: styles.message, "data-warn": "true" },
3864
+ t("marketIndexStale") + " ",
3865
+ el("button", {
3866
+ type: "button",
3867
+ className: styles.toggle,
3868
+ onClick: () => loadMarketIndex(),
3869
+ }, t("marketIndexRetry")))
3870
+ : null,
3799
3871
  el("div", { className: styles.search, id: "pc-market-search" },
3800
3872
  el("div", { className: styles.searchInner },
3801
3873
  el("input", {
package/lib/index.js CHANGED
@@ -593,7 +593,7 @@ async function removeBundleFromManifest(profileDir, bundlePkg) {
593
593
 
594
594
  /** 用 corepack pnpm 移除包(与安装同一管理器与凭据抑制环境)。
595
595
  * 注意:pnpm remove 不支持 --registry 选项,去掉避免静默失败。 */
596
- async function pnpmRemove(profileDir, packageName) {
596
+ export async function pnpmRemove(profileDir, packageName) {
597
597
  const args = ['remove', packageName]
598
598
  const opts = {
599
599
  cwd: profileDir,
@@ -636,7 +636,7 @@ function cleanupStalePackageDir(profileDir, packageName) {
636
636
  const SOURCES_FILE = join(dshHome(), 'plugin-console-sources.json')
637
637
  /** 敏感凭据单独落盘(避免与可分享配置混存):自定义搜索源 headers + Gitee secret/token。 */
638
638
  const SOURCES_SECRETS_FILE = join(dshHome(), 'plugin-console-sources.secrets.json')
639
- const DEFAULT_SOURCES = {
639
+ export const DEFAULT_SOURCES = {
640
640
  registries: [
641
641
  { id: 'npmmirror', name: 'npmmirror(国内镜像)', url: 'https://registry.npmmirror.com', primary: true },
642
642
  { id: 'npmjs', name: 'npmjs(官方源)', url: 'https://registry.npmjs.org', primary: false },
@@ -646,9 +646,16 @@ const DEFAULT_SOURCES = {
646
646
  { id: 'gitee', name: 'Gitee', type: 'builtin' },
647
647
  ],
648
648
  // 市场静态索引源(按主→备依次尝试;内网可整体替换为自建镜像,实现完全离线的市场浏览)
649
+ // 2026-09-20 扩容 2 → 5:只有 jsDelivr + ghproxy 两个源时,两者同时不可达就会让整个市场退化成
650
+ // 「只能搜 GitHub 实时结果」——收录清单(含 ★7800 全家桶)与本地索引模糊匹配一起失效
651
+ // (另一位用户实测报「市场索引加载失败:网络不可达(2 个索引源全部失败):GitHub 请求超时」)。
652
+ // jsDelivr 官方多入口互为主备:cdn(主)/ gcore / fastly;再加 ghproxy 与 raw 直连兜底。
649
653
  indexSources: [
650
654
  { id: 'jsdelivr', name: 'jsDelivr CDN', url: 'https://cdn.jsdelivr.net/gh/Noob-stupid/dsh-plugin-hub@main/marketplace/index.json', primary: true },
655
+ { id: 'jsdelivr-gcore', name: 'jsDelivr (gcore)', url: 'https://gcore.jsdelivr.net/gh/Noob-stupid/dsh-plugin-hub@main/marketplace/index.json', primary: false },
656
+ { id: 'jsdelivr-fastly', name: 'jsDelivr (fastly)', url: 'https://fastly.jsdelivr.net/gh/Noob-stupid/dsh-plugin-hub@main/marketplace/index.json', primary: false },
651
657
  { id: 'ghproxy', name: 'ghproxy 镜像', url: 'https://ghproxy.net/https://raw.githubusercontent.com/Noob-stupid/dsh-plugin-hub/main/marketplace/index.json', primary: false },
658
+ { id: 'raw', name: 'GitHub raw 直连', url: 'https://raw.githubusercontent.com/Noob-stupid/dsh-plugin-hub/main/marketplace/index.json', primary: false },
652
659
  ],
653
660
  // Git 克隆源({owner}/{repo} 占位符;按主→备依次尝试)。
654
661
  // 可替换为 Gitee / GitLab / 自建 Gitea / 任意镜像代理,实现「换一个网站下载仓库内容」。
@@ -2250,6 +2257,130 @@ async function fetchRepoPackage(repo, branch) {
2250
2257
  return (await fetchRepoPackageEx(repo, branch)).pkg
2251
2258
  }
2252
2259
 
2260
+ /** 从 npm 的 repository 字段解析出 GitHub/Gitee 仓库标识(纯函数,单测覆盖)。
2261
+ * 支持 `https://github.com/o/r.git`、`git+https://…`、`git://…`、带 `#path` 的 monorepo 写法。 */
2262
+ export function parseRepoFromUrl(url) {
2263
+ const raw = String(url ?? '').trim()
2264
+ // npm 老式简写:`github:owner/repo` / `gitee:owner/repo`
2265
+ const shorthand = raw.match(/^(?:github|gitee):([^/\s]+)\/([^/\s#?]+?)(?:\.git)?(?:[#?].*)?$/u)
2266
+ if (shorthand !== null) return `${shorthand[1]}/${shorthand[2]}`
2267
+ const m = raw.match(/(?:github\.com|gitee\.com)[/:]([^/\s]+)\/([^/\s#?]+?)(?:\.git)?(?:[#?].*)?$/u)
2268
+ if (m === null) return null
2269
+ return `${m[1]}/${m[2]}`
2270
+ }
2271
+
2272
+ /** 结果里是否已有"名字逐词命中查询词"的条目——决定要不要再加 `in:readme` 重查一次。
2273
+ * (仓库搜索的检索面只有 名字/描述/topics;README 里的词必须显式 in:readme 才查得到) */
2274
+ export function hasDirectNameHit(items, query) {
2275
+ const tokens = String(query ?? '').toLowerCase().split(/[\s\-_/.]+/u).filter((tk) => tk.length >= 3)
2276
+ if (tokens.length === 0) return true // 查询太短/太泛:不做二次查询,避免把结果冲稀
2277
+ return items.some((it) => {
2278
+ const name = String(it?.fullName ?? '').toLowerCase()
2279
+ return tokens.every((tk) => name.includes(tk))
2280
+ })
2281
+ }
2282
+
2283
+ /** npm 包名搜索:registry 搜索接口 → 候选包 → 读 packument 的 repository.url → 映射回 GitHub 仓库。
2284
+ * 背景(2026-09-20):用户搜 `web-all`(= npm 包 `@linxin666/dsh-web-all`)搜不到,因为 `web-all`
2285
+ * 只存在于 npm 包名、仓库文件与 README 里,而 GitHub 仓库搜索的检索面只有 名字/描述/topics。
2286
+ * 这条通道不依赖静态索引、也不依赖 GitHub 登录,且命中后可按包名直接安装。 */
2287
+ export async function searchNpmPackages(query, registries, limit = 3, token = null) {
2288
+ const q = String(query ?? '').trim().toLowerCase()
2289
+ if (q.length < 2) return []
2290
+ let hits = null
2291
+ let registry = null
2292
+ for (const reg of (registries ?? []).slice(0, 3)) {
2293
+ try {
2294
+ // eslint-disable-next-line no-await-in-loop
2295
+ const data = await fetchJsonUrl(`${reg}/-/v1/search?text=${encodeURIComponent(q)}&size=10`, 8000)
2296
+ if (data && Array.isArray(data.objects)) { hits = data.objects; registry = reg; break }
2297
+ } catch {}
2298
+ }
2299
+ if (hits === null) return []
2300
+ const candidates = hits
2301
+ .map((o) => o?.package)
2302
+ .filter((p) => p && typeof p.name === 'string' && p.name.toLowerCase().includes(q))
2303
+ .slice(0, limit)
2304
+ const out = []
2305
+ for (const cand of candidates) {
2306
+ try {
2307
+ const encoded = cand.name.startsWith('@')
2308
+ ? `@${encodeURIComponent(cand.name.slice(1).split('/')[0])}%2f${encodeURIComponent(cand.name.split('/').slice(1).join('/'))}`
2309
+ : encodeURIComponent(cand.name)
2310
+ // eslint-disable-next-line no-await-in-loop
2311
+ const meta = await fetchJsonUrl(`${registry}/${encoded}`, 8000)
2312
+ const repo = parseRepoFromUrl(meta?.repository?.url ?? meta?.repository ?? cand.links?.repository ?? '')
2313
+ if (repo === null) continue
2314
+ // 顺带补仓库真实元数据(星数/描述/默认分支)——npm 里的信息不足,且默认分支可能是 dev
2315
+ let info = null
2316
+ try {
2317
+ // eslint-disable-next-line no-await-in-loop
2318
+ info = await githubJson(`${GITHUB_API}/repos/${repo}`, null, token)
2319
+ } catch {}
2320
+ out.push({
2321
+ fullName: repo,
2322
+ description: `${info?.description ?? cand.description ?? ''}(npm 包:${cand.name}@${cand.version ?? '?'})`.trim(),
2323
+ htmlUrl: info?.html_url ?? `https://github.com/${repo}`,
2324
+ stars: typeof info?.stargazers_count === 'number' ? info.stargazers_count : 0,
2325
+ updatedAt: info?.updated_at ?? '',
2326
+ defaultBranch: info?.default_branch ?? null,
2327
+ topics: Array.isArray(info?.topics) ? info.topics : [],
2328
+ source: 'npm',
2329
+ sourceName: 'npm',
2330
+ packageName: cand.name,
2331
+ npmVersion: cand.version ?? null,
2332
+ npmPackage: true,
2333
+ })
2334
+ } catch {}
2335
+ }
2336
+ return out
2337
+ }
2338
+
2339
+ /** monorepo 子包增强:GitHub 代码搜索(`<词> filename:package.json`)→ 命中 `packages/<包>/package.json`
2340
+ * → 读该 package.json 取真实包名 → 作为子包条目返回(这样 `dsh-web-all`、OpenViking 这类
2341
+ * 「只存在于仓库文件里的包名」也能被搜到,且带 packageName 可直接按包名安装)。
2342
+ * ⚠️ GitHub **代码搜索 API 强制要求登录**(未登录实测 401 Requires authentication),未登录时返回空。 */
2343
+ export async function searchSubpackageItems(query, token = null, signal = null) {
2344
+ const subItems = []
2345
+ try {
2346
+ const codeData = await githubJson(
2347
+ `${GITHUB_API}/search/code?q=${encodeURIComponent(`${query} filename:package.json`)}`,
2348
+ signal,
2349
+ token,
2350
+ )
2351
+ for (const hit of (codeData.items ?? []).slice(0, 10)) {
2352
+ const hitPath = typeof hit.path === 'string' ? hit.path : ''
2353
+ if (!/^(?:packages|examples|plugins|skills|apps|extensions|src|lib)\/[^/]+\/package\.json$/u.test(hitPath)) continue
2354
+ const repoName = hit.repository?.full_name ?? ''
2355
+ if (!repoName) continue
2356
+ const dir = hitPath.split('/').slice(0, -1).join('/')
2357
+ let packageName = dir.split('/').slice(-1)[0]
2358
+ try {
2359
+ // eslint-disable-next-line no-await-in-loop
2360
+ const pkgText = await rawTextWithFallback(repoName, 'main', hitPath)
2361
+ if (pkgText !== null) {
2362
+ const pkg = JSON.parse(pkgText)
2363
+ if (pkg && typeof pkg.name === 'string') packageName = pkg.name
2364
+ }
2365
+ } catch {}
2366
+ subItems.push({
2367
+ fullName: repoName,
2368
+ description: `子包:${dir}`,
2369
+ htmlUrl: `https://github.com/${repoName}/tree/main/${dir}`,
2370
+ stars: 0,
2371
+ updatedAt: '',
2372
+ defaultBranch: 'main',
2373
+ topics: [],
2374
+ source: 'github',
2375
+ subpackagePath: dir,
2376
+ packageName,
2377
+ })
2378
+ if (subItems.length >= 5) break
2379
+ }
2380
+ } catch {}
2381
+ return subItems
2382
+ }
2383
+
2253
2384
  /** 探测失败时的用户可读文案:「抓取超时/不可达」与「真的没有」必须区分开。导出供测试。 */
2254
2385
  export function packageProbeErrorText(repo, branch, reason) {
2255
2386
  if (reason === FETCH_UNREACHABLE) {
@@ -2266,7 +2397,7 @@ export function packageProbeErrorText(repo, branch, reason) {
2266
2397
  * profile 目录由 pnpm 管理;若用 npm 写入会与 pnpm 的目录重建互相破坏
2267
2398
  * (曾导致入口链接丢失、DSH 启动崩溃)。registry 走国内镜像。
2268
2399
  */
2269
- async function pnpmInstall(profileDir, spec, registry = 'https://registry.npmmirror.com', timeout = 90000, signal = null) {
2400
+ export async function pnpmInstall(profileDir, spec, registry = 'https://registry.npmmirror.com', timeout = 90000, signal = null) {
2270
2401
  const args = ['add', spec, '--registry', registry]
2271
2402
  const opts = {
2272
2403
  cwd: profileDir,
@@ -2916,23 +3047,38 @@ function gitCloneUrls(repoFullName, source = 'github') {
2916
3047
  return urls.length > 0 ? urls : [`https://github.com/${repoFullName}.git`]
2917
3048
  }
2918
3049
 
2919
- /** git clone(镜像→直连;gitee 直连),返回或抛错。 */
2920
- async function gitCloneRepo(repo, dest, source = 'github', timeout = 180000) {
3050
+ /** 逐条尝试的错误汇总(纯函数,单测覆盖):报**第一个**错误(真实原因)+ 尝试清单。
3051
+ * 为什么要这样:多源重试时若第一次失败留下半成品目录,第二次会以
3052
+ * `fatal: destination path '…' already exists and is not an empty directory` 失败,
3053
+ * 旧代码把它当 lastError 抛出去 → 用户只看到"目录非空",真实原因(镜像/网络不可达)被完全掩盖。
3054
+ * 2026-09-20 另一位用户实测报的就是这句。 */
3055
+ export function summarizeCloneErrors(errors) {
3056
+ const first = errors[0]
3057
+ const tried = errors.map((e) => (/already exists and is not an empty directory/u.test(e.message) ? `${e.url}(目录非空)` : e.url)).join(';')
3058
+ return `git clone 失败(首个错误:${first?.message ?? '未知'});已尝试 ${errors.length} 个源:${tried}`
3059
+ }
3060
+
3061
+ /** git clone(镜像→直连;gitee 直连),返回 { url } 或抛错。 */
3062
+ export async function gitCloneRepo(repo, dest, source = 'github', timeout = 180000) {
2921
3063
  const urls = gitCloneUrls(repo, source)
2922
- let lastError = null
2923
- for (const url of urls) {
3064
+ const errors = []
3065
+ for (const [attempt, url] of urls.entries()) {
3066
+ // 每次尝试前都清掉目标目录:上一次可能留下半成品(git 会先建目录再传输),
3067
+ // 不清就会让第二次以"目录非空"失败并掩盖真实原因(见 summarizeCloneErrors 注释)。
3068
+ try { rmSync(dest, { recursive: true, force: true }) } catch {}
2924
3069
  try {
3070
+ // eslint-disable-next-line no-await-in-loop
2925
3071
  await execFileAsync('git', ['clone', '--depth', '1', '--quiet', url, dest], {
2926
3072
  timeout,
2927
3073
  windowsHide: true,
2928
3074
  env: gitEnv(),
2929
3075
  })
2930
- return
3076
+ return { url, attempt: attempt + 1 }
2931
3077
  } catch (error) {
2932
- lastError = error
3078
+ errors.push({ url, message: error?.message ?? String(error) })
2933
3079
  }
2934
3080
  }
2935
- throw new Error(`git clone 失败:${lastError?.message ?? '未知'}`)
3081
+ throw new Error(summarizeCloneErrors(errors))
2936
3082
  }
2937
3083
 
2938
3084
  /** 从 GitHub Release 下载预构建 tgz 装配到 node_modules/<pkgName>(gh CLI 通道,含绝对路径候选)。 */
@@ -5459,6 +5605,36 @@ async function handle(ctx, req, res) {
5459
5605
  const all = body.all === true
5460
5606
  const auth = readGithubAuth()
5461
5607
  let items = []
5608
+ // 给前端的补充说明(如"代码搜索需登录")——放响应里,前端据此给提示,不改 items 结构
5609
+ const extraNotes = {}
5610
+ // 增量检索(前端浏览器直连成功时**并行**调用):只补「npm 包名映射 + in:readme 重查 + 代码搜索子包」,
5611
+ // 不重复仓库搜索。为什么需要它:直连成功就不会走本路由,而未登录用户恰恰只能走直连
5612
+ // (2026-09-20 事故:未登录 + 索引加载失败 → 三条检索路全断,搜 web-all 搜不到 dsh-web)。
5613
+ if (body.extras === true) {
5614
+ const extra = []
5615
+ if (raw !== '') {
5616
+ try { extra.push(...await searchNpmPackages(raw, orderedRegistries(readSources()), 3, auth.token)) } catch {}
5617
+ try {
5618
+ const again = await githubJson(
5619
+ `${GITHUB_API}/search/repositories?q=${encodeURIComponent(`${raw} in:name,description,readme${all ? '' : ' topic:dsh-plugin'}`)}&sort=stars&order=desc&per_page=20&page=1`,
5620
+ req.signal,
5621
+ auth.token,
5622
+ )
5623
+ extra.push(...normalizePlatformItems(again.items ?? [], 'main').map((it) => ({ ...it, source: 'github', viaReadme: true })))
5624
+ } catch {}
5625
+ try { extra.push(...await searchSubpackageItems(raw, auth.token, req.signal)) } catch {}
5626
+ }
5627
+ sendJson(res, 200, {
5628
+ ok: true,
5629
+ query: raw,
5630
+ items: extra,
5631
+ extras: true,
5632
+ authenticated: auth.loggedIn,
5633
+ source: 'github',
5634
+ ...(raw !== '' && !auth.loggedIn ? { codeSearchSkipped: true } : {}),
5635
+ })
5636
+ return
5637
+ }
5462
5638
  if (body.multi === true) {
5463
5639
  // 多源汇总:GitHub + 全部自定义搜索源并行检索,结果合并(每项带 source 标记)。
5464
5640
  // Gitee 为直装模式(关键词搜索无意义),不参与多源汇总。
@@ -5565,6 +5741,11 @@ async function handle(ctx, req, res) {
5565
5741
  const data = await fetchJsonUrl(url, 15000, custom.headers ?? {})
5566
5742
  items = normalizePlatformItems(data, 'main').map((item) => ({ ...item, source, sourceName: custom.name }))
5567
5743
  } else {
5744
+ // npm 包名搜索与 GitHub 仓库搜索**并行**:npm 侧要查 registry 搜索 + packument + 仓库元数据
5745
+ // (实测 4.5~9s),串行会把两段等待叠加到用户身上。
5746
+ const npmPromise = raw !== '' && !body.skills
5747
+ ? searchNpmPackages(raw, orderedRegistries(readSources()), 3, auth.token).catch(() => [])
5748
+ : Promise.resolve([])
5568
5749
  const data = await githubJson(
5569
5750
  `${GITHUB_API}/search/repositories?q=${encodeURIComponent(all ? query : `${query} topic:dsh-plugin`)}&sort=stars&order=desc&per_page=20&page=${page}`,
5570
5751
  req.signal,
@@ -5572,51 +5753,50 @@ async function handle(ctx, req, res) {
5572
5753
  )
5573
5754
  items = normalizePlatformItems(data.items ?? [], 'main').map((item) => ({ ...item, source: 'github' }))
5574
5755
  items = await enrichItems(items)
5575
- // monorepo 子包增强(OpenViking/examples/dsh-memory-plugin 等可按子包名搜到)
5756
+ // monorepo 子包增强(OpenViking/examples/dsh-memory-plugin 等可按子包名搜到;
5757
+ // 代码搜索需登录,未登录时该函数返回空数组,见 searchSubpackageItems 的说明)
5576
5758
  if (raw !== '' && !body.skills) {
5759
+ for (const sub of await searchSubpackageItems(raw, auth.token, req.signal)) {
5760
+ if (!items.some((x) => x.fullName === sub.fullName)) items.push(sub)
5761
+ }
5762
+ }
5763
+ // ── B′:README 重查(2026-09-20)────────────────────────────────────────
5764
+ // 仓库搜索只在「仓库名 + 描述 + topics」里找词,所以只写在 README 或仓库文件里的名字搜不到。
5765
+ // 典型:`web-all` 只是 npm 包名 + `packages/dsh-web-all/package.json` 的内容,
5766
+ // `q=web-all topic:dsh-plugin` 32 条里没有 dsh-web;而 `web-all in:readme` 第 9 条就是它。
5767
+ // 首屏没有"名字逐词命中"的条目时,用 in:name,description,readme 再查一次(未登录也能用)。
5768
+ if (raw !== '' && !body.skills && page === 1 && !hasDirectNameHit(items, raw)) {
5577
5769
  try {
5578
- const codeData = await githubJson(
5579
- `${GITHUB_API}/search/code?q=${encodeURIComponent(`${raw} filename:package.json`)}`,
5770
+ const again = await githubJson(
5771
+ `${GITHUB_API}/search/repositories?q=${encodeURIComponent(`${raw} in:name,description,readme${all ? '' : ' topic:dsh-plugin'}`)}&sort=stars&order=desc&per_page=20&page=1`,
5580
5772
  req.signal,
5581
5773
  auth.token,
5582
5774
  )
5583
- const subItems = []
5584
- for (const hit of (codeData.items ?? []).slice(0, 10)) {
5585
- const hitPath = typeof hit.path === 'string' ? hit.path : ''
5586
- if (!/^(?:packages|examples|plugins|skills|apps|extensions|src|lib)\/[^/]+\/package\.json$/u.test(hitPath)) continue
5587
- const repoName = hit.repository?.full_name ?? ''
5588
- if (!repoName) continue
5589
- const dir = hitPath.split('/').slice(0, -1).join('/')
5590
- let packageName = dir.split('/').slice(-1)[0]
5591
- try {
5592
- const pkgText = await rawTextWithFallback(repoName, 'main', hitPath)
5593
- if (pkgText !== null) {
5594
- const pkg = JSON.parse(pkgText)
5595
- if (pkg && typeof pkg.name === 'string') packageName = pkg.name
5596
- }
5597
- } catch {}
5598
- subItems.push({
5599
- fullName: repoName,
5600
- description: `子包:${dir}`,
5601
- htmlUrl: `https://github.com/${repoName}/tree/main/${dir}`,
5602
- stars: 0,
5603
- updatedAt: '',
5604
- defaultBranch: 'main',
5605
- topics: [],
5606
- source: 'github',
5607
- subpackagePath: dir,
5608
- packageName,
5609
- })
5610
- if (subItems.length >= 5) break
5611
- }
5612
- for (const sub of subItems) {
5613
- if (!items.some((x) => x.fullName === sub.fullName)) items.push(sub)
5775
+ for (const it of normalizePlatformItems(again.items ?? [], 'main')) {
5776
+ if (items.some((x) => x.fullName === it.fullName)) continue
5777
+ items.push({ ...it, source: 'github', viaReadme: true })
5778
+ if (items.length >= 40) break
5614
5779
  }
5615
5780
  } catch {}
5616
5781
  }
5782
+ // ── A:npm 包名搜索(2026-09-20)────────────────────────────────────────
5783
+ // 用户输入常常是 npm 包名(`web-all`),而 GitHub 元数据里没有它 → 走 registry 搜索接口反查
5784
+ // 包 → repository.url → 仓库,命中**置顶**并带 npmPackage 标记(前端按包名安装)。
5785
+ // 不依赖静态索引、不依赖 GitHub 登录;registry 走配置的软件源(默认国内镜像)。
5786
+ // 已在列表里的同仓库条目(例如代码搜索加进来的"子包")合并 npm 信息后上移——精确命中不该排在第 21 位。
5787
+ if (raw !== '' && !body.skills) {
5788
+ for (const it of (await npmPromise).reverse()) {
5789
+ const existingIdx = items.findIndex((x) => x.fullName === it.fullName)
5790
+ const existing = existingIdx >= 0 ? items.splice(existingIdx, 1)[0] : null
5791
+ items.unshift({ ...(existing ?? {}), ...it })
5792
+ }
5793
+ }
5794
+ // 代码搜索(monorepo 子包)需要 GitHub 登录:未登录时它拿不到结果,
5795
+ // 前端据此提示"登录后可按子包名搜索"(2026-09-20 事故复盘:未登录用户三条检索路径全断)
5796
+ if (raw !== '' && !body.skills && !auth.loggedIn) extraNotes.codeSearchSkipped = true
5617
5797
  }
5618
5798
  items = items.filter((item) => item.fullName !== '')
5619
- sendJson(res, 200, { ok: true, query, items, authenticated: auth.loggedIn, source })
5799
+ sendJson(res, 200, { ok: true, query, items, authenticated: auth.loggedIn, source, ...(extraNotes) })
5620
5800
  return
5621
5801
  }
5622
5802
 
@@ -6090,9 +6270,13 @@ async function handle(ctx, req, res) {
6090
6270
  }
6091
6271
  let lastError = null
6092
6272
  let formatError = null
6273
+ // 总预算:索引源扩容到 5 个后,逐个 15s 最坏要等 75s(用户只会看到"市场一直转圈")。
6274
+ // 单源 8s + 整体 20s 封顶,超预算立刻进入落盘缓存兜底。
6275
+ const deadline = Date.now() + 20000
6093
6276
  for (const src of indexList) {
6277
+ if (Date.now() > deadline) break
6094
6278
  try {
6095
- const data = await fetchJsonUrl(src.url, 15000)
6279
+ const data = await fetchJsonUrl(src.url, 8000)
6096
6280
  if (data && Array.isArray(data.items)) {
6097
6281
  marketIndexCache = { at: Date.now(), data, sourceName: src.name }
6098
6282
  try { await writeFile(MARKET_INDEX_CACHE_FILE, JSON.stringify({ at: Date.now(), data, sourceName: src.name }), 'utf8') } catch {}
@@ -6115,7 +6299,9 @@ async function handle(ctx, req, res) {
6115
6299
  const reason = formatError !== null
6116
6300
  ? formatError
6117
6301
  : `网络不可达(${indexList.length} 个索引源全部失败):${lastError?.message ?? '未知错误'}`
6118
- sendError(res, 500, `索引加载失败:${reason}`)
6302
+ // 说清后果(2026-09-20 另一位用户实测的困惑):索引不在时市场只剩 GitHub 实时结果,
6303
+ // 收录条目与本地索引模糊匹配一起失效(他搜 web-all 搜不到 dsh-web 全家桶就是这个原因)
6304
+ sendError(res, 500, `索引加载失败:${reason}(此时市场只能搜 GitHub 实时结果,收录条目可能看不到;可在「软件源 → 索引源」增删/更换索引源后重试)`)
6119
6305
  return
6120
6306
  }
6121
6307
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.3.48",
3
+ "version": "0.3.50",
4
4
  "description": "DSH plugin management panel & marketplace: one-click enable/disable, multi-source market (GitHub/Gitee/custom), static-index market (500+ plugins / 300 skills), skills, suites, and one-click framework upgrade.",
5
5
  "repository": {
6
6
  "type": "git",