@noob-stupid/dsh-plugin-console 0.3.29 → 0.3.32

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.
Files changed (3) hide show
  1. package/lib/client.js +384 -59
  2. package/lib/index.js +507 -54
  3. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -621,8 +621,24 @@ const DEFAULT_SOURCES = {
621
621
  { id: 'github', name: 'GitHub', type: 'builtin' },
622
622
  { id: 'gitee', name: 'Gitee', type: 'builtin' },
623
623
  ],
624
+ // 市场静态索引源(按主→备依次尝试;内网可整体替换为自建镜像,实现完全离线的市场浏览)
625
+ indexSources: [
626
+ { id: 'jsdelivr', name: 'jsDelivr CDN', url: 'https://cdn.jsdelivr.net/gh/Noob-stupid/dsh-plugin-hub@main/marketplace/index.json', primary: true },
627
+ { id: 'ghproxy', name: 'ghproxy 镜像', url: 'https://ghproxy.net/https://raw.githubusercontent.com/Noob-stupid/dsh-plugin-hub/main/marketplace/index.json', primary: false },
628
+ ],
629
+ // Git 克隆源({owner}/{repo} 占位符;按主→备依次尝试)。
630
+ // 可替换为 Gitee / GitLab / 自建 Gitea / 任意镜像代理,实现「换一个网站下载仓库内容」。
631
+ gitSources: [
632
+ { id: 'ghproxy-git', name: 'ghproxy 镜像', urlTemplate: 'https://ghproxy.net/https://github.com/{owner}/{repo}.git', primary: true },
633
+ { id: 'github-git', name: 'GitHub 直连', urlTemplate: 'https://github.com/{owner}/{repo}.git', primary: false },
634
+ ],
635
+ // 索引合并模式:true = 所有索引源结果合并去重(公共索引 + 内网私有索引同时可见);
636
+ // false = 主→备只用一个(内网优先,更快)
637
+ indexMerge: false,
624
638
  gitee: { clientId: '', clientSecret: '', token: '', login: '' },
625
639
  }
640
+ /** 市场索引落盘缓存(网络不可达时降级展示上次成功结果,避免内网/断网下市场空白)。 */
641
+ const MARKET_INDEX_CACHE_FILE = join(dshHome(), 'plugin-console-market-index-cache.json')
626
642
 
627
643
  /** 源地址校验(模块顶层,readSources 与 sources 路由共用):https 任意;http 仅限私网/本机地址(内网 npm registry、内网搜索服务常用 http)。 */
628
644
  function isAllowedSourceUrl(url) {
@@ -644,6 +660,12 @@ function isAllowedSourceUrl(url) {
644
660
  }
645
661
  }
646
662
 
663
+ /** Git 源地址校验:在通用校验之上额外允许 file:// 本地裸仓库(完全离线/内网共享盘场景)。 */
664
+ function isAllowedGitSourceUrl(url) {
665
+ if (/^file:\/\/\/\S+$/u.test(url)) return true
666
+ return isAllowedSourceUrl(url)
667
+ }
668
+
647
669
  /** 读取 Gitee OAuth 配置(clientId/clientSecret/token/login)。 */
648
670
  function readGiteeConfig(data) {
649
671
  const gitee = data && typeof data === 'object' && data.gitee && typeof data.gitee === 'object' ? data.gitee : {}
@@ -700,6 +722,36 @@ function readSources() {
700
722
  })
701
723
  .filter((s) => s !== null)
702
724
  const giteeBase = readGiteeConfig(data)
725
+ // 索引源:老配置无该字段时用默认;URL 必须是合法 https 或本机/私网 http
726
+ const indexSources = (Array.isArray(data.indexSources) ? data.indexSources : [])
727
+ .filter((s) => s && typeof s.url === 'string' && isAllowedSourceUrl(s.url))
728
+ .map((s) => ({
729
+ id: String(s.id ?? '').slice(0, 40) || `idx-${Math.random().toString(36).slice(2, 8)}`,
730
+ name: String(s.name ?? s.url).slice(0, 60) || s.url,
731
+ url: s.url,
732
+ primary: s.primary === true,
733
+ }))
734
+ const indexFinal = indexSources.length > 0
735
+ ? (() => {
736
+ if (!indexSources.some((s) => s.primary)) indexSources[0].primary = true
737
+ return indexSources
738
+ })()
739
+ : defaults.indexSources
740
+ // Git 克隆源:模板必须同时含 {owner} 与 {repo};老配置无该字段时用默认
741
+ const gitSources = (Array.isArray(data.gitSources) ? data.gitSources : [])
742
+ .filter((s) => s && typeof s.urlTemplate === 'string' && s.urlTemplate.includes('{owner}') && s.urlTemplate.includes('{repo}') && isAllowedGitSourceUrl(s.urlTemplate))
743
+ .map((s) => ({
744
+ id: String(s.id ?? '').slice(0, 40) || `git-${Math.random().toString(36).slice(2, 8)}`,
745
+ name: String(s.name ?? s.urlTemplate).slice(0, 60) || s.urlTemplate,
746
+ urlTemplate: s.urlTemplate,
747
+ primary: s.primary === true,
748
+ }))
749
+ const gitFinal = gitSources.length > 0
750
+ ? (() => {
751
+ if (!gitSources.some((s) => s.primary)) gitSources[0].primary = true
752
+ return gitSources
753
+ })()
754
+ : defaults.gitSources
703
755
  const gitee = {
704
756
  ...giteeBase,
705
757
  clientSecret: secrets.gitee?.clientSecret ?? giteeBase.clientSecret,
@@ -707,7 +759,7 @@ function readSources() {
707
759
  }
708
760
  if (registries.length > 0) {
709
761
  if (!registries.some((r) => r.primary)) registries[0].primary = true
710
- return { registries, searchSources, gitee }
762
+ return { registries, searchSources, indexSources: indexFinal, gitSources: gitFinal, indexMerge: data.indexMerge === true, gitee }
711
763
  }
712
764
  } catch {}
713
765
  return defaults
@@ -769,6 +821,9 @@ function maskSources(sources) {
769
821
  }
770
822
  return {
771
823
  registries: sources.registries,
824
+ indexSources: sources.indexSources ?? DEFAULT_SOURCES.indexSources,
825
+ indexMerge: sources.indexMerge === true,
826
+ gitSources: sources.gitSources ?? DEFAULT_SOURCES.gitSources,
772
827
  searchSources: (sources.searchSources ?? []).map((s) => {
773
828
  if (s && typeof s.headers === 'object' && Object.keys(s.headers).length > 0) {
774
829
  const masked = {}
@@ -1588,6 +1643,7 @@ async function curlText(url, timeout = 10000) {
1588
1643
  /** 任意 https JSON 接口(Gitee/自定义源用):curl 优先、node:https 兜底;4xx/5xx 确定性失败直接抛出。 */
1589
1644
  async function fetchJsonUrl(url, timeout = 15000, headers = {}) {
1590
1645
  let lastError
1646
+ const isHttps = /^https:\/\//u.test(url)
1591
1647
  for (let attempt = 0; attempt < 2; attempt += 1) {
1592
1648
  if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 1000))
1593
1649
  try {
@@ -1597,6 +1653,9 @@ async function fetchJsonUrl(url, timeout = 15000, headers = {}) {
1597
1653
  if (error instanceof Error && /^请求失败 \(HTTP \d+\)$/u.test(error.message)) throw error
1598
1654
  lastError = error
1599
1655
  }
1656
+ // node:https 兜底仅对 https 有效:私网 http 镜像只走 curl 通道,
1657
+ // 否则会抛出 "Protocol http: not supported" 掩盖 curl 的真实连接错误
1658
+ if (!isHttps) continue
1600
1659
  try {
1601
1660
  const res = await githubRequest(url, { headers })
1602
1661
  const status = res.statusCode ?? 0
@@ -1741,23 +1800,29 @@ async function enrichItemOne(item) {
1741
1800
  const pkg = JSON.parse(pkgResult.value)
1742
1801
  if (typeof pkg.dsh?.bundle?.patch === 'string') {
1743
1802
  official = true
1744
- } else if (pkg.private === true && (Array.isArray(pkg.workspaces) || /(^|-)dsh[-/]/u.test(String(pkg.name ?? '')))) {
1745
- aggregate = true
1746
- try {
1747
- const tree = await curlJson(`https://api.github.com/repos/${item.fullName}/git/trees/${encodeURIComponent(branch)}?recursive=1`, 6000)
1748
- const pkgPaths = (tree.tree ?? [])
1749
- .filter((n) => n.type === 'blob' && /^packages\/[^/]+\/package\.json$/u.test(n.path))
1750
- .map((n) => n.path)
1751
- .slice(0, 12)
1752
- if (pkgPaths.length > 0) {
1753
- const subs = await Promise.all(pkgPaths.map((p) => curlText(`https://raw.githubusercontent.com/${item.fullName}/${encodeURIComponent(branch)}/${p}`, 4000)
1754
- .then((t) => { try { return JSON.parse(t) } catch { return null } })
1755
- .catch(() => null)))
1756
- if (subs.some((sp) => sp && typeof sp.dsh?.bundle?.patch === 'string')) {
1757
- aggregateInstallable = true
1803
+ } else {
1804
+ // 成功读到 package.json 且没有 dsh.bundle.patch → 确定「非官方」。
1805
+ // 必须落成 false(而不是留 null),否则缓存条件 official !== null 永不成立,
1806
+ // 每个非官方插件每次打开都重新请求 → /enrich 缓存命中也要十几秒。
1807
+ official = false
1808
+ if (pkg.private === true && (Array.isArray(pkg.workspaces) || /(^|-)dsh[-/]/u.test(String(pkg.name ?? '')))) {
1809
+ aggregate = true
1810
+ try {
1811
+ const tree = await curlJson(`https://api.github.com/repos/${item.fullName}/git/trees/${encodeURIComponent(branch)}?recursive=1`, 6000)
1812
+ const pkgPaths = (tree.tree ?? [])
1813
+ .filter((n) => n.type === 'blob' && /^packages\/[^/]+\/package\.json$/u.test(n.path))
1814
+ .map((n) => n.path)
1815
+ .slice(0, 12)
1816
+ if (pkgPaths.length > 0) {
1817
+ const subs = await Promise.all(pkgPaths.map((p) => curlText(`https://raw.githubusercontent.com/${item.fullName}/${encodeURIComponent(branch)}/${p}`, 4000)
1818
+ .then((t) => { try { return JSON.parse(t) } catch { return null } })
1819
+ .catch(() => null)))
1820
+ if (subs.some((sp) => sp && typeof sp.dsh?.bundle?.patch === 'string')) {
1821
+ aggregateInstallable = true
1822
+ }
1758
1823
  }
1759
- }
1760
- } catch {}
1824
+ } catch {}
1825
+ }
1761
1826
  }
1762
1827
  }
1763
1828
  if (skillResult.status === 'fulfilled' && skillResult.value?.hasSkill === true) {
@@ -1786,9 +1851,13 @@ async function enrichItems(items) {
1786
1851
  const key = `${item.fullName}@${item.defaultBranch ?? 'main'}`
1787
1852
  const cached = cache[key]
1788
1853
  try {
1789
- if (cached !== undefined && typeof cached?.at === 'number' && Date.now() - cached.at < ENRICH_CACHE_TTL && cached.data && (cached.data.official !== null || item.fullName.startsWith('deepseek-ai/'))) {
1790
- out[i] = { ...item, ...cached.data }
1791
- continue
1854
+ // official 已判定(true/false)→ 24h 缓存;判定失败(null)→ 1h 短缓存,减少无谓重试
1855
+ if (cached !== undefined && typeof cached?.at === 'number' && cached.data) {
1856
+ const ttl = cached.data.official === null ? 60 * 60 * 1000 : ENRICH_CACHE_TTL
1857
+ if (Date.now() - cached.at < ttl) {
1858
+ out[i] = { ...item, ...cached.data }
1859
+ continue
1860
+ }
1792
1861
  }
1793
1862
  let result = await enrichItemOne(item)
1794
1863
  // 本次失败 → 回退缓存(哪怕已过期),避免「看天吃饭」
@@ -1800,7 +1869,16 @@ async function enrichItems(items) {
1800
1869
  }
1801
1870
  out[i] = result
1802
1871
  } catch {
1803
- out[i] = cached?.data ? { ...item, ...cached.data } : { ...item, official: null, aggregate: false, aggregateInstallable: false, hasSkill: false, hasSuite: false }
1872
+ // 抛错的条目也必须写缓存(null 结果 + 1h 短 TTL),否则每次打开都重试同一批,
1873
+ // /enrich 即使「命中缓存」也要等十几秒。
1874
+ if (cached?.data) {
1875
+ out[i] = { ...item, ...cached.data }
1876
+ } else {
1877
+ const miss = { official: null, aggregate: false, aggregateInstallable: false, hasSkill: false, hasSuite: false }
1878
+ cache[key] = { at: Date.now(), data: miss }
1879
+ cacheDirty = true
1880
+ out[i] = { ...item, ...miss }
1881
+ }
1804
1882
  }
1805
1883
  }
1806
1884
  }
@@ -2166,6 +2244,30 @@ async function githubReleaseInstall(profileDir, repo, packageName) {
2166
2244
  * 读已装包的 bundle patch → 检查每个 insert 引用的包是否存在 → 缺失补装(pnpm/curl)→
2167
2245
  * 仍缺则在用户 patch 层自动禁用该行(防崩兜底)+ 返回报告供面板提示。
2168
2246
  */
2247
+ /** 读取聚合包 cordis.patch.yml 的 insert 行 name 列表(注册前校验用)。 */
2248
+ function readBundlePatchRefNames(profileDir, pkgName) {
2249
+ try {
2250
+ const pkgJsonPath = join(profileDir, 'node_modules', pkgName, 'package.json')
2251
+ if (!existsSync(pkgJsonPath)) return []
2252
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'))
2253
+ const patchRel = pkg.dsh?.bundle?.patch
2254
+ if (typeof patchRel !== 'string') return []
2255
+ const text = readFileSync(join(profileDir, 'node_modules', pkgName, patchRel), 'utf8')
2256
+ const names = []
2257
+ const lines = text.split(/\r?\n/u)
2258
+ let inInsert = false
2259
+ for (let i = 0; i < lines.length; i += 1) {
2260
+ const line = lines[i]
2261
+ if (/^- insert:\s*$/u.test(line)) { inInsert = true; continue }
2262
+ if (/^- /u.test(line) && !/^ {4}- /u.test(line)) inInsert = false
2263
+ if (!inInsert) continue
2264
+ const nameM = (lines[i + 1] ?? '').match(/^ {6}name: ['"]([^'"]+)['"]\s*$/u)
2265
+ if (nameM) names.push(nameM[1])
2266
+ }
2267
+ return names
2268
+ } catch { return [] }
2269
+ }
2270
+
2169
2271
  async function ensureBundlePatchIntegrity(profileDir, pkgName, userPatchPath, transientAllow = []) {
2170
2272
  const report = { checked: 0, installed: [], disabled: [], missing: [], pending: [] }
2171
2273
  try {
@@ -2339,6 +2441,7 @@ function installJobView(job) {
2339
2441
  skillNote: job.skillNote ?? null,
2340
2442
  suiteReport: job.suiteReport ?? null,
2341
2443
  suiteNote: job.suiteNote ?? null,
2444
+ hint: job.hint ?? null,
2342
2445
  }
2343
2446
  }
2344
2447
 
@@ -2424,18 +2527,45 @@ function packageEntryExists(dir, pkg) {
2424
2527
  return candidates.some((c) => existsSync(join(dir, c)))
2425
2528
  }
2426
2529
 
2530
+ /** git 非交互环境:禁止任何登录/凭据窗口弹出(私有仓库或不可达源直接失败,不做交互式重试)。 */
2531
+ function gitEnv() {
2532
+ return {
2533
+ ...process.env,
2534
+ GIT_TERMINAL_PROMPT: '0',
2535
+ GCM_INTERACTIVE: 'never',
2536
+ GIT_ASKPASS: 'echo',
2537
+ SSH_ASKPASS: 'echo',
2538
+ }
2539
+ }
2540
+
2541
+ /** Git 克隆 URL 列表(按主→备顺序)。来源在「功能包 → 软件源 → Git 源」中自定义,
2542
+ * 可替换为 Gitee / GitLab / 自建 Gitea / 任意镜像代理,实现「换一个网站下载仓库内容」。 */
2543
+ function gitCloneUrls(repoFullName, source = 'github') {
2544
+ if (source === 'gitee') return [`https://gitee.com/${repoFullName}.git`]
2545
+ const [owner = '', repo = ''] = String(repoFullName).split('/')
2546
+ let list = []
2547
+ try {
2548
+ list = readSources().gitSources ?? []
2549
+ } catch {
2550
+ list = DEFAULT_SOURCES.gitSources
2551
+ }
2552
+ const ordered = [...list].sort((a, b) => (b.primary === true ? 1 : 0) - (a.primary === true ? 1 : 0))
2553
+ const urls = ordered
2554
+ .map((s) => String(s.urlTemplate).replace(/\{owner\}/gu, owner).replace(/\{repo\}/gu, repo))
2555
+ .filter((u) => u !== '')
2556
+ return urls.length > 0 ? urls : [`https://github.com/${repoFullName}.git`]
2557
+ }
2558
+
2427
2559
  /** git clone(镜像→直连;gitee 直连),返回或抛错。 */
2428
2560
  async function gitCloneRepo(repo, dest, source = 'github', timeout = 180000) {
2429
- const urls = source === 'gitee'
2430
- ? [`https://gitee.com/${repo}.git`]
2431
- : [`https://ghproxy.net/https://github.com/${repo}.git`, `https://github.com/${repo}.git`]
2561
+ const urls = gitCloneUrls(repo, source)
2432
2562
  let lastError = null
2433
2563
  for (const url of urls) {
2434
2564
  try {
2435
2565
  await execFileAsync('git', ['clone', '--depth', '1', '--quiet', url, dest], {
2436
2566
  timeout,
2437
2567
  windowsHide: true,
2438
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
2568
+ env: gitEnv(),
2439
2569
  })
2440
2570
  return
2441
2571
  } catch (error) {
@@ -2598,9 +2728,7 @@ async function runSkillInstallJob(job, ctx) {
2598
2728
  job.stage = 'preparing'
2599
2729
  const shortName = String(job.repo).split('/').pop() || job.repo
2600
2730
  mkdirSync(tmpDir, { recursive: true })
2601
- const urls = job.source === 'gitee'
2602
- ? [`https://gitee.com/${job.repo}.git`]
2603
- : [`https://ghproxy.net/https://github.com/${job.repo}.git`, `https://github.com/${job.repo}.git`]
2731
+ const urls = gitCloneUrls(job.repo, job.source)
2604
2732
  let cloned = false
2605
2733
  let lastError = null
2606
2734
  for (const url of urls) {
@@ -2608,7 +2736,7 @@ async function runSkillInstallJob(job, ctx) {
2608
2736
  await execFileAsync('git', ['clone', '--depth', '1', '--quiet', url, tmpDir], {
2609
2737
  timeout: 120000,
2610
2738
  windowsHide: true,
2611
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
2739
+ env: gitEnv(),
2612
2740
  })
2613
2741
  cloned = true
2614
2742
  break
@@ -3173,6 +3301,30 @@ async function aiEmpowerPlan(job, ctx, profileDir) {
3173
3301
  job.finishedAt = Date.now()
3174
3302
  return
3175
3303
  }
3304
+ // 优先用「Git 源」把目标仓库克隆到本地,让子代理读本地材料:
3305
+ // 内网/离线环境下 api.github.com 与 raw 通道不可达时,这是唯一可行的调研通道。
3306
+ let materialDir = null
3307
+ if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(String(job.source ?? '').trim())) {
3308
+ const srcDir = join(tmpdir(), `dsh-ai-src-${job.id}-${Date.now().toString(36)}`)
3309
+ try {
3310
+ mkdirSync(srcDir, { recursive: true })
3311
+ for (const url of gitCloneUrls(job.source)) {
3312
+ try {
3313
+ await execFileAsync('git', ['clone', '--depth', '1', '--quiet', url, srcDir], { timeout: 120000, windowsHide: true, env: gitEnv() })
3314
+ materialDir = srcDir
3315
+ break
3316
+ } catch {}
3317
+ }
3318
+ } catch {}
3319
+ if (materialDir === null) {
3320
+ try { rmSync(srcDir, { recursive: true, force: true }) } catch {}
3321
+ } else {
3322
+ job.materialDir = srcDir
3323
+ // 30 分钟后自动清理(规划+执行通常早已结束)
3324
+ const timer = setTimeout(() => { try { rmSync(srcDir, { recursive: true, force: true }) } catch {} }, 30 * 60 * 1000)
3325
+ if (typeof timer?.unref === 'function') timer.unref()
3326
+ }
3327
+ }
3176
3328
  const prompt = [
3177
3329
  '你是 DSH 插件控制台挂载的「AI 赋能」部署规划器。用户想把下面的组件完整部署到本机。',
3178
3330
  `目标来源:${job.source}`,
@@ -3181,7 +3333,9 @@ async function aiEmpowerPlan(job, ctx, profileDir) {
3181
3333
  frameworkCheckPromptText(job.frameworkCheck),
3182
3334
  '',
3183
3335
  '【任务】只做只读调研与规划,禁止执行任何安装/写文件/启动服务/杀进程命令(curl 的 GET 抓取、gh api 查询、读取文件是允许的)。',
3184
- '调研材料:npm README / GitHub README / docs/ 目录 / package.json / install 说明。',
3336
+ materialDir !== null
3337
+ ? `调研材料:目标仓库已由控制台通过「Git 源」克隆到本地目录 ${materialDir} —— 直接读取该目录下的 README / package.json / docs/ / install 说明即可(无需联网,内网/离线环境同样可用)。`
3338
+ : '调研材料:npm README / GitHub README / docs/ 目录 / package.json / install 说明。',
3185
3339
  '全部调研完成后,输出且只输出一个 ```json ... ``` 代码块,内容为部署计划;代码块之外不要输出任何文字。',
3186
3340
  '',
3187
3341
  '计划 schema(严格遵循)',
@@ -3774,6 +3928,15 @@ function frameworkCheckPromptText(fc) {
3774
3928
  async function runInstallJob(job, ctx) {
3775
3929
  try {
3776
3930
  job.stage = 'preparing'
3931
+ // 2026-09-06 事故(室友机器):对 deepseek-ai/deepseek-harness(框架本体仓库)点「添加到本地/安装」
3932
+ // 会按 bundle 规则注册其 patch,其中 deepseek-ai-dsh-root 等框架级行的包只存在于 npx 缓存/框架树,
3933
+ // profile node_modules 不存在 → 整服务启动崩溃。直接拦截。
3934
+ const repoNorm = String(job.repo ?? '').toLowerCase().replace(/^git\+/u, '').replace(/\.git$/u, '')
3935
+ if (repoNorm === 'deepseek-ai/deepseek-harness') {
3936
+ job.status = 'failed'
3937
+ job.error = '这是 DSH 框架本体仓库,不能按插件安装(其组件引用框架内部包,安装后会导致服务启动失败)。如需升级框架请用「框架升级」按钮;如需生态插件请选择 dsh-plugin 插件仓库。'
3938
+ return
3939
+ }
3777
3940
  let candidates = [job.packageName].filter((name) => typeof name === 'string' && name !== '')
3778
3941
  let subpackageMode = false
3779
3942
  if (candidates.length === 0) {
@@ -3797,8 +3960,17 @@ async function runInstallJob(job, ctx) {
3797
3960
  const branch = meta?.default_branch ?? 'main'
3798
3961
  const pkg = await fetchRepoPackage(job.repo, branch)
3799
3962
  if (pkg === null) {
3963
+ // 无 package.json:先探测是否技能仓库(含 SKILL.md)→ 自动转技能安装;
3964
+ // 否则标记 hint=repo-land,前端给出「仓库落地」一键入口(克隆到本地目录)。
3965
+ const skillProbe = await detectSkillRepo(job.repo, branch)
3966
+ if (skillProbe.hasSkill) {
3967
+ job.kind = 'skill'
3968
+ await runSkillInstallJob(job, ctx)
3969
+ return
3970
+ }
3800
3971
  job.status = 'failed'
3801
- job.error = `仓库 ${job.repo} 没有可用的 package.json,无法作为 npm 包安装`
3972
+ job.hint = 'repo-land'
3973
+ job.error = `仓库 ${job.repo} 没有 package.json(也不是技能仓库),无法作为插件安装——可改用「仓库落地」克隆到本地目录。`
3802
3974
  return
3803
3975
  }
3804
3976
  if (pkg.private === true) {
@@ -3928,7 +4100,7 @@ async function runInstallJob(job, ctx) {
3928
4100
  const gitSpecs = job.source === 'gitee'
3929
4101
  ? [`git+https://gitee.com/${job.repo}.git`]
3930
4102
  : [
3931
- `git+https://ghproxy.net/https://github.com/${job.repo}.git`,
4103
+ ...gitCloneUrls(job.repo).map((u) => `git+${u}`),
3932
4104
  `github:${job.repo}`,
3933
4105
  ]
3934
4106
  for (const spec of gitSpecs) {
@@ -4037,6 +4209,17 @@ async function runInstallJob(job, ctx) {
4037
4209
  // 官方 dsh plugin add 行为:声明 dsh.bundle 的包追加为 profile bundle 层,
4038
4210
  // 其 cordis.patch.yml 在下次启动时参与组合(含皮肤包与 web-ui-settings 这类有入口的包)
4039
4211
  if (!alreadyServed) {
4212
+ // 通用防线(2026-09-06 事故:装 dsh-desktop 类插件时,其 bundle patch 引用 @deepseek-ai/dsh-root 等
4213
+ // 框架级行,包不在 profile node_modules → 注册后整服务启动崩溃):
4214
+ // 注册前校验 bundle patch 引用的行模块全部可解析(**含 @deepseek-ai/* —— 正是室友崩塌的那些框架级包**),
4215
+ // 缺失即拒绝注册并给出清单。
4216
+ const refs = readBundlePatchRefNames(profileDir, installedName)
4217
+ const missing = refs.filter((n) => resolvePackageJson(n, profileDir) === null)
4218
+ if (missing.length > 0) {
4219
+ job.status = 'failed'
4220
+ job.error = `该聚合包(${installedName})的插件组引用以下未安装模块:${missing.join('、')}。若这些是框架内部包(@deepseek-ai/*),该包不能作为插件安装(会导致服务启动失败);若为本应随包安装的依赖,请重试或检查网络。已取消注册。`
4221
+ return
4222
+ }
4040
4223
  await addBundleToManifest(profileDir, installedName)
4041
4224
  job.bundle = true
4042
4225
  } else {
@@ -4325,7 +4508,10 @@ async function handle(ctx, req, res) {
4325
4508
  return
4326
4509
  }
4327
4510
 
4328
- if (method !== 'POST') {
4511
+ // GET 兼容白名单:只读且无副作用的接口允许 GET 调用
4512
+ // (market-index 曾被客户端以 GET 调用,落进 405 后错误被静默吞掉,导致静态索引长期未生效)
4513
+ const GET_COMPAT = new Set([`${ROUTE_PREFIX}/market-index`])
4514
+ if (method !== 'POST' && !(method === 'GET' && GET_COMPAT.has(pathname))) {
4329
4515
  sendError(res, 405, '不支持的方法')
4330
4516
  return
4331
4517
  }
@@ -4670,7 +4856,7 @@ async function handle(ctx, req, res) {
4670
4856
  // 纯命令(无注释,CMD/PowerShell 通用);不再关闭 TLS 校验;
4671
4857
  // 脚本用 powershell -File 调用,CMD 里也能跑
4672
4858
  installCommand = [
4673
- `git clone --recurse-submodules https://github.com/${repo}.git`,
4859
+ `git clone --recurse-submodules ${gitCloneUrls(repo)[0]}`,
4674
4860
  `cd ${short}`,
4675
4861
  hasInstallScript
4676
4862
  ? `powershell -ExecutionPolicy Bypass -File install.ps1`
@@ -4713,6 +4899,39 @@ async function handle(ctx, req, res) {
4713
4899
  return
4714
4900
  }
4715
4901
 
4902
+ // 软件源扫描:并发探测每个 npm 源的「可达性 / 延迟 / 该源上的最新版本」。
4903
+ // 用于判断主源是否最优(内网私服 vs 公共镜像),结果直接在前端软件源列表里显示。
4904
+ if (pathname === `${ROUTE_PREFIX}/registry-scan`) {
4905
+ const sources = readSources()
4906
+ const scanPkg = '@noob-stupid/dsh-plugin-console'
4907
+ const encoded = encodeURIComponent(scanPkg)
4908
+ const scanOne = async (r) => {
4909
+ const started = Date.now()
4910
+ const base = { id: r.id, name: r.name, url: r.url, primary: r.primary === true }
4911
+ try {
4912
+ const response = await fetch(`${String(r.url).replace(/\/+$/u, '')}/${encoded}`, {
4913
+ headers: { accept: 'application/vnd.npm.install-v1+json' },
4914
+ signal: AbortSignal.timeout(8000),
4915
+ })
4916
+ const ms = Date.now() - started
4917
+ if (!response.ok) return { ...base, ok: false, ms, status: response.status, latest: null, versions: null, error: `HTTP ${response.status}` }
4918
+ const data = await response.json()
4919
+ const latest = typeof data?.['dist-tags']?.latest === 'string' ? data['dist-tags'].latest : null
4920
+ const versions = data?.versions !== undefined && data.versions !== null && typeof data.versions === 'object' ? Object.keys(data.versions).length : null
4921
+ return { ...base, ok: true, ms, status: response.status, latest, versions, error: null }
4922
+ } catch (error) {
4923
+ const ms = Date.now() - started
4924
+ const message = error?.name === 'TimeoutError'
4925
+ ? '超时(8s)'
4926
+ : (error instanceof Error ? error.message : String(error))
4927
+ return { ...base, ok: false, ms, status: null, latest: null, versions: null, error: message }
4928
+ }
4929
+ }
4930
+ const results = await Promise.all(sources.registries.map((r) => scanOne(r)))
4931
+ sendJson(res, 200, { ok: true, pkg: scanPkg, scannedAt: Date.now(), results })
4932
+ return
4933
+ }
4934
+
4716
4935
  if (pathname === `${ROUTE_PREFIX}/sources`) {
4717
4936
  const { action } = body
4718
4937
  const sources = readSources()
@@ -4824,8 +5043,168 @@ async function handle(ctx, req, res) {
4824
5043
  sendJson(res, 200, { ok: true, sources: maskSources(sources) })
4825
5044
  return
4826
5045
  }
5046
+ if (action === 'add-index') {
5047
+ const name = typeof body.name === 'string' ? body.name.trim() : ''
5048
+ const url = typeof body.url === 'string' ? body.url.trim() : ''
5049
+ if (!isAllowedSourceUrl(url)) {
5050
+ sendError(res, 400, '索引地址必须是 https:// 开头(或本机/私网 http://)的合法 URL')
5051
+ return
5052
+ }
5053
+ if ((sources.indexSources ?? []).some((s) => s.url === url)) {
5054
+ sendError(res, 400, '该索引源已存在')
5055
+ return
5056
+ }
5057
+ sources.indexSources = [...(sources.indexSources ?? []), {
5058
+ id: `idx-${Date.now().toString(36)}`,
5059
+ name: name || url,
5060
+ url,
5061
+ primary: (sources.indexSources ?? []).length === 0,
5062
+ }]
5063
+ marketIndexCache = null
5064
+ await writeSources(sources)
5065
+ sendJson(res, 200, { ok: true, sources: maskSources(sources) })
5066
+ return
5067
+ }
5068
+ if (action === 'edit-index') {
5069
+ const id = typeof body.id === 'string' ? body.id : ''
5070
+ const name = typeof body.name === 'string' ? body.name.trim() : ''
5071
+ const url = typeof body.url === 'string' ? body.url.trim() : ''
5072
+ if (!isAllowedSourceUrl(url)) {
5073
+ sendError(res, 400, '索引地址必须是 https:// 开头(或本机/私网 http://)的合法 URL')
5074
+ return
5075
+ }
5076
+ const target = (sources.indexSources ?? []).find((s) => s.id === id)
5077
+ if (!target) {
5078
+ sendError(res, 404, '没有这个索引源')
5079
+ return
5080
+ }
5081
+ target.name = name || url
5082
+ target.url = url
5083
+ marketIndexCache = null
5084
+ await writeSources(sources)
5085
+ sendJson(res, 200, { ok: true, sources: maskSources(sources) })
5086
+ return
5087
+ }
5088
+ if (action === 'set-index-primary') {
5089
+ const id = typeof body.id === 'string' ? body.id : ''
5090
+ const list = sources.indexSources ?? []
5091
+ if (!list.some((s) => s.id === id)) {
5092
+ sendError(res, 404, '没有这个索引源')
5093
+ return
5094
+ }
5095
+ for (const s of list) s.primary = s.id === id
5096
+ marketIndexCache = null
5097
+ await writeSources(sources)
5098
+ sendJson(res, 200, { ok: true, sources: maskSources(sources) })
5099
+ return
5100
+ }
5101
+ if (action === 'remove-index') {
5102
+ const id = typeof body.id === 'string' ? body.id : ''
5103
+ const list = sources.indexSources ?? []
5104
+ if (!list.some((s) => s.id === id)) {
5105
+ sendError(res, 404, '没有这个索引源')
5106
+ return
5107
+ }
5108
+ const rest = list.filter((s) => s.id !== id)
5109
+ if (rest.length === 0) {
5110
+ sendError(res, 400, '至少保留一个索引源(可先添加自建镜像再删除默认源)')
5111
+ return
5112
+ }
5113
+ if (!rest.some((s) => s.primary)) rest[0].primary = true
5114
+ sources.indexSources = rest
5115
+ marketIndexCache = null
5116
+ await writeSources(sources)
5117
+ sendJson(res, 200, { ok: true, sources: maskSources(sources) })
5118
+ return
5119
+ }
5120
+ if (action === 'set-index-merge') {
5121
+ sources.indexMerge = body.merge === true
5122
+ marketIndexCache = null
5123
+ await writeSources(sources)
5124
+ sendJson(res, 200, { ok: true, sources: maskSources(sources) })
5125
+ return
5126
+ }
5127
+ if (action === 'add-git') {
5128
+ const name = typeof body.name === 'string' ? body.name.trim() : ''
5129
+ const urlTemplate = typeof body.urlTemplate === 'string' ? body.urlTemplate.trim() : ''
5130
+ if (!urlTemplate.includes('{owner}') || !urlTemplate.includes('{repo}')) {
5131
+ sendError(res, 400, 'Git 源模板必须同时包含 {owner} 与 {repo} 占位符')
5132
+ return
5133
+ }
5134
+ if (!isAllowedGitSourceUrl(urlTemplate)) {
5135
+ sendError(res, 400, 'Git 源地址必须是 https://(或本机/私网 http://、file:// 本地裸仓库)的合法 URL')
5136
+ return
5137
+ }
5138
+ if ((sources.gitSources ?? []).some((s) => s.urlTemplate === urlTemplate)) {
5139
+ sendError(res, 400, '该 Git 源已存在')
5140
+ return
5141
+ }
5142
+ sources.gitSources = [...(sources.gitSources ?? []), {
5143
+ id: `git-${Date.now().toString(36)}`,
5144
+ name: name || urlTemplate,
5145
+ urlTemplate,
5146
+ primary: (sources.gitSources ?? []).length === 0,
5147
+ }]
5148
+ await writeSources(sources)
5149
+ sendJson(res, 200, { ok: true, sources: maskSources(sources) })
5150
+ return
5151
+ }
5152
+ if (action === 'edit-git') {
5153
+ const id = typeof body.id === 'string' ? body.id : ''
5154
+ const name = typeof body.name === 'string' ? body.name.trim() : ''
5155
+ const urlTemplate = typeof body.urlTemplate === 'string' ? body.urlTemplate.trim() : ''
5156
+ if (!urlTemplate.includes('{owner}') || !urlTemplate.includes('{repo}')) {
5157
+ sendError(res, 400, 'Git 源模板必须同时包含 {owner} 与 {repo} 占位符')
5158
+ return
5159
+ }
5160
+ if (!isAllowedGitSourceUrl(urlTemplate)) {
5161
+ sendError(res, 400, 'Git 源地址必须是 https://(或本机/私网 http://、file:// 本地裸仓库)的合法 URL')
5162
+ return
5163
+ }
5164
+ const target = (sources.gitSources ?? []).find((s) => s.id === id)
5165
+ if (!target) {
5166
+ sendError(res, 404, '没有这个 Git 源')
5167
+ return
5168
+ }
5169
+ target.name = name || urlTemplate
5170
+ target.urlTemplate = urlTemplate
5171
+ await writeSources(sources)
5172
+ sendJson(res, 200, { ok: true, sources: maskSources(sources) })
5173
+ return
5174
+ }
5175
+ if (action === 'set-git-primary') {
5176
+ const id = typeof body.id === 'string' ? body.id : ''
5177
+ const list = sources.gitSources ?? []
5178
+ if (!list.some((s) => s.id === id)) {
5179
+ sendError(res, 404, '没有这个 Git 源')
5180
+ return
5181
+ }
5182
+ for (const s of list) s.primary = s.id === id
5183
+ await writeSources(sources)
5184
+ sendJson(res, 200, { ok: true, sources: maskSources(sources) })
5185
+ return
5186
+ }
5187
+ if (action === 'remove-git') {
5188
+ const id = typeof body.id === 'string' ? body.id : ''
5189
+ const list = sources.gitSources ?? []
5190
+ if (!list.some((s) => s.id === id)) {
5191
+ sendError(res, 404, '没有这个 Git 源')
5192
+ return
5193
+ }
5194
+ const rest = list.filter((s) => s.id !== id)
5195
+ if (rest.length === 0) {
5196
+ sendError(res, 400, '至少保留一个 Git 源(可先添加自建镜像再删除默认源)')
5197
+ return
5198
+ }
5199
+ if (!rest.some((s) => s.primary)) rest[0].primary = true
5200
+ sources.gitSources = rest
5201
+ await writeSources(sources)
5202
+ sendJson(res, 200, { ok: true, sources: maskSources(sources) })
5203
+ return
5204
+ }
4827
5205
  if (action === 'reset') {
4828
5206
  const defaults = JSON.parse(JSON.stringify(DEFAULT_SOURCES))
5207
+ marketIndexCache = null
4829
5208
  await writeSources(defaults)
4830
5209
  sendJson(res, 200, { ok: true, sources: maskSources(defaults) })
4831
5210
  return
@@ -4852,34 +5231,86 @@ async function handle(ctx, req, res) {
4852
5231
  sendJson(res, 200, { ok: true, sources: maskSources(sources) })
4853
5232
  return
4854
5233
  }
4855
- sendError(res, 400, '未知操作(add / edit / set-primary / remove / add-search / remove-search / gitee-setup / gitee-clear / reset)')
5234
+ sendError(res, 400, '未知操作(add / edit / set-primary / remove / add-search / remove-search / add-index / edit-index / set-index-primary / remove-index / set-index-merge / add-git / edit-git / set-git-primary / remove-git / gitee-setup / gitee-clear / reset)')
4856
5235
  return
4857
5236
  }
4858
5237
 
4859
5238
  if (pathname === `${ROUTE_PREFIX}/market-index`) {
4860
- // 静态插件索引(jsDelivr CDN):curl 拉取 + 10 分钟内存缓存,市场秒开、零 GitHub API 调用
5239
+ // 静态插件索引:按「软件源 → 索引源」主→备顺序拉取 + 10 分钟内存缓存(市场秒开、零 GitHub API 调用)。
5240
+ // 全部索引源失败时回退落盘缓存(内网/断网仍可浏览,响应带 offline 标记),无缓存则区分错误类型。
4861
5241
  if (marketIndexCache !== null && Date.now() - marketIndexCache.at < 600000) {
4862
- sendJson(res, 200, { ok: true, ...marketIndexCache.data })
5242
+ sendJson(res, 200, { ok: true, sourceName: marketIndexCache.sourceName ?? null, ...marketIndexCache.data })
4863
5243
  return
4864
5244
  }
4865
- const urls = [
4866
- 'https://cdn.jsdelivr.net/gh/Noob-stupid/dsh-plugin-hub@main/marketplace/index.json',
4867
- 'https://ghproxy.net/https://raw.githubusercontent.com/Noob-stupid/dsh-plugin-hub/main/marketplace/index.json',
4868
- ]
5245
+ const indexConf = readSources()
5246
+ let indexList = [...(indexConf.indexSources ?? [])].sort((a, b) => (b.primary === true ? 1 : 0) - (a.primary === true ? 1 : 0))
5247
+ // 合并模式:并发拉取所有索引源并去重合并(公共索引 + 内网私有索引同时可见)
5248
+ if (indexConf.indexMerge === true && indexList.length > 1) {
5249
+ const fetched = await Promise.all(indexList.map(async (src) => {
5250
+ try {
5251
+ // 各源独立短超时:单个慢源(被墙镜像/不可达内网)不该拖垮整体
5252
+ const data = await fetchJsonUrl(src.url, 8000)
5253
+ return data && Array.isArray(data.items) ? { src, data } : null
5254
+ } catch { return null }
5255
+ }))
5256
+ const good = fetched.filter((x) => x !== null)
5257
+ if (good.length > 0) {
5258
+ const seen = new Set()
5259
+ const skillSeen = new Set()
5260
+ const items = []
5261
+ const skills = []
5262
+ for (const { data } of good) {
5263
+ for (const it of data.items) {
5264
+ const key = typeof it?.fullName === 'string' ? it.fullName : JSON.stringify(it)
5265
+ if (seen.has(key)) continue
5266
+ seen.add(key)
5267
+ items.push(it)
5268
+ }
5269
+ for (const sk of (Array.isArray(data.skills) ? data.skills : [])) {
5270
+ const key = typeof sk?.fullName === 'string' ? sk.fullName : JSON.stringify(sk)
5271
+ if (skillSeen.has(key)) continue
5272
+ skillSeen.add(key)
5273
+ skills.push(sk)
5274
+ }
5275
+ }
5276
+ const sourceName = good.map((g) => g.src.name).join(' + ')
5277
+ const merged = { items, skills, skillCount: skills.length, merged: true, sourceName }
5278
+ marketIndexCache = { at: Date.now(), data: merged, sourceName }
5279
+ try { await writeFile(MARKET_INDEX_CACHE_FILE, JSON.stringify({ at: Date.now(), data: merged, sourceName }), 'utf8') } catch {}
5280
+ sendJson(res, 200, { ok: true, ...merged })
5281
+ return
5282
+ }
5283
+ // 所有源都失败 → 跳过逐个重试,直接进入下方缓存兜底
5284
+ indexList = []
5285
+ }
4869
5286
  let lastError = null
4870
- for (const url of urls) {
5287
+ let formatError = null
5288
+ for (const src of indexList) {
4871
5289
  try {
4872
- const data = await fetchJsonUrl(url, 15000)
5290
+ const data = await fetchJsonUrl(src.url, 15000)
4873
5291
  if (data && Array.isArray(data.items)) {
4874
- marketIndexCache = { at: Date.now(), data }
4875
- sendJson(res, 200, { ok: true, ...data })
5292
+ marketIndexCache = { at: Date.now(), data, sourceName: src.name }
5293
+ try { await writeFile(MARKET_INDEX_CACHE_FILE, JSON.stringify({ at: Date.now(), data, sourceName: src.name }), 'utf8') } catch {}
5294
+ sendJson(res, 200, { ok: true, sourceName: src.name, ...data })
4876
5295
  return
4877
5296
  }
5297
+ formatError = `索引格式异常(${src.name} 未返回 items 数组)`
4878
5298
  } catch (error) {
4879
5299
  lastError = error
4880
5300
  }
4881
5301
  }
4882
- sendError(res, 500, `索引加载失败:${lastError?.message ?? '未知'}`)
5302
+ try {
5303
+ const cached = JSON.parse(readFileSync(MARKET_INDEX_CACHE_FILE, 'utf8'))
5304
+ if (cached && cached.data && Array.isArray(cached.data.items)) {
5305
+ marketIndexCache = { at: Date.now(), data: cached.data, sourceName: cached.sourceName ?? null }
5306
+ sendJson(res, 200, { ok: true, offline: true, cachedAt: typeof cached.at === 'number' ? cached.at : null, sourceName: cached.sourceName ?? null, ...cached.data })
5307
+ return
5308
+ }
5309
+ } catch {}
5310
+ const reason = formatError !== null
5311
+ ? formatError
5312
+ : `网络不可达(${indexList.length} 个索引源全部失败):${lastError?.message ?? '未知错误'}`
5313
+ sendError(res, 500, `索引加载失败:${reason}`)
4883
5314
  return
4884
5315
  }
4885
5316
 
@@ -5997,16 +6428,22 @@ async function handle(ctx, req, res) {
5997
6428
  }
5998
6429
 
5999
6430
  if (pathname === `${ROUTE_PREFIX}/repo-clone`) {
6000
- // 仓库落地:从 GitHub 克隆任意项目到 <配置路径>/<owner>/<name>(不限于 DSH 插件)
6431
+ // 仓库落地:克隆任意项目到 <配置路径>/<owner>/<name>(地址由「软件源 → Git 源」决定,
6432
+ // 因此这里接受任意平台的仓库链接:GitHub / Gitee / GitLab / 自建 Gitea / 镜像代理前缀)
6001
6433
  const raw = typeof body.repo === 'string' ? body.repo.trim() : ''
6434
+ // 循环剥离协议/域名前缀:镜像代理链接可能叠两层(ghproxy.net/https://github.com/...)
6002
6435
  let repo = raw
6003
- .replace(/^https?:\/\/github\.com\//u, '')
6004
- .replace(/^git@github\.com:/u, '')
6005
- .replace(/\.git$/u, '')
6006
- .replace(/\/+$/u, '')
6436
+ for (let guard = 0; guard < 4; guard += 1) {
6437
+ const next = repo
6438
+ .replace(/^https?:\/\/[^/]+\//u, '')
6439
+ .replace(/^git@[^:]+:/u, '')
6440
+ if (next === repo) break
6441
+ repo = next
6442
+ }
6443
+ repo = repo.replace(/\.git$/u, '').replace(/\/+$/u, '')
6007
6444
  const m = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/u.exec(repo)
6008
6445
  if (!m) {
6009
- sendError(res, 400, '仓库格式不正确:请提供 owner/repo 或 GitHub 仓库链接')
6446
+ sendError(res, 400, '仓库格式不正确:请提供 owner/repo 或仓库链接(如 https://gitee.com/owner/repo)')
6010
6447
  return
6011
6448
  }
6012
6449
  const [, owner, name] = m
@@ -6018,8 +6455,24 @@ async function handle(ctx, req, res) {
6018
6455
  }
6019
6456
  try {
6020
6457
  mkdirSync(root, { recursive: true })
6021
- await execFileAsync('git.exe', ['clone', '--depth', '1', `https://github.com/${owner}/${name}.git`, target], { cwd: root, timeout: 600000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 })
6022
- sendJson(res, 200, { ok: true, owner, name, path: target })
6458
+ const urls = gitCloneUrls(`${owner}/${name}`)
6459
+ let lastError = null
6460
+ let cloned = false
6461
+ let usedUrl = ''
6462
+ for (const url of urls) {
6463
+ try {
6464
+ await execFileAsync('git.exe', ['clone', '--depth', '1', url, target], { cwd: root, timeout: 600000, windowsHide: true, maxBuffer: 4 * 1024 * 1024, env: gitEnv() })
6465
+ cloned = true
6466
+ usedUrl = url
6467
+ break
6468
+ } catch (error) {
6469
+ lastError = error
6470
+ // 失败可能留下半成品目录,清理后再试下一个源,否则会因目标已存在而连环失败
6471
+ try { rmSync(target, { recursive: true, force: true }) } catch {}
6472
+ }
6473
+ }
6474
+ if (!cloned) throw lastError ?? new Error('未知错误')
6475
+ sendJson(res, 200, { ok: true, owner, name, path: target, url: usedUrl })
6023
6476
  } catch (error) {
6024
6477
  sendError(res, 500, `克隆失败:${error instanceof Error ? error.message : String(error)}`)
6025
6478
  }