@noob-stupid/dsh-plugin-console 0.3.58 → 0.3.59

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
@@ -311,4 +311,4 @@ The layered-refactor preview lives in its **own repository**: [**`Noob-stupid/ds
311
311
  - **What it is**: the monolithic `lib/index.js` (8547 lines) split into layers — `lib/index.js` **141 lines** + `lib/server/**` **38 modules**; **feature-equivalent to `0.3.57`**, maintainability only;
312
312
  - **Verified**: 19 test suites green · 8 architecture-guard assertions · **route inventory identical** (`status` + response fields) against the stable build;
313
313
  - **Not yet exercised**: real framework upgrade / rollback, restart guardian, 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.58`, or `main` of this repo.
314
+ - **For everyday use** stick to npm `@noob-stupid/dsh-plugin-console@0.3.59`, or `main` of this repo.
package/README.zh.md CHANGED
@@ -279,5 +279,5 @@ MIT
279
279
  - **已验证**:19 套测试全绿 · 8 条架构守卫断言 · 与稳定版逐条对打**路由清单一致**(`status` + 响应字段);
280
280
  - **已实测**(2026-09-20 真装真卸演练):普通插件 / bundle 插件 / 无 npm 仓库 / 套装 / 技能 / 聚合仓库子包 / 仓库落地 / 服务器组件启停;
281
281
  - **尚未实测**:真框架升级 / 真回滚、重启守护链路、AI 真跑、Gitee OAuth 回调(长尾风险主观估计 **10%~25%**);
282
- - **日常使用请继续用**:npm `@noob-stupid/dsh-plugin-console@0.3.58`,或本仓库 `main`。
282
+ - **日常使用请继续用**:npm `@noob-stupid/dsh-plugin-console@0.3.59`,或本仓库 `main`。
283
283
  - 请多反馈问题
package/lib/index.js CHANGED
@@ -2818,15 +2818,29 @@ async function curlManualInstall(profileDir, packageName, registries, signal = n
2818
2818
  }
2819
2819
  }
2820
2820
 
2821
- /** 安装通道并行竞速:pnpm 与 curl 同时尝试,先成功者生效;失败方被 abort 不干扰。 */
2822
- async function raceInstallChannels(profileDir, name, registries) {
2821
+ /** 安装通道并行竞速:pnpm 与 curl 同时尝试,先成功者生效;失败方被 abort 不干扰。
2822
+ *
2823
+ * ★ 2026-09-22 挂起根因修复(issue #3 发现):旧实现只挂「成功」与「120 秒兜底」两个出口 ——
2824
+ * `waitSuccess` 把失败**吞成永不 settle 的 Promise**(本意是"一条失败不代表放弃另一条",是对的),
2825
+ * 但两条通道**都已失败**时(包根本没发布到 registry,pnpm 与 curl 都是秒级 404)就没有出口了,
2826
+ * 只能空等满 120 秒。现场表现:装一个不存在的子包,每个候选白等 2 分钟;
2827
+ * 聚合仓库展开出 3 个候选就是 6 分钟,作业 8 分钟预算被吃光后掉进 AI 兜底再等 10 分钟授权 ——
2828
+ * e2e(test-suite-install.mjs)看起来就是"永不结束"。
2829
+ * 修法:补上第三个出口 —— 两条通道都 settle(无论成败)即刻收工;同时把定时器清掉,
2830
+ * 否则每次竞速都会留下一个 120 秒的挂起定时器,拖住进程退出。
2831
+ * 第 4 个参数是可选注入(单测用:把两条通道换成桩,才能离线断言"都失败 → 立刻收工"的时延语义;
2832
+ * capMs 也只是给单测缩短兜底时长,生产一律用默认 120 秒)。 */
2833
+ export async function raceInstallChannels(profileDir, name, registries, impls = {}) {
2834
+ const runPnpm = typeof impls.pnpmInstall === 'function' ? impls.pnpmInstall : pnpmInstall
2835
+ const runCurl = typeof impls.curlManualInstall === 'function' ? impls.curlManualInstall : curlManualInstall
2836
+ const capMs = Number.isFinite(impls.capMs) && impls.capMs > 0 ? impls.capMs : 120000
2823
2837
  const controller = new AbortController()
2824
2838
  const signal = controller.signal
2825
2839
  const pnpmTask = (async () => {
2826
2840
  let lastError = null
2827
2841
  for (const registry of registries) {
2828
2842
  try {
2829
- await pnpmInstall(profileDir, name, registry, 90000, signal)
2843
+ await runPnpm(profileDir, name, registry, 90000, signal)
2830
2844
  return { channel: 'pnpm', info: null }
2831
2845
  } catch (error) {
2832
2846
  lastError = error
@@ -2836,14 +2850,21 @@ async function raceInstallChannels(profileDir, name, registries) {
2836
2850
  throw lastError ?? new Error('pnpm 通道失败')
2837
2851
  })()
2838
2852
  const curlTask = (async () => {
2839
- const info = await curlManualInstall(profileDir, name, registries, signal)
2853
+ const info = await runCurl(profileDir, name, registries, signal)
2840
2854
  return { channel: 'curl', info }
2841
2855
  })()
2842
2856
  const waitSuccess = (promise) => promise.then((value) => ({ value }), () => new Promise(() => {}))
2843
- const timeout = new Promise((resolve) => setTimeout(() => resolve(null), 120000))
2844
- const winner = await Promise.race([waitSuccess(pnpmTask), waitSuccess(curlTask), timeout])
2845
- controller.abort()
2846
- return winner ? winner.value : null
2857
+ // 两条通道都跑完(含都失败)→ 立即以 null 收工;仍有通道在跑时才等 capMs 兜底
2858
+ const bothSettled = Promise.allSettled([pnpmTask, curlTask]).then(() => null)
2859
+ let timer = null
2860
+ const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(null), capMs) })
2861
+ try {
2862
+ const winner = await Promise.race([waitSuccess(pnpmTask), waitSuccess(curlTask), bothSettled, timeout])
2863
+ return winner ? winner.value : null
2864
+ } finally {
2865
+ if (timer !== null) clearTimeout(timer)
2866
+ controller.abort()
2867
+ }
2847
2868
  }
2848
2869
 
2849
2870
  /**
@@ -2945,69 +2966,530 @@ async function backfillMissingDeps(profileDir, deps, registries) {
2945
2966
  return stillMissing
2946
2967
  }
2947
2968
 
2969
+ // ── GitHub Release 源解析(issue #3:按包名反查发布仓库 → 遍历 release 的 assets → 按包名挑产物)──
2970
+ //
2971
+ // 背景(用户 issue,附逐条实测):安装 yjh051108/dsh-routing-suite(根包 @dsh-external/dsh-super-injector,
2972
+ // private: true)时 npm registry 404 → 直接掉进 AI 兜底(约 4 分钟)。真正能装上的产物在**另一个仓库**
2973
+ // yjh051108/dsh-super-injector 的 release 里(asset 形如 dsh-external-dsh-super-injector-0.3.5.tgz)。
2974
+ // 旧 githubReleaseInstall() 只用 job.repo 找仓库、只看 releases/latest、且对同一 release 下多个 asset
2975
+ // 不按包名匹配 —— 于是"产物在别的仓库"这一整类包永远装不上。
2976
+ //
2977
+ // 这段把那条链路的**选源与挑选**独立出来(纯逻辑 + 只读网络探测;下载/落盘仍由 githubReleaseInstall 做):
2978
+ // ① 候选仓库集合按优先级:显式 repo → 已装包 package.json.repository → npm registry 元数据 → GitHub 搜索包名
2979
+ // ② 遍历候选仓库的最近 ≤10 条 release,把每条 release 的 assets **全部**列出,按包名匹配挑选
2980
+ // ③ 选不中时给出"尝试过的仓库 + asset 清单"的清单式错误(排查用),
2981
+ // 并且**任何单个来源探测失败都只是"这个来源没有"**,绝不让探测异常冒泡成未捕获异常;
2982
+ // ④ 整条链路有**硬预算**(总时长 + 候选仓库扫描上限 + 单次 release 条数),到点即放弃,
2983
+ // 绝不阻塞安装主链(详见下面的常量注释)。
2984
+ //
2985
+ // 为什么不抛:这条通道是安装兜底链的最后一环,探测失败(限流/未登录/仓库不存在/没有 release)是常态,
2986
+ // 该做的是换下一个候选来源并如实汇报,而不是把整条作业打断。
2987
+
2988
+ /** 每个候选仓库最多看多少条 release:翻页对收益极小(产物一般在最近几条),却可能把作业拖到超时。 */
2989
+ const RELEASE_LIST_LIMIT = 10
2990
+ /** 候选仓库上限:每个仓库至少 1 次 releases 接口调用,候选太多会把时间预算吃光。 */
2991
+ const MAX_RELEASE_CANDIDATE_REPOS = 5
2992
+ /** 真正去扫 release 的候选仓库上限(issue #3 的硬预算之一):候选列表可以长,但只对排在前面的少数几个
2993
+ * 花"列 release"的钱——后面的候选要么是搜索出来的同名无关仓库,要么命中率极低。 */
2994
+ export const RELEASE_SCAN_MAX_REPOS = 3
2995
+ /** **整条反查链路的总时间预算**(issue #3 明确要求):反查候选仓库 + 逐仓库列 release + 挑 asset 全算在内。
2996
+ * 到点即放弃、把控制权交回安装主链的下一条通道——这条通道是兜底链的最后一环,
2997
+ * 任何情况下都不允许它把一次安装在"没有产物的候选"上拖住(现场:私有聚合根展开出 3 个候选,
2998
+ * 每个候选都要重打一遍 registry + 搜索接口才算"没有")。 */
2999
+ export const RELEASE_CHANNEL_BUDGET_MS = 20000
3000
+ /** GitHub 搜索命中的、仓库名与包名逐字对上的候选最多取几个(同名仓库可能有多个,按星数排)。 */
3001
+ const RELEASE_SEARCH_REPOS = 2
3002
+ /** 搜索接口单页条数。 */
3003
+ const RELEASE_SEARCH_LIMIT = 5
3004
+ /** 元数据探测超时(registry packument):404 是确定性结论,超时即换下一个来源。 */
3005
+ const RELEASE_META_TIMEOUT_MS = 12000
3006
+ /** release 产物体积上限:asset 可以是任何东西(安装包/镜像/视频),DSH 插件本体都在几 MB 内;
3007
+ * 超限即失败换下一个候选,避免把大文件拉进临时目录(下载本身仍受 curl -m 60 的时间上限约束)。 */
3008
+ const MAX_RELEASE_ASSET_BYTES = 128 * 1024 * 1024
3009
+ /** release 产物下载的镜像前缀(与 raw/api 用的是同一批加速器)。
3010
+ * ★ 为什么必须有(2026-09-22 实测):本机 curl 直连 `https://github.com/<owner>/<repo>/releases/download/…`
3011
+ * 返回 exit 35(SSL connect error;node https 也报 unable to verify the first certificate),
3012
+ * 而**同一个 URL 经 ghproxy.net 是 200 / 358KB** —— 只试直连会让"反查命中 + asset 挑对"之后
3013
+ * 仍然装不上(issue #3 的现场正是这样被拖进 AI 兜底 4 分钟)。顺序 = 信任顺序:直连优先,镜像兜底。 */
3014
+ export const RELEASE_DOWNLOAD_MIRROR_PREFIXES = [
3015
+ 'https://ghproxy.net/',
3016
+ 'https://ghfast.top/',
3017
+ ]
3018
+ /** 包名→仓库的反查结果缓存(10 分钟):同一个作业里多个候选包、同一包多次重试都不必重打搜索接口
3019
+ * (GitHub 搜索接口限额 30 次/分,是最容易被自己打满的一条)。 */
3020
+ const releaseRepoCache = new Map()
3021
+ const RELEASE_REPO_CACHE_TTL = 10 * 60 * 1000
3022
+
3023
+ /** 清空反查缓存(单测用:避免用例间互相污染)。 */
3024
+ function clearReleaseSourceCache() {
3025
+ releaseRepoCache.clear()
3026
+ }
3027
+
3028
+ /** 这条链路的总预算文案(失败时如实告诉用户"为什么后面没试")。 */
3029
+ function releaseBudgetText() {
3030
+ return `release 反查总预算 ${Math.round(RELEASE_CHANNEL_BUDGET_MS / 1000)} 秒`
3031
+ }
3032
+
3033
+ /** 距 deadline 还剩多少毫秒;deadline 非有限值(Infinity)= 不限制。 */
3034
+ function remainingMs(deadline) {
3035
+ return Number.isFinite(deadline) ? Math.max(0, deadline - Date.now()) : Number.POSITIVE_INFINITY
3036
+ }
3037
+
3038
+ /** 把"剩余预算"变成可中断的 AbortSignal(githubJson 支持 signal,超时会真的中断 https 请求与镜像竞速)。
3039
+ * 拿不到就返回 undefined —— 此时仍由调用方的时间判断 + githubJson 自带超时兜底。 */
3040
+ function budgetSignal(ms) {
3041
+ if (!Number.isFinite(ms) || ms <= 0) return undefined
3042
+ if (typeof AbortSignal === 'undefined' || typeof AbortSignal.timeout !== 'function') return undefined
3043
+ return AbortSignal.timeout(Math.max(1, Math.ceil(ms)))
3044
+ }
3045
+
3046
+ /** asset 文件名归一:大小写不敏感 + 下划线/短横线互换(issue #3 明确要求容忍这两种变体)。 */
3047
+ function normalizeAssetName(name) {
3048
+ return String(name ?? '').toLowerCase().replace(/_/gu, '-')
3049
+ }
3050
+
3051
+ /** 包名 → 可接受的 asset 文件名主干(去版本号后应与其中之一相等)。
3052
+ * `@scope/pkg` → `scope-pkg`(精确形式)与 `pkg`(裸名形式);非 scoped 包只有一种形式。
3053
+ * 顺序即优先级:**精确形式优先**(少一次"同名不同 scope"的误判机会)。 */
3054
+ function releaseAssetStems(packageName) {
3055
+ const raw = normalizeAssetName(String(packageName ?? '').trim())
3056
+ if (raw === '') return []
3057
+ const m = raw.match(/^@([^/]+)\/(.+)$/u)
3058
+ if (m) return [`${m[1]}-${m[2]}`, m[2]]
3059
+ return [raw]
3060
+ }
3061
+
3062
+ /** asset 名 → 包名匹配信息(纯函数,单测覆盖)。返回 null = 不是这个包的产物。
3063
+ * 容忍:`scope-pkg-<version>.tgz`、`scope-pkg.tgz`、`pkg-<version>.tgz`、`pkg.tgz`(大小写/下划线变体)。
3064
+ * 只认 tarball(.tgz / .tar.gz):zip/exe/源码包没有安装路径,当它们不存在比"装了再说"安全。 */
3065
+ export function assetMatchInfo(assetName, packageName) {
3066
+ const file = normalizeAssetName(assetName)
3067
+ const base = file.replace(/\.tar\.gz$/u, '').replace(/\.tgz$/u, '')
3068
+ if (base === file || base === '') return null
3069
+ const stems = releaseAssetStems(packageName)
3070
+ for (let i = 0; i < stems.length; i += 1) {
3071
+ const stem = stems[i]
3072
+ if (base === stem) return { file, exact: i === 0, version: null }
3073
+ if (!base.startsWith(`${stem}-`)) continue
3074
+ // 版本号必须紧跟主干:`scope-pkg-other-1.0.0` 这种"别的包名以本包名开头"不能算命中
3075
+ const rest = base.slice(stem.length + 1)
3076
+ if (!/^v?\d/u.test(rest)) continue
3077
+ return { file, exact: i === 0, version: parseSemverText(rest) === null ? null : rest.replace(/^v/u, '') }
3078
+ }
3079
+ return null
3080
+ }
3081
+
3082
+ /** 候选产物的排序(纯函数,单测覆盖):① 包名精确匹配优先 ② 版本更高优先(带版本 > 不带版本)
3083
+ * ③ release 更新优先 ④ 文件名兜底(保证结果确定,不受输入顺序影响)。 */
3084
+ function compareAssetMatch(a, b) {
3085
+ if (a.exact !== b.exact) return a.exact ? -1 : 1
3086
+ const av = a.version === null ? null : parseSemverText(a.version)
3087
+ const bv = b.version === null ? null : parseSemverText(b.version)
3088
+ if (av !== null && bv !== null) {
3089
+ const d = compareSemverText(bv, av)
3090
+ if (d !== 0) return d
3091
+ } else if (av !== null || bv !== null) {
3092
+ return av !== null ? -1 : 1
3093
+ }
3094
+ const byTime = (b.publishedAt ?? 0) - (a.publishedAt ?? 0)
3095
+ if (byTime !== 0) return byTime
3096
+ return String(a.file).localeCompare(String(b.file))
3097
+ }
3098
+
3099
+ /** 一条 release 的 assets → 与包名匹配的候选(已排序)。 */
3100
+ export function rankReleaseAssets(assets, packageName, publishedAt = 0) {
3101
+ const out = []
3102
+ for (const asset of (Array.isArray(assets) ? assets : [])) {
3103
+ const name = typeof asset?.name === 'string' ? asset.name : ''
3104
+ const info = assetMatchInfo(name, packageName)
3105
+ if (info === null) continue
3106
+ out.push({ asset, ...info, publishedAt })
3107
+ }
3108
+ return out.sort(compareAssetMatch)
3109
+ }
3110
+
3111
+ /** 遍历的分组(每个候选仓库 + 它的 release 列表)→ 选定结果(纯函数,单测覆盖)。
3112
+ * 命中:{ ok:true, repo, release, asset, file, version, tried }
3113
+ * 未命中:{ ok:false, tried, message } ← message 是清单式排查文案,**不是抛出的异常**
3114
+ * 语义:候选仓库按优先级**依次**尝试,第一个找到匹配 asset 的仓库胜出(不再跨仓库比版本——
3115
+ * 否则"最可疑的仓库"会被"更晚反查到的仓库"顶掉,来源就不可预期了)。 */
3116
+ export function planReleaseInstall(packageName, groups) {
3117
+ const tried = []
3118
+ for (const group of (Array.isArray(groups) ? groups : [])) {
3119
+ const repo = group?.repo ?? null
3120
+ if (group?.error) {
3121
+ tried.push({ repo, error: String(group.error), releases: [] })
3122
+ continue
3123
+ }
3124
+ const matches = []
3125
+ const rows = []
3126
+ for (const release of (Array.isArray(group?.releases) ? group.releases : [])) {
3127
+ const at = Date.parse(release?.published_at ?? release?.created_at ?? '') || 0
3128
+ const ranked = rankReleaseAssets(release?.assets, packageName, at)
3129
+ rows.push({
3130
+ tag: typeof release?.tag_name === 'string' ? release.tag_name : null,
3131
+ assets: (Array.isArray(release?.assets) ? release.assets : []).map((a) => (typeof a?.name === 'string' ? a.name : '')),
3132
+ matched: ranked.map((r) => r.file),
3133
+ })
3134
+ for (const r of ranked) matches.push({ ...r, release })
3135
+ }
3136
+ if (matches.length > 0) {
3137
+ matches.sort(compareAssetMatch)
3138
+ const best = matches[0]
3139
+ const tag = typeof best.release?.tag_name === 'string' ? best.release.tag_name.replace(/^v/iu, '') : null
3140
+ return { ok: true, repo, release: best.release, asset: best.asset, file: best.file, version: best.version ?? tag, tried }
3141
+ }
3142
+ tried.push({ repo, releases: rows })
3143
+ }
3144
+ return { ok: false, tried, message: releaseChannelFailureText(packageName, tried) }
3145
+ }
3146
+
3147
+ /** 失败时的清单式文案(纯函数,单测覆盖):把"尝试过哪些仓库、每个仓库有哪些 release/asset"**如实**摊开——
3148
+ * 排查这类问题全靠这份清单(旧文案只有一句"仓库没有 latest release",用户根本不知道还试过谁)。 */
3149
+ function releaseChannelFailureText(packageName, tried) {
3150
+ const name = String(packageName ?? '(未知名)')
3151
+ const head = `GitHub release 通道:没能找到与包名 ${name} 匹配的发布产物`
3152
+ if (!Array.isArray(tried) || tried.length === 0) {
3153
+ return `${head}(也没能反查到候选仓库:显式仓库为空、本机没有已安装的该包、npm registry 元数据与 GitHub 搜索都没能给出仓库)。`
3154
+ }
3155
+ const lines = tried.map((t) => {
3156
+ const repo = t?.repo ?? '(未知仓库)'
3157
+ if (t?.error) return `· ${repo}:读取 releases 失败(${t.error})`
3158
+ const releases = Array.isArray(t?.releases) ? t.releases : []
3159
+ if (releases.length === 0) return `· ${repo}:没有任何 release`
3160
+ const rows = releases.map((r) => `${r.tag ?? '(无 tag)'} → ${r.assets.length > 0 ? r.assets.join('、') : '(无 asset)'}`)
3161
+ return `· ${repo}:${rows.join(';')}`
3162
+ })
3163
+ return `${head}。已尝试的仓库与资产清单:\n${lines.join('\n')}`
3164
+ }
3165
+
3166
+ /** 仓库标识归一(`owner/name`、完整 URL、`git+https://…`):非法输入返回 null 而不是抛。
3167
+ * 复用 githubRepoInfo(仓库名格式的唯一权威),它抛错就说明用户给的不是仓库。 */
3168
+ function normalizeRepoSpec(value) {
3169
+ const raw = String(value ?? '').trim().replace(/^git\+/u, '')
3170
+ if (raw === '') return null
3171
+ try {
3172
+ return githubRepoInfo(raw)
3173
+ } catch {
3174
+ return null
3175
+ }
3176
+ }
3177
+
3178
+ /** npm 包名 → packument URL 段(与 curlManualInstall 同一口径:scope 的 `/` 编成 %2f)。 */
3179
+ function encodeNpmName(packageName) {
3180
+ const name = String(packageName)
3181
+ return name.startsWith('@')
3182
+ ? `@${encodeURIComponent(name.slice(1).split('/')[0])}%2f${encodeURIComponent(name.split('/').slice(1).join('/'))}`
3183
+ : encodeURIComponent(name)
3184
+ }
3185
+
3186
+ /** npm registry 元数据反查仓库:多源依次尝试(镜像/官方),命中 repository 即返回。
3187
+ * 注:包根本没发布到 registry(issue 里的 @dsh-external/* 正是如此,npmjs/npmmirror 双 404)时这里就是空手,
3188
+ * 必须靠后面的 GitHub 搜索兜底——所以这一段的失败绝不能当成"没有可用产物"。
3189
+ * 预算:每个 registry 的单次超时是 min(RELEASE_META_TIMEOUT_MS, 剩余预算),预算耗尽即整体放弃。 */
3190
+ async function repoFromNpmMetadata(packageName, registries, fetchJson, deadline = Number.POSITIVE_INFINITY) {
3191
+ for (const reg of (registries ?? []).slice(0, 3)) {
3192
+ const left = remainingMs(deadline)
3193
+ if (left <= 0) break
3194
+ try {
3195
+ const meta = await fetchJson(`${reg}/${encodeNpmName(packageName)}`, Math.max(1000, Math.min(RELEASE_META_TIMEOUT_MS, left)))
3196
+ const repo = parseRepoFromUrl(meta?.repository?.url ?? meta?.repository ?? '')
3197
+ if (repo !== null) return { repo, from: `npm 元数据(${reg})` }
3198
+ } catch {}
3199
+ }
3200
+ return null
3201
+ }
3202
+
3203
+ /** 从包名推导仓库:GitHub 仓库搜索,按"仓库名与包名逐字对上 → 星数"挑。
3204
+ * 实测(2026-09-22,issue #3 验收):`@scope/name` 的 `scope name` 查询**常常 0 条**——scope 不在仓库检索面里
3205
+ * (dsh-external dsh-super-injector → 0 条,而 dsh-super-injector → 5 条且首位就是正确仓库)。
3206
+ * 所以先按 scope+name 试一次,没有再退回裸包名;未登录/限流/网络失败一律跳过,不抛。
3207
+ * 预算:每次搜索都带剩余预算的 AbortSignal,到点即停(搜索接口限额 30 次/分,也不该多打)。 */
3208
+ async function reposFromGithubSearch(packageName, token, ghJson, deadline = Number.POSITIVE_INFINITY) {
3209
+ const raw = normalizeAssetName(String(packageName ?? '').trim())
3210
+ if (raw === '') return []
3211
+ const m = raw.match(/^@([^/]+)\/(.+)$/u)
3212
+ const base = m ? m[2] : raw
3213
+ const queries = m ? [`${m[1]} ${base}`, base] : [base]
3214
+ for (const q of queries) {
3215
+ const left = remainingMs(deadline)
3216
+ if (left <= 0) break
3217
+ let items = []
3218
+ try {
3219
+ const data = await ghJson(`${GITHUB_API}/search/repositories?q=${encodeURIComponent(q)}&per_page=${RELEASE_SEARCH_LIMIT}`, budgetSignal(left), token)
3220
+ items = Array.isArray(data?.items) ? data.items : []
3221
+ } catch {
3222
+ continue
3223
+ }
3224
+ const hitName = (it) => normalizeAssetName(String(it?.name ?? ''))
3225
+ const named = items.filter((it) => hitName(it) === base)
3226
+ const picked = (named.length > 0 ? named : items.filter((it) => hitName(it).includes(base)))
3227
+ .slice()
3228
+ .sort((a, b) => (b?.stargazers_count ?? 0) - (a?.stargazers_count ?? 0))
3229
+ .slice(0, named.length > 0 ? RELEASE_SEARCH_REPOS : 1)
3230
+ const out = picked
3231
+ .map((it) => ({ repo: normalizeRepoSpec(it?.full_name), from: `GitHub 搜索「${q}」` }))
3232
+ .filter((c) => c.repo !== null)
3233
+ if (out.length > 0) return out
3234
+ }
3235
+ return []
3236
+ }
3237
+
3238
+ /** 网络侧的两条反查(带 10 分钟缓存)。deadline 是整条 release 链路的总预算终点。 */
3239
+ async function networkCandidateRepos(packageName, { registries, token, fetchers, deadline = Number.POSITIVE_INFINITY }) {
3240
+ const key = String(packageName ?? '')
3241
+ const hit = releaseRepoCache.get(key)
3242
+ if (hit !== undefined && Date.now() - hit.at < RELEASE_REPO_CACHE_TTL) return hit.repos
3243
+ const repos = []
3244
+ const npm = await repoFromNpmMetadata(packageName, registries, fetchers.fetchJson, deadline)
3245
+ if (npm !== null) repos.push(npm)
3246
+ if (repos.length < MAX_RELEASE_CANDIDATE_REPOS && remainingMs(deadline) > 0) {
3247
+ repos.push(...await reposFromGithubSearch(packageName, token, fetchers.githubJson, deadline))
3248
+ }
3249
+ releaseRepoCache.set(key, { at: Date.now(), repos })
3250
+ return repos
3251
+ }
3252
+
3253
+ /** 候选仓库集合(按优先级,去重,上限 MAX_RELEASE_CANDIDATE_REPOS):
3254
+ * ① 显式给的 repo(现有行为,优先级最高——调用方说哪个仓库就是哪个)
3255
+ * ② name 已安装/可解析时读其 package.json 的 repository(复用 entryPkgMeta,本地零网络成本)
3256
+ * ③ npm registry 元数据的 repository
3257
+ * ④ 从包名推导:GitHub 搜索 scope/name(失败即跳过)
3258
+ * 每条都带 from(来源),最终写进用户可见的"来源"说明里。 */
3259
+ export async function resolveReleaseCandidateRepos(options = {}) {
3260
+ const {
3261
+ repo = null, packageName = null, baseUrl = null, profileDir = null,
3262
+ registries = null, token = null, fetchers = {},
3263
+ deadline = Date.now() + RELEASE_CHANNEL_BUDGET_MS,
3264
+ } = options
3265
+ const fetch = { fetchJson: fetchers.fetchJson ?? fetchJsonUrl, githubJson: fetchers.githubJson ?? githubJson }
3266
+ const out = []
3267
+ const push = (candidate) => {
3268
+ if (candidate?.repo == null) return
3269
+ if (out.some((c) => c.repo.toLowerCase() === candidate.repo.toLowerCase())) return
3270
+ if (out.length >= MAX_RELEASE_CANDIDATE_REPOS) return
3271
+ out.push(candidate)
3272
+ }
3273
+ push({ repo: normalizeRepoSpec(repo), from: '调用方显式指定' })
3274
+ if (typeof packageName === 'string' && packageName !== '') {
3275
+ try {
3276
+ const meta = entryPkgMeta(packageName, baseUrl ?? 'file:///', profileDir ?? null)
3277
+ push({ repo: parseRepoFromUrl(meta?.repository ?? ''), from: '本机已装包的 package.json.repository' })
3278
+ } catch {}
3279
+ for (const c of await networkCandidateRepos(packageName, {
3280
+ registries: Array.isArray(registries) && registries.length > 0 ? registries : orderedRegistries(readSources()),
3281
+ token,
3282
+ fetchers: fetch,
3283
+ deadline,
3284
+ })) push(c)
3285
+ }
3286
+ return out
3287
+ }
3288
+
3289
+ /** 取一个仓库的最近若干条 release(**一次**接口调用拿到 release 及其 assets,不翻页)。
3290
+ * 失败不抛:返回 { releases: [], error },由清单式文案如实汇报"这个仓库没读成"。
3291
+ * budgetMs 是这条链路剩余的预算:≤0 时直接返回"超出预算"(不发起请求),正数则作为本次调用的硬上限。 */
3292
+ export async function fetchReleaseList(repo, token = null, ghJson = githubJson, limit = RELEASE_LIST_LIMIT, budgetMs = RELEASE_CHANNEL_BUDGET_MS) {
3293
+ const left = Number.isFinite(budgetMs) ? Math.min(budgetMs, RELEASE_CHANNEL_BUDGET_MS) : RELEASE_CHANNEL_BUDGET_MS
3294
+ if (!(left > 0)) return { releases: [], error: `${releaseBudgetText()}已用尽,未再请求该仓库` }
3295
+ try {
3296
+ const data = await ghJson(`${GITHUB_API}/repos/${repo}/releases?per_page=${limit}`, budgetSignal(left), token)
3297
+ const releases = (Array.isArray(data) ? data : []).filter((r) => r !== null && typeof r === 'object')
3298
+ // 新→旧:接口默认按创建时间倒序,这里显式排序,保证"逐条尝试"的顺序与"版本更高优先"的输入确定
3299
+ releases.sort((a, b) => (Date.parse(b.published_at ?? b.created_at ?? '') || 0) - (Date.parse(a.published_at ?? a.created_at ?? '') || 0))
3300
+ return { releases, error: null }
3301
+ } catch (error) {
3302
+ return { releases: [], error: error instanceof Error ? error.message : String(error) }
3303
+ }
3304
+ }
3305
+
3306
+ /** 选源主入口:反查候选仓库 → 逐仓库取 release 列表 → 第一个匹配上的仓库胜出。
3307
+ * 返回 planReleaseInstall 的结果,外加:
3308
+ * · repos/froms:反查到的候选仓库(如实写进用户可见来源/排查文案)
3309
+ * · sourceFallback:全部候选都没有匹配 asset 时,仍可用的"最新 tag 源码 tarball"(老行为兜底)
3310
+ * · expired:本次是否因为总预算用尽而提前收工(失败文案要把这件事说清楚)
3311
+ * 任何探测失败都不抛——未命中时由调用方决定是抛清单式错误还是走兜底。
3312
+ * ★ 硬预算(issue #3):整个过程被 RELEASE_CHANNEL_BUDGET_MS 封顶,且只对前 RELEASE_SCAN_MAX_REPOS 个
3313
+ * 候选仓库"列 release";到点即返回未命中,绝不阻塞安装主链。 */
3314
+ export async function selectReleaseInstall(options = {}) {
3315
+ const {
3316
+ repo = null, packageName = null, baseUrl = null, profileDir = null,
3317
+ registries = null, token = null, fetchers = {},
3318
+ budgetMs = RELEASE_CHANNEL_BUDGET_MS,
3319
+ } = options
3320
+ const deadline = Date.now() + (Number.isFinite(budgetMs) && budgetMs > 0 ? budgetMs : RELEASE_CHANNEL_BUDGET_MS)
3321
+ const ghJson = fetchers.githubJson ?? githubJson
3322
+ const candidates = await resolveReleaseCandidateRepos({ repo, packageName, baseUrl, profileDir, registries, token, fetchers, deadline })
3323
+ const groups = []
3324
+ let expired = false
3325
+ for (const candidate of candidates.slice(0, RELEASE_SCAN_MAX_REPOS)) {
3326
+ const left = remainingMs(deadline)
3327
+ if (left <= 0) {
3328
+ expired = true
3329
+ groups.push({ repo: candidate.repo, from: candidate.from, releases: [], error: `${releaseBudgetText()}已用尽,未再扫描该仓库` })
3330
+ continue
3331
+ }
3332
+ // 逐仓库串行:拿到第一个有匹配 asset 的仓库就停(后面的候选连 releases 都不必读)
3333
+ const fetched = await fetchReleaseList(candidate.repo, token, ghJson, RELEASE_LIST_LIMIT, left)
3334
+ if (fetched.error !== null && remainingMs(deadline) <= 0) expired = true
3335
+ groups.push({ repo: candidate.repo, from: candidate.from, error: fetched.error, releases: fetched.releases })
3336
+ const plan = planReleaseInstall(packageName, groups)
3337
+ if (plan.ok) return { ...plan, repos: candidates.map((c) => ({ ...c })), groups, expired }
3338
+ }
3339
+ if (candidates.length > RELEASE_SCAN_MAX_REPOS) {
3340
+ groups.push({
3341
+ repo: `(另有 ${candidates.length - RELEASE_SCAN_MAX_REPOS} 个候选仓库)`, from: '预算裁剪',
3342
+ releases: [], error: `候选仓库扫描上限为 ${RELEASE_SCAN_MAX_REPOS} 个(预算裁剪),未再扫描`,
3343
+ })
3344
+ }
3345
+ const plan = planReleaseInstall(packageName, groups)
3346
+ if (expired) plan.message = `${plan.message}\n(注:${releaseBudgetText()}已用尽,剩余候选仓库与资产未再扫描——这是时间预算,不代表它们没有产物)`
3347
+ return { ...plan, repos: candidates.map((c) => ({ ...c })), groups, sourceFallback: sourceTarballFallback(groups), expired }
3348
+ }
3349
+
3350
+ /** 老行为兜底(**保留**,不是新增能力):候选仓库的 release 里一个匹配 asset 都没有时,仍按
3351
+ * "第一条 release 的 tag + codeload 源码 tarball"装——很多插件仓库就是只打 tag 不发 asset 的,
3352
+ * 删掉这条路会让它们从"能装"变成"装不上"。盒子验证照旧把关包名,装错包名一律被拒绝。 */
3353
+ export function sourceTarballFallback(groups) {
3354
+ for (const group of (Array.isArray(groups) ? groups : [])) {
3355
+ const first = (Array.isArray(group?.releases) ? group.releases : [])[0]
3356
+ const tag = typeof first?.tag_name === 'string' ? first.tag_name : null
3357
+ if (tag !== null) return { repo: group.repo, tag }
3358
+ }
3359
+ return null
3360
+ }
3361
+
3362
+ /** release 产物的下载候选地址(纯函数,单测覆盖):直连优先 + 镜像兜底。
3363
+ * 空 url 返回空数组(调用方按"没有下载地址"报错,不去打无意义的请求)。 */
3364
+ export function releaseDownloadUrls(url) {
3365
+ const raw = String(url ?? '').trim()
3366
+ if (raw === '') return []
3367
+ return [raw, ...RELEASE_DOWNLOAD_MIRROR_PREFIXES.map((prefix) => `${prefix}${raw}`)]
3368
+ }
3369
+
3370
+ /** curl 下载 release 产物到 dest(带体积上下限与超时):asset 是仓库里的任意文件,
3371
+ * 太小=没下成(黑洞期常见 0 字节/错误页),太大=不该拉进临时目录。runner 可注入(单测)。
3372
+ * 下载地址按 releaseDownloadUrls 顺序依次尝试,**总时长被 timeoutMs 封顶**(每次尝试只拿到剩余预算,
3373
+ * 所以镜像再多也不会把兜底通道拖长);第一个下成并通过体积校验的即胜出。 */
3374
+ export async function downloadReleaseArtifact(url, dest, options = {}) {
3375
+ const { bin = null, maxBytes = MAX_RELEASE_ASSET_BYTES, timeoutMs = 70000, runner = execFileAsync, mirrors = true } = options
3376
+ const curlBin = bin ?? (process.platform === 'win32' ? 'curl.exe' : 'curl')
3377
+ const urls = mirrors ? releaseDownloadUrls(url) : [String(url ?? '')].filter((u) => u !== '')
3378
+ if (urls.length === 0) throw new Error('GitHub 通道:没有下载地址')
3379
+ const deadline = Date.now() + timeoutMs
3380
+ let lastError = null
3381
+ for (let i = 0; i < urls.length; i += 1) {
3382
+ const left = deadline - Date.now()
3383
+ if (left < 5000) { lastError = lastError ?? new Error(`下载总预算 ${Math.round(timeoutMs / 1000)} 秒已用尽`); break }
3384
+ const attempt = urls[i]
3385
+ try {
3386
+ await runner(curlBin, ['-s', '-L', '-m', String(Math.max(5, Math.min(60, Math.floor(left / 1000)))), '-o', dest, attempt], { timeout: Math.min(timeoutMs, left) + 3000, windowsHide: true })
3387
+ if (!existsSync(dest)) throw new Error(`下载没有落盘(${attempt})`)
3388
+ const size = statSync(dest).size
3389
+ if (size < 100) throw new Error(`下载内容过小(${size} 字节,${attempt})`)
3390
+ if (size > maxBytes) throw new Error(`产物超过体积上限(${(size / 1048576).toFixed(1)}MB > ${Math.round(maxBytes / 1048576)}MB,${attempt})`)
3391
+ return size
3392
+ } catch (error) {
3393
+ lastError = error
3394
+ try { rmSync(dest, { force: true }) } catch {}
3395
+ }
3396
+ }
3397
+ const tried = urls.map((u) => (u === urls[0] ? `${u}(直连)` : u)).join('、')
3398
+ throw new Error(`GitHub 通道:下载失败(已尝试 ${urls.length} 条地址:${tried}):${lastError?.message ?? '未知'}`)
3399
+ }
3400
+
3401
+ /** 安装目标根(宿主插件特判):本面板部署在宿主根层 node_modules,更新时覆盖根层而非 web profile
3402
+ * node_modules(包根本身由 lib/index.js 的 import.meta.url 解析——全仓库只有那一处算包根)。
3403
+ * 返回 `<root>/<packageName>`。
3404
+ * ★ 特判的判据必须包含"自身确实住在某个 node_modules 里"(dirname 的 basename 为 node_modules):
3405
+ * 旧判据只有 `existsSync(<pkg>/package.json)`,而任何**开发检出**(D:\dsh\dsh-plugin-hub 这种
3406
+ * 不在 node_modules 下的目录)都满足它 → 目标会被算成检出的**父目录**,release 通道装一次插件就往
3407
+ * `D:\dsh\<包名>` 写一份。生产布局不变:宿主根层 `<host>/node_modules/<pkg>` 仍然命中特判。 */
3408
+ function releaseInstallTarget(profileDir, packageName) {
3409
+ let targetRoot = join(profileDir, 'node_modules')
3410
+ try {
3411
+ const selfDir = dirname(dirname(fileURLToPath(import.meta.url))) // 自身包目录(含 package.json)
3412
+ const selfRoot = dirname(selfDir) // 自身包所在 node_modules
3413
+ if (selfRoot !== targetRoot && basename(selfRoot) === 'node_modules' && existsSync(join(selfDir, 'package.json'))) targetRoot = selfRoot
3414
+ } catch {}
3415
+ return join(targetRoot, packageName)
3416
+ }
3417
+
2948
3418
  /**
2949
- * GitHub release 下载安装通道(npm 上不存在的包,例如面板自身 @deepseek-ai/dsh-plugin-console):
2950
- * 拉取仓库 latest release 源码 tarball → 盒子验证 → 覆盖 node_modules 正式位置。
2951
- * 宿主插件特判:本面板部署在宿主根层 node_modules(import.meta.url 解析),更新时覆盖根层而非 web profile。
3419
+ * GitHub release 下载安装通道(npm 上不存在的包,例如只发 GitHub release 的社区插件)。
3420
+ * issue #3 起不再"只用 job.repo + releases/latest":
3421
+ * ① 按包名反查真实发布仓库(显式 repo → 已装包 package.json.repository → npm 元数据 → GitHub 搜索包名);
3422
+ * ② 遍历候选仓库最近 ≤10 条 release,把每条 release 的 assets 全列出,**按包名匹配**挑选产物;
3423
+ * ③ 所有候选都没有匹配 asset 时,退回老行为(最新 tag 的 codeload 源码 tarball)——很多插件仓库
3424
+ * 只打 tag 不发 asset,删掉这条路会让它们从"能装"变成"装不上";盒子验证照旧把关包名。
3425
+ * 选源/挑选的纯逻辑与只读探测在上面的 release 源解析段(含硬预算:总 20 秒 + 候选仓库扫描上限)。
3426
+ * 签名向后兼容:第 4 个参数是可选扩展(baseUrl/registries/token),旧调用点不受影响。
3427
+ * 返回里的 sourceNote 如实写明"哪个仓库的哪条 release 的哪个 asset"(面板原样展示给用户)。
2952
3428
  */
2953
- async function githubReleaseInstall(profileDir, repo, packageName) {
3429
+ export async function githubReleaseInstall(profileDir, repo, packageName, options = {}) {
2954
3430
  const auth = readGithubAuth()
2955
- const headers = { 'User-Agent': 'dsh-plugin-console' }
2956
- if (auth.token) headers.Authorization = `token ${auth.token}`
2957
- const release = await fetchJsonUrl(`https://api.github.com/repos/${repo}/releases/latest`, 15000, headers)
2958
- const tag = typeof release?.tag_name === 'string' ? release.tag_name : null
2959
- if (tag === null) throw new Error('GitHub 通道:仓库没有 latest release(版本发布后才可更新)')
3431
+ const token = typeof options.token === 'string' && options.token !== '' ? options.token : (auth.token ?? null)
3432
+ const registries = Array.isArray(options.registries) && options.registries.length > 0
3433
+ ? options.registries
3434
+ : orderedRegistries(readSources())
3435
+ const plan = await selectReleaseInstall({ repo, packageName, baseUrl: options.baseUrl ?? null, profileDir, registries, token })
2960
3436
  const bin = process.platform === 'win32' ? 'curl.exe' : 'curl'
2961
3437
  const tmp = join(tmpdir(), `pc-gh-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`)
2962
3438
  mkdirSync(tmp, { recursive: true })
2963
3439
  try {
2964
3440
  const tgz = join(tmp, 'pkg.tgz')
2965
- // codeload 官方源码 tarball(已验证本机可用 200)。GitHub 黑洞期由上层通道兜底
2966
- // (pnpm/git 通道),此处失败即报错保留旧版本。
2967
- await execFileAsync(bin, ['-s', '-L', '-m', '60', '-o', tgz, `https://codeload.github.com/${repo}/tar.gz/refs/tags/${encodeURIComponent(tag)}`], { timeout: 70000, windowsHide: true })
2968
- if (!existsSync(tgz) || statSync(tgz).size < 100) throw new Error(`GitHub 通道:下载源码 tarball 失败(${repo}@${tag})`)
3441
+ let version = null
3442
+ let sourceNote = null
3443
+ if (plan.ok) {
3444
+ const url = plan.asset?.browser_download_url
3445
+ if (typeof url !== 'string' || url === '') throw new Error(`GitHub 通道:asset ${plan.file} 没有下载地址(接口未返回 browser_download_url)`)
3446
+ await downloadReleaseArtifact(url, tgz, { bin })
3447
+ version = plan.version ?? null
3448
+ sourceNote = `${plan.repo} 的 release ${plan.release?.tag_name ?? '(无 tag)'} 的资产 ${plan.file}`
3449
+ } else if (plan.sourceFallback !== null) {
3450
+ const srcRepo = plan.sourceFallback.repo
3451
+ const tag = plan.sourceFallback.tag
3452
+ // codeload 官方源码 tarball(已验证本机可用 200)。GitHub 黑洞期由上层通道兜底
3453
+ // (pnpm/git 通道),此处失败即报错保留旧版本。
3454
+ await downloadReleaseArtifact(`https://codeload.github.com/${srcRepo}/tar.gz/refs/tags/${encodeURIComponent(tag)}`, tgz, { bin })
3455
+ version = String(tag).replace(/^v/iu, '')
3456
+ sourceNote = `${srcRepo} 的 release ${tag} 源码 tarball(该仓库没有任何与包名匹配的资产)`
3457
+ } else {
3458
+ // 清单式错误:说清试过哪些仓库、各自有哪些 release/asset(issue #3 的排查要求)
3459
+ throw new Error(plan.message)
3460
+ }
2969
3461
  await execFileAsync('tar', ['-xzf', tgz, '-C', tmp], { timeout: 60000, windowsHide: true })
2970
- // 源码 tarball 顶层目录名是 {repo}-{sha}:定位含 package.json 的顶层目录
3462
+ // 顶层目录名两种形态:asset(npm pack 产物)= package/,源码 tarball = {repo}-{sha}/ → 统一找含 package.json 的目录
2971
3463
  const subdirs = readdirSync(tmp, { withFileTypes: true })
2972
3464
  .filter((d) => d.isDirectory())
2973
3465
  .map((d) => join(tmp, d.name))
2974
3466
  const pkgPath = subdirs.find((d) => existsSync(join(d, 'package.json')))
2975
3467
  if (!pkgPath) throw new Error('GitHub 通道:tarball 内未找到 package.json')
2976
- // 目标位置:宿主插件(面板自身,部署在宿主根层 node_modules)→ 覆盖自身所在目录;
2977
- // 普通插件 → profile node_modules
2978
- let targetRoot = join(profileDir, 'node_modules')
2979
- try {
2980
- const selfDir = dirname(dirname(fileURLToPath(import.meta.url))) // 自身包目录(含 package.json)
2981
- const selfRoot = dirname(selfDir) // 自身包所在 node_modules
2982
- if (selfRoot !== targetRoot && existsSync(join(selfDir, 'package.json'))) {
2983
- targetRoot = selfRoot
2984
- }
2985
- } catch {}
3468
+ // 目标位置:宿主插件(面板自身,部署在宿主根层 node_modules)→ 覆盖自身所在目录;普通插件 → profile
3469
+ const target = releaseInstallTarget(profileDir, packageName)
2986
3470
  // 盒子实验:静态验证通过才覆盖(失败保留旧版本)。引用检查根用目标目录(宿主插件在根层)
2987
- const box = verifyPackageBox(pkgPath, profileDir, packageName, targetRoot)
3471
+ const box = verifyPackageBox(pkgPath, profileDir, packageName, dirname(target))
2988
3472
  const pkg = JSON.parse(readFileSync(join(pkgPath, 'package.json'), 'utf8'))
2989
3473
  // 只统计 dependencies(peerDependencies 是宿主契约,不补装——见 curlManualInstall 注释)
2990
3474
  const deps = { ...(pkg.dependencies ?? {}) }
2991
3475
  const missingDeps = Object.keys(deps).filter((d) => !existsSync(join(profileDir, 'node_modules', d)))
2992
- const target = join(targetRoot, packageName)
2993
3476
  if (existsSync(target)) rmSync(target, { recursive: true, force: true })
2994
3477
  mkdirSync(dirname(target), { recursive: true })
2995
3478
  copyTree(pkgPath, target)
2996
- // 版本号对齐(防死循环):release tarball 内 package.json version 可能滞后于 tag
2997
- // (历史发布只打 tag 不改 version),改写为 tag 版本 → 下次检测 latest===current → "已是最新"
3479
+ // 版本号对齐(防死循环):产物内 package.json version 可能滞后于 release tag
3480
+ // (历史发布只打 tag 不改 version),改写为选定版本 → 下次检测 latest===current → "已是最新"
2998
3481
  try {
2999
3482
  const targetPkgPath = join(target, 'package.json')
3000
3483
  const targetPkg = JSON.parse(readFileSync(targetPkgPath, 'utf8'))
3001
- const tagVersion = tag.replace(/^v/iu, '')
3002
- if (targetPkg.version !== tagVersion) {
3003
- targetPkg.version = tagVersion
3484
+ if (typeof version === 'string' && version !== '' && targetPkg.version !== version) {
3485
+ targetPkg.version = version
3004
3486
  writeFileSync(targetPkgPath, JSON.stringify(targetPkg, null, 4), 'utf8')
3005
3487
  }
3006
3488
  } catch {}
3007
3489
  try {
3008
3490
  writeFileSync(join(target, '.dsh-installed-at'), String(Date.now()), 'utf8')
3009
3491
  } catch {}
3010
- return { version: tag.replace(/^v/iu, ''), missingDeps, boxNote: box.note, source: 'github' }
3492
+ return { version, missingDeps, boxNote: box.note, source: 'github', sourceNote }
3011
3493
  } finally {
3012
3494
  rmSync(tmp, { recursive: true, force: true })
3013
3495
  }
@@ -5183,6 +5665,149 @@ function frameworkCheckPromptText(fc) {
5183
5665
  ].filter((s) => s !== null).join('\n')
5184
5666
  }
5185
5667
 
5668
+ // ── 安装通道派发(issue #3:守卫收尾 + release 反查预算 + 通道桩注入缝)──────────────────
5669
+
5670
+ /** release 通道的候选预算(issue #3):release 通道现在按包名反查发布仓库,**每个候选**至少一次
5671
+ * `releases?per_page=10` 调用(第一梯队仓库没命中还要多试几个候选仓库),而候选可能有十几个
5672
+ * (聚合仓库懒惰展开后最多 19 个)——不封顶会把 8 分钟的作业时间预算吃光。
5673
+ * 只给前 N 个候选扫 release;排在后面的候选仍照走 curl/并行竞速(按包名施工,代价小)。 */
5674
+ const RELEASE_CHANNEL_BUDGET = 3
5675
+
5676
+ /** 安装通道实现集合(默认真实实现;ctx.installChannels 可覆盖)。
5677
+ * 为什么留这个缝:通道守卫(哪些通道在懒惰展开之后仍应被尝试)正是 issue #3 的核心语义,
5678
+ * 用真实通道无法离线断言"谁被调用了"——单测注入桩函数即可把语义钉死(见 test-suite-detect.mjs ⑪)。
5679
+ * 也给离线 e2e(test-suite-install.mjs)用:那条用例不该为了验证通道派发去真装一个包。 */
5680
+ function channelImpls(ports) {
5681
+ const real = { pnpmInstall, curlManualInstall, raceInstallChannels, githubReleaseInstall, backfillMissingDeps }
5682
+ const override = ports?.installChannels
5683
+ return override !== null && typeof override === 'object' ? { ...real, ...override } : real
5684
+ }
5685
+
5686
+ /** 单个候选包的通道尝试序列(通道实现由 ch 注入;job.curlNote 等展示字段在此更新)。
5687
+ * 返回 { installedName, lastError }。三条守卫语义及理由(issue #3 要求逐条写清):
5688
+ *
5689
+ * ① 并行竞速(pnpm‖curl)与 curl 手动通道:**始终可试,不判 expanded**。
5690
+ * 这两条都按 name 走 registry,与 job.repo、根包是否 private 毫无关系;懒惰展开只是让候选变多,
5691
+ * 没有任何理由让展开后的候选失去这两条通道。旧代码的 `!expanded` 会让展开后的所有候选
5692
+ * 直接跳到最后 → 子包只能靠 AI 兜底,正是 issue 报的"四分钟才装上"。
5693
+ *
5694
+ * ② GitHub release 通道:**不判 expanded,也不判 subpackageMode**(只受"是否反查到候选仓库"限制)。
5695
+ * issue #3 之后它会按包名反查真实发布仓库——子包的产物常常发布在**另一个仓库**的 release 里
5696
+ * (实测:dsh-routing-suite 的私根包 @dsh-external/dsh-super-injector,产物在 dsh-super-injector
5697
+ * 仓库的 release 资产里)。按 job.repo 判断"该不该试 release"因此不再成立,代价用预算封顶。
5698
+ *
5699
+ * ③ git 通道:**保留 repoChannelAllowed**(它只 clone `job.repo`,候选是子包时 clone 根仓库装不出子包,
5700
+ * 属无意义尝试)**并保留 !expanded**(同一作业里对同一个 job.repo 反复 clone 纯属浪费时间,
5701
+ * 级联顺序也不该被破坏)。 */
5702
+ export async function tryCandidateChannels({ job, ch, name, profileDir, registries, repoChannelAllowed, budget, baseUrl = null, expanded = false }) {
5703
+ let installedName = null
5704
+ let lastError = null
5705
+ // 加法优化:包已在 node_modules 且名字匹配时,不再重复安装/触发 EPERM,直接进入启用流程
5706
+ const existingTarget = join(profileDir, 'node_modules', name, 'package.json')
5707
+ if (existsSync(existingTarget)) {
5708
+ try {
5709
+ const existingPkg = JSON.parse(readFileSync(existingTarget, 'utf8'))
5710
+ if (existingPkg && existingPkg.name === name && job.update !== true) {
5711
+ job.curlNote = `已检测到本地已安装 ${name}@${existingPkg.version ?? '?'},跳过重复下载`
5712
+ return { installedName: name, lastError: null }
5713
+ }
5714
+ } catch {}
5715
+ }
5716
+ // 通道 0:并行竞速(pnpm 与 curl 同时启动,先成功者生效;失败方 abort,不影响后续串行通道)——守卫①
5717
+ {
5718
+ const raced = await ch.raceInstallChannels(profileDir, name, registries)
5719
+ if (raced) {
5720
+ installedName = name
5721
+ if (raced.channel === 'curl') {
5722
+ const racedInfo = raced.info
5723
+ const stillMissing = await ch.backfillMissingDeps(profileDir, racedInfo.missingDeps, registries)
5724
+ job.curlNote = `已通过并行 curl 通道安装 v${racedInfo.version}${stillMissing.length > 0 ? `(依赖仍未补齐:${stillMissing.join('、')},网络恢复后建议重新安装)` : '(捆绑依赖已补齐)'}`
5725
+ if (racedInfo.boxNote) job.curlNote += `(盒子验证:${racedInfo.boxNote})`
5726
+ }
5727
+ }
5728
+ }
5729
+ // 通道 1..n:配置的软件源依次尝试(每源 90 秒封顶)
5730
+ for (let ri = 0; ri < registries.length && installedName === null; ri += 1) {
5731
+ try {
5732
+ await ch.pnpmInstall(profileDir, name, registries[ri])
5733
+ installedName = name
5734
+ break
5735
+ } catch (error) {
5736
+ lastError = error
5737
+ }
5738
+ }
5739
+ if (installedName === null) {
5740
+ // 通道 n+1:curl 手动安装(node 网络黑洞时 pnpm 下载卡死、curl 可用)——下载 registry tarball
5741
+ // 解压到 node_modules,零依赖包可完整安装。守卫①:不判 expanded。
5742
+ try {
5743
+ const info = await ch.curlManualInstall(profileDir, name, registries)
5744
+ installedName = name
5745
+ const stillMissing = await ch.backfillMissingDeps(profileDir, info.missingDeps, registries)
5746
+ job.curlNote = `已通过 curl 通道安装 v${info.version}${stillMissing.length > 0 ? `(依赖仍未补齐:${stillMissing.join('、')},网络恢复后建议重新安装)` : '(捆绑依赖已补齐)'}`
5747
+ if (info.boxNote) job.curlNote += `(盒子验证:${info.boxNote})`
5748
+ } catch (curlError) {
5749
+ lastError = curlError
5750
+ }
5751
+ }
5752
+ if (installedName === null) {
5753
+ // 通道 n+1b:GitHub release 下载安装(npm 上不存在的包,例如只发 GitHub release 的社区插件)——
5754
+ // 按包名反查发布仓库 → 遍历最近 ≤10 条 release 的 assets 按包名挑产物 → 盒子验证 → 覆盖。
5755
+ // 守卫②:不判 expanded / subpackageMode(理由见本函数顶部注释);预算封顶避免吃光作业时间。
5756
+ if (budget.release > 0) {
5757
+ budget.release -= 1
5758
+ try {
5759
+ const info = await ch.githubReleaseInstall(profileDir, job.repo ?? null, name, { baseUrl })
5760
+ installedName = name
5761
+ const stillMissing = await ch.backfillMissingDeps(profileDir, info.missingDeps, registries)
5762
+ // 如实记录来源:哪个仓库的哪条 release 的哪个资产(issue #3 明确要求,面板直接展示这句话)
5763
+ const from = typeof info.sourceNote === 'string' && info.sourceNote !== '' ? `(来源:${info.sourceNote})` : ''
5764
+ job.curlNote = `已通过 GitHub release 通道安装 v${info.version ?? '?'}${from}${stillMissing.length > 0 ? `(依赖仍未补齐:${stillMissing.join('、')},网络恢复后建议重新安装)` : '(捆绑依赖已补齐)'}`
5765
+ if (info.boxNote) job.curlNote += `(盒子验证:${info.boxNote})`
5766
+ } catch (ghError) {
5767
+ lastError = ghError
5768
+ }
5769
+ } else if (lastError === null) {
5770
+ // 预算用尽:不覆盖真实错误(面板/AI 兜底要看的是 curl·pnpm 的失败原因),只在无更具体错误时说明
5771
+ lastError = new Error(`release 通道候选预算已用尽(本作业只对前 ${RELEASE_CHANNEL_BUDGET} 个候选做按包名反查+release 扫描)`)
5772
+ }
5773
+ }
5774
+ if (installedName === null) {
5775
+ // 通道 n+2:git 通道(GitHub 走加速代理+直连;Gitee 走对应平台;各 60 秒封顶)——守卫③
5776
+ if (repoChannelAllowed && !expanded) {
5777
+ const gitSpecs = job.source === 'gitee'
5778
+ ? [`git+https://gitee.com/${job.repo}.git`]
5779
+ : [
5780
+ ...gitCloneUrls(job.repo).map((u) => `git+${u}`),
5781
+ `github:${job.repo}`,
5782
+ ]
5783
+ for (const spec of gitSpecs) {
5784
+ try {
5785
+ await ch.pnpmInstall(profileDir, spec, undefined, 60000)
5786
+ installedName = name
5787
+ break
5788
+ } catch (gitError) {
5789
+ lastError = gitError
5790
+ }
5791
+ }
5792
+ }
5793
+ }
5794
+ if (installedName !== null) return { installedName, lastError }
5795
+ // Windows 原子替换失败(陈旧目录 / _tmp_ 残留)是 EPERM 类错误的根因:
5796
+ // 清理后用主源重试一次(把 AI 人工修复经验自动化,减少 AI 兜底触发)
5797
+ if (/EPERM|EACCES|rename/i.test(String(lastError?.message ?? ''))) {
5798
+ const cleaned = cleanupStalePackageDir(profileDir, name)
5799
+ if (cleaned > 0) {
5800
+ try {
5801
+ await ch.pnpmInstall(profileDir, name, registries[0])
5802
+ installedName = name
5803
+ } catch (error) {
5804
+ lastError = error
5805
+ }
5806
+ }
5807
+ }
5808
+ return { installedName, lastError }
5809
+ }
5810
+
5186
5811
  async function runInstallJob(job, ctx) {
5187
5812
  try {
5188
5813
  job.stage = 'preparing'
@@ -5310,128 +5935,23 @@ async function runInstallJob(job, ctx) {
5310
5935
  // 由 installJobView 折算成 progress{index,total,name} 下发给面板。
5311
5936
  job.candidateTotal = candidates.length
5312
5937
  job.candidateDone = false
5938
+ // 通道实现与 release 反查预算(三条守卫各自的理由见 tryCandidateChannels 顶部注释)
5939
+ const ch = channelImpls(ctx)
5940
+ const budget = { release: RELEASE_CHANNEL_BUDGET }
5313
5941
  for (let index = 0; index < candidates.length && installedName === null; index += 1) {
5314
- // Issue(2026-09-21):subpackageMode 只表达"优先装子包",不该连坐禁用其它通道。
5315
- // curl 通道按 name 走 registry(与根包是否 private 无关)→ 子包候选也开放;
5316
- // release/git 通道是按 job.repo 说的 → 只在"候选就是被请求的那个包"时试,避免对 private 根做无意义尝试。
5317
- const repoChannelAllowed = !subpackageMode || job.packageName === null || name === job.packageName
5318
- // 明明 UI 推荐的 `dsh plugin add github:owner/repo` 和 release 通道都没被尝试过。现在:
5319
5942
  if (Date.now() > deadline) break
5320
5943
  const name = candidates[index]
5321
5944
  job.candidateIndex = index + 1
5322
5945
  job.candidateName = name
5323
- // 加法优化:包已在 node_modules 且名字匹配时,不再重复安装/触发 EPERM,直接进入启用流程
5324
- if (installedName === null) {
5325
- const existingTarget = join(profileDir, 'node_modules', name, 'package.json')
5326
- if (existsSync(existingTarget)) {
5327
- try {
5328
- const existingPkg = JSON.parse(readFileSync(existingTarget, 'utf8'))
5329
- if (existingPkg && existingPkg.name === name && job.update !== true) {
5330
- installedName = name
5331
- job.curlNote = `已检测到本地已安装 ${name}@${existingPkg.version ?? '?'},跳过重复下载`
5332
- }
5333
- } catch {}
5334
- }
5335
- }
5336
- // 加法并行竞速:pnpm 与 curl 同时启动,先成功者生效;失败方 abort,不影响后续串行通道
5337
- if (installedName === null && !expanded) {
5338
- const raced = await raceInstallChannels(profileDir, name, registries)
5339
- if (raced) {
5340
- installedName = name
5341
- if (raced.channel === 'curl') {
5342
- const racedInfo = raced.info
5343
- const stillMissing = await backfillMissingDeps(profileDir, racedInfo.missingDeps, registries)
5344
- job.curlNote = `已通过并行 curl 通道安装 v${racedInfo.version}${stillMissing.length > 0 ? `(依赖仍未补齐:${stillMissing.join('、')},网络恢复后建议重新安装)` : '(捆绑依赖已补齐)'}`
5345
- if (racedInfo.boxNote) job.curlNote += `(盒子验证:${racedInfo.boxNote})`
5346
- }
5347
- }
5348
- }
5349
- // 通道 1..n:配置的软件源依次尝试(每源 90 秒封顶)
5350
- for (let ri = 0; ri < registries.length && installedName === null; ri += 1) {
5351
- try {
5352
- await pnpmInstall(profileDir, name, registries[ri])
5353
- installedName = name
5354
- break
5355
- } catch (error) {
5356
- lastError = error
5357
- }
5358
- }
5359
- if (installedName === null) {
5360
- // 通道 n+1:curl 手动安装(node 网络黑洞时 pnpm 下载卡死、curl 可用)——
5361
- // 下载 registry tarball 解压到 node_modules,零依赖包可完整安装
5362
- if (!expanded) {
5363
- try {
5364
- const info = await curlManualInstall(profileDir, name, registries)
5365
- installedName = name
5366
- const stillMissing = await backfillMissingDeps(profileDir, info.missingDeps, registries)
5367
- job.curlNote = `已通过 curl 通道安装 v${info.version}${stillMissing.length > 0 ? `(依赖仍未补齐:${stillMissing.join('、')},网络恢复后建议重新安装)` : '(捆绑依赖已补齐)'}`
5368
- if (info.boxNote) job.curlNote += `(盒子验证:${info.boxNote})`
5369
- } catch (curlError) {
5370
- lastError = curlError
5371
- }
5372
- }
5373
- }
5374
- if (installedName === null) {
5375
- // 通道 n+1b:GitHub release 下载安装(npm 上不存在的包,例如面板自身
5376
- // @deepseek-ai/dsh-plugin-console)——拉 latest release 源码 tarball → 盒子验证 → 覆盖。
5377
- // 触发条件:curl 通道失败(registry 404/网络),且能从 job.repo 或已装包 repository 反查到 GitHub。
5378
- if (repoChannelAllowed && !expanded) {
5379
- let ghRepo = typeof job.repo === 'string' && job.repo !== '' && job.repo.includes('/') ? job.repo : null
5380
- if (ghRepo === null) {
5381
- try {
5382
- const meta = entryPkgMeta(name, ctx.baseUrl ?? 'file:///', profileDirOf(ctx))
5383
- const repoUrl = typeof meta?.repository === 'string' ? meta.repository : (meta?.repository && typeof meta.repository === 'object' ? meta.repository.url : null)
5384
- const m = typeof repoUrl === 'string' ? repoUrl.replace(/^git\+/u, '').match(/github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?$/u) : null
5385
- if (m) ghRepo = m[1]
5386
- } catch {}
5387
- }
5388
- if (ghRepo !== null) {
5389
- try {
5390
- const info = await githubReleaseInstall(profileDir, ghRepo, name)
5391
- installedName = name
5392
- const stillMissing = await backfillMissingDeps(profileDir, info.missingDeps, registries)
5393
- job.curlNote = `已通过 GitHub release 通道安装 v${info.version}${stillMissing.length > 0 ? `(依赖仍未补齐:${stillMissing.join('、')},网络恢复后建议重新安装)` : '(捆绑依赖已补齐)'}`
5394
- if (info.boxNote) job.curlNote += `(盒子验证:${info.boxNote})`
5395
- } catch (ghError) {
5396
- lastError = ghError
5397
- }
5398
- }
5399
- }
5400
- }
5401
- if (installedName === null) {
5402
- // 通道 n+2:git 通道(GitHub 走加速代理+直连;Gitee 走对应平台;各 60 秒封顶)
5403
- if (repoChannelAllowed && !expanded) {
5404
- const gitSpecs = job.source === 'gitee'
5405
- ? [`git+https://gitee.com/${job.repo}.git`]
5406
- : [
5407
- ...gitCloneUrls(job.repo).map((u) => `git+${u}`),
5408
- `github:${job.repo}`,
5409
- ]
5410
- for (const spec of gitSpecs) {
5411
- try {
5412
- await pnpmInstall(profileDir, spec, undefined, 60000)
5413
- installedName = name
5414
- break
5415
- } catch (gitError) {
5416
- lastError = gitError
5417
- }
5418
- }
5419
- }
5420
- }
5421
- if (installedName !== null) break
5422
- // Windows 原子替换失败(陈旧目录 / _tmp_ 残留)是 EPERM 类错误的根因:
5423
- // 清理后用主源重试一次(把 AI 人工修复经验自动化,减少 AI 兜底触发)
5424
- if (installedName === null && /EPERM|EACCES|rename/i.test(String(lastError?.message ?? ''))) {
5425
- const cleaned = cleanupStalePackageDir(profileDir, name)
5426
- if (cleaned > 0) {
5427
- try {
5428
- await pnpmInstall(profileDir, name, registries[0])
5429
- installedName = name
5430
- } catch (error) {
5431
- lastError = error
5432
- }
5433
- }
5434
- }
5946
+ // Issue(2026-09-21/#3):subpackageMode 只表达"优先装子包",不再连坐禁用其它通道;
5947
+ // 它现在只服务 git 通道(release/curl/竞速都按包名施工,见 tryCandidateChannels 注释②)。
5948
+ // ⚠️ 本行必须在 `const name` **之后**求值:旧代码把它写在 name 声明之前,`name === job.packageName`
5949
+ // 一被求值就命中 TDZ(ReferenceError: Cannot access 'name' before initialization)——
5950
+ // 私有聚合根(subpackageMode=true 且 packageName 非空)安装必失败,且报错文案完全指不到真正原因。
5951
+ const repoChannelAllowed = !subpackageMode || job.packageName === null || name === job.packageName
5952
+ const attempt = await tryCandidateChannels({ job, ch, name, profileDir, registries, repoChannelAllowed, budget, baseUrl: ctx.baseUrl ?? null, expanded })
5953
+ installedName = attempt.installedName
5954
+ lastError = attempt.lastError
5435
5955
  if (installedName !== null) break
5436
5956
  // 懒惰展开:registry 与 git 通道都失败时,自动发现仓库子包继续尝试(聚合包优先),
5437
5957
  // 覆盖"给了根包名但根包未发布"的场景——AI 兜底只处理真正无解的案例
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.3.58",
3
+ "version": "0.3.59",
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",