@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 +1 -1
- package/README.zh.md +1 -1
- package/lib/index.js +677 -157
- package/package.json +1 -1
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
2844
|
-
const
|
|
2845
|
-
|
|
2846
|
-
|
|
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
|
|
2950
|
-
*
|
|
2951
|
-
*
|
|
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
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
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
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
2997
|
-
// (历史发布只打 tag 不改 version
|
|
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
|
-
|
|
3002
|
-
|
|
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
|
|
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
|
-
|
|
5324
|
-
|
|
5325
|
-
|
|
5326
|
-
|
|
5327
|
-
|
|
5328
|
-
|
|
5329
|
-
|
|
5330
|
-
|
|
5331
|
-
|
|
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.
|
|
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",
|