@noob-stupid/dsh-plugin-console 0.5.77 → 0.5.87
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/lib/client.js +490 -20
- package/lib/server/domain/dev-roots.js +12 -0
- package/lib/server/domain/disk-audit.js +330 -0
- package/lib/server/domain/enrich-throttle.js +242 -0
- package/lib/server/domain/install-box.js +77 -0
- package/lib/server/domain/install-channels.js +242 -0
- package/lib/server/domain/install-diagnose.js +9 -4
- package/lib/server/domain/install-finalize.js +3 -1
- package/lib/server/domain/install-job.js +49 -175
- package/lib/server/domain/install-jobs-store.js +72 -0
- package/lib/server/domain/install-version.js +65 -0
- package/lib/server/domain/install.js +18 -71
- package/lib/server/domain/link-inventory.js +41 -4
- package/lib/server/domain/link-self-sufficiency.js +67 -0
- package/lib/server/domain/lock-autofix.js +310 -0
- package/lib/server/domain/manifest.js +17 -3
- package/lib/server/domain/market.js +13 -7
- package/lib/server/domain/persist.js +128 -3
- package/lib/server/domain/restart-launcher.js +110 -0
- package/lib/server/domain/restart.js +84 -10
- package/lib/server/domain/selfupdate.js +6 -1
- package/lib/server/domain/skills.js +9 -5
- package/lib/server/domain/unpin-dev-dir.js +149 -0
- package/lib/server/infra/egress.js +34 -1
- package/lib/server/routes/disk.js +59 -0
- package/lib/server/routes/framework.js +63 -2
- package/lib/server/routes/index.js +19 -3
- package/lib/server/routes/install.js +6 -0
- package/lib/server/routes/link-actions.js +120 -0
- package/lib/server/routes/state.js +23 -1
- package/package.json +1 -1
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// L1 · domain —— install-channels.js(单个候选包的**通道尝试序列** + 版本闸;2026-10-18 从 install-job.js 搬出)
|
|
2
|
+
//
|
|
3
|
+
// 为什么单独成模块:install-job.js 贴着 600 行架构棘轮(tests/test-architecture-guard.mjs),而
|
|
4
|
+
// "一个候选包要按什么顺序试哪些通道、每条守卫为什么成立"本来就是一块自洽的策略。搬移只搬移未改逻辑
|
|
5
|
+
// (注释、判据、文案逐字保留),install-job.js 继续 re-export `tryCandidateChannels` 与
|
|
6
|
+
// `RELEASE_CHANNEL_BUDGET` —— **导出名与调用点一个字都没变**。
|
|
7
|
+
//
|
|
8
|
+
// 本轮(0.5.87)在这块里只改了一件事,见下方 ★ 版本号的两处注释:
|
|
9
|
+
// ① 版本号(`job.version`)必须一路传到**按名下载**的通道(curl 的两条腿、pnpm 的两次重试);
|
|
10
|
+
// ② 通道说"装上了"之后要**核对落地版本**(版本闸)—— 落地版本 ≠ 点名版本不算成功。
|
|
11
|
+
import { cleanupStalePackageDir } from '../infra/fsx.js'
|
|
12
|
+
import { alreadyInstalledTarget } from './install-verify.js'
|
|
13
|
+
import { noteChannel, tryGitChannel } from './git-channel.js'
|
|
14
|
+
import { retryPnpmOnce } from './install-diagnose.js'
|
|
15
|
+
import { targetVersionOf, versionMissOf, versionMissText } from './install-version.js'
|
|
16
|
+
import { tryPresetSourceChannel } from './preset-source.js'
|
|
17
|
+
|
|
18
|
+
/** release 通道的候选预算(issue #3):release 通道现在按包名反查发布仓库,**每个候选**至少一次
|
|
19
|
+
* `releases?per_page=10` 调用(第一梯队仓库没命中还要多试几个候选仓库),而候选可能有十几个
|
|
20
|
+
* (聚合仓库懒惰展开后最多 19 个)——不封顶会把 8 分钟的作业时间预算吃光。
|
|
21
|
+
* 只给前 N 个候选扫 release;排在后面的候选仍照走 curl/并行竞速(按包名施工,代价小)。 */
|
|
22
|
+
const RELEASE_CHANNEL_BUDGET = 3
|
|
23
|
+
|
|
24
|
+
/** 单个候选包的通道尝试序列(通道实现由 ch 注入;job.curlNote 等展示字段在此更新)。
|
|
25
|
+
* 返回 { installedName, lastError }。三条守卫语义及理由(issue #3 要求逐条写清):
|
|
26
|
+
*
|
|
27
|
+
* ① 并行竞速(pnpm‖curl)与 curl 手动通道:**始终可试,不判 expanded**。
|
|
28
|
+
* 这两条都按 name 走 registry,与 job.repo、根包是否 private 毫无关系;懒惰展开只是让候选变多,
|
|
29
|
+
* 没有任何理由让展开后的候选失去这两条通道。旧代码的 `!expanded` 会让展开后的所有候选
|
|
30
|
+
* 直接跳到最后 → 子包只能靠 AI 兜底,正是 issue 报的"四分钟才装上"。
|
|
31
|
+
*
|
|
32
|
+
* ② GitHub release 通道:**不判 expanded,也不判 subpackageMode**(只受"是否反查到候选仓库"限制)。
|
|
33
|
+
* issue #3 之后它会按包名反查真实发布仓库——子包的产物常常发布在**另一个仓库**的 release 里
|
|
34
|
+
* (实测:dsh-routing-suite 的私根包 @dsh-external/dsh-super-injector,产物在 dsh-super-injector
|
|
35
|
+
* 仓库的 release 资产里)。按 job.repo 判断"该不该试 release"因此不再成立,代价用预算封顶。
|
|
36
|
+
*
|
|
37
|
+
* ③ git 通道:**保留 repoChannelAllowed**(它只 clone `job.repo`,候选是子包时 clone 根仓库装不出子包,
|
|
38
|
+
* 属无意义尝试)**并保留 !expanded**(同一作业里对同一个 job.repo 反复 clone 纯属浪费时间,
|
|
39
|
+
* 级联顺序也不该被破坏)。 */
|
|
40
|
+
async function tryCandidateChannels({ job, ch, name, profileDir, registries, repoChannelAllowed, budget, baseUrl = null, expanded = false, deadline = null, expand = null, sourceDeps = {} }) {
|
|
41
|
+
const installSpec = (typeof job?.version === 'string' && job.version !== '' && name === job.packageName)
|
|
42
|
+
? `${name}@${job.version}`
|
|
43
|
+
: name // ★ 0.5.81:规格与身份分离 —— name 仍是身份;点名包+指定版本时规格才带 @version
|
|
44
|
+
// ★ 0.5.87 改错(真机 2026-10-18):`installSpec` 只交给了 pnpm;**curl 的两条腿拿的都是裸包名**,
|
|
45
|
+
// 于是"按 dist-tags.latest 下载"——而该 tag 在镜像上可能恰恰是**旧版**(真机
|
|
46
|
+
// @deepseek-ai/dsh-experimental-auto-review 的 latest = 0.1.6-alpha.1,而目标 0.2.1-rc.2)。
|
|
47
|
+
// 结果:旧版被原样铺回去,作业却报"更新成功"。版本号必须传到底(curlManualInstall 的 exactVersion)。
|
|
48
|
+
const exactVersion = targetVersionOf(job, name)
|
|
49
|
+
// 声明行的目标 profile(0.5.28):框架 0.1.7-rc.x 起预设不再靠目录发现,装配后必须往这个 profile 的补丁写 `preset-<id>` 行,否则用户看不见(见 preset-declare.js 顶部)。
|
|
50
|
+
// 注:0.5.87 搬出时删掉了这里的 `const patchPath = join(profileDir, 'cordis.patch.yml')` ——
|
|
51
|
+
// 它是一个**从未被读过**的局部量(预设声明早已改由 preset-declare.js 自己算路径),删掉不改任何行为。
|
|
52
|
+
let installedName = null
|
|
53
|
+
let lastError = null
|
|
54
|
+
// 哪个通道声称装上了(只用于版本闸的如实告知,不参与任何判定)
|
|
55
|
+
let lastChannel = null
|
|
56
|
+
// 0.5.41 接线:这次成功装上的**来源**(`{ spec, origin, kind }`)—— 取样通道(curl tarball / GitHub
|
|
57
|
+
// release 资产)知道真实下载地址,收口那一步据此走 `pnpm add <spec>` 真装;registry/git 通道都不填。
|
|
58
|
+
let source = null
|
|
59
|
+
// 加法优化:包已在 node_modules 且名字匹配时,不再重复安装/触发 EPERM,直接进入启用流程
|
|
60
|
+
// (2026-09-27 搬进 install-verify.js#alreadyInstalledTarget;install-job.js 当时贴着 600 行架构硬顶)
|
|
61
|
+
const already = alreadyInstalledTarget(profileDir, name, job)
|
|
62
|
+
if (already !== null) {
|
|
63
|
+
job.curlNote = already
|
|
64
|
+
// ★ 0.5.87:这条短路(`update !== true` 且包已在盘上)同样要过版本闸 —— 请求若点名了版本、
|
|
65
|
+
// 而盘上不是那一版,"已检测到本地已安装"就不能被当成"更新完成"。
|
|
66
|
+
const missText = gateVersion(job, profileDir, name, 'local')
|
|
67
|
+
if (missText === null) return { installedName: name, lastError: null }
|
|
68
|
+
return { installedName: null, lastError: new Error(missText) }
|
|
69
|
+
}
|
|
70
|
+
// 通道 0:并行竞速(pnpm 与 curl 同时启动,先成功者生效;失败方 abort,不影响后续串行通道)——守卫①
|
|
71
|
+
{
|
|
72
|
+
try {
|
|
73
|
+
// ★ 0.5.87:竞速里的 pnpm 腿要**带版本**的规格,curl 腿要**裸包名 + 目标版本**
|
|
74
|
+
// (旧代码把带 `@版本` 的规格同时塞给 curl —— 对 curl 那是非法包名,永远 404)
|
|
75
|
+
const raced = await ch.raceInstallChannels(profileDir, installSpec, registries, {}, { packageName: name, exactVersion })
|
|
76
|
+
if (raced) {
|
|
77
|
+
installedName = name
|
|
78
|
+
lastChannel = raced.channel ?? 'race'
|
|
79
|
+
if (raced.channel === 'curl') {
|
|
80
|
+
const racedInfo = raced.info
|
|
81
|
+
try {
|
|
82
|
+
const stillMissing = await ch.backfillMissingDeps(profileDir, racedInfo.missingDeps, registries)
|
|
83
|
+
job.curlNote = `已通过并行 curl 通道安装 v${racedInfo.version}${stillMissing.length > 0 ? `(依赖仍未补齐:${stillMissing.join('、')},网络恢复后建议重新安装)` : '(捆绑依赖已补齐)'}`
|
|
84
|
+
} catch (backfillError) {
|
|
85
|
+
// 包已装好:补齐依赖失败只影响提示,绝不能因此把这次安装判成失败
|
|
86
|
+
job.curlNote = `已通过并行 curl 通道安装 v${racedInfo.version}(捆绑依赖补齐失败:${backfillError instanceof Error ? backfillError.message : String(backfillError)};网络恢复后建议重新安装)`
|
|
87
|
+
noteChannel(job, `并行 curl 通道的依赖补齐失败(安装本身已成功):${backfillError instanceof Error ? backfillError.message : String(backfillError)}`)
|
|
88
|
+
}
|
|
89
|
+
if (racedInfo.boxNote) job.curlNote += `(盒子验证:${racedInfo.boxNote})`
|
|
90
|
+
job.integrity = racedInfo.integrity ?? null // ④ 下载物摘要校验结果(加法;见 install-verify.js)
|
|
91
|
+
// 0.5.41:curl tarball 是**取样通道**(物是解压铺进去的,不是包管理器装的)——
|
|
92
|
+
// 真实来源规格(registry tarball URL)带回收口。registry 可解析时收口仍写版本号(不倒退)。
|
|
93
|
+
source = { spec: racedInfo.sourceSpec ?? null, origin: 'sampled', kind: 'registry-tarball' }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} catch (raceError) {
|
|
97
|
+
lastError = raceError
|
|
98
|
+
noteChannel(job, `并行竞速通道异常(已继续尝试后续串行通道):${raceError instanceof Error ? raceError.message : String(raceError)}`)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// 通道 1..n:配置的软件源依次尝试(每源 90 秒封顶)
|
|
102
|
+
let pnpmError = null
|
|
103
|
+
for (let ri = 0; ri < registries.length && installedName === null; ri += 1) {
|
|
104
|
+
try {
|
|
105
|
+
await ch.pnpmInstall(profileDir, installSpec, registries[ri])
|
|
106
|
+
installedName = name
|
|
107
|
+
lastChannel = 'pnpm'
|
|
108
|
+
break
|
|
109
|
+
} catch (error) { lastError = error; pnpmError = error }
|
|
110
|
+
}
|
|
111
|
+
if (installedName === null) {
|
|
112
|
+
// 通道 n+1:curl 手动安装(node 网络黑洞时 pnpm 下载卡死、curl 可用)——下载 registry tarball
|
|
113
|
+
// 解压到 node_modules,零依赖包可完整安装。守卫①:不判 expanded。
|
|
114
|
+
try {
|
|
115
|
+
// ★ 0.5.87 改错(真机事故的**决定性**那一行):这里过去只传裸包名,curlManualInstall 于是取
|
|
116
|
+
// `dist-tags.latest` —— 镜像上的 latest 是旧版时,装完就是**降级/原地不动**,而作业报成功。
|
|
117
|
+
// 现在把点名版本作为 exactVersion 传进去(没点名时仍是 null ⇒ 既有行为一字不变)。
|
|
118
|
+
const info = await ch.curlManualInstall(profileDir, name, registries, null, exactVersion)
|
|
119
|
+
installedName = name
|
|
120
|
+
lastChannel = 'curl'
|
|
121
|
+
const stillMissing = await ch.backfillMissingDeps(profileDir, info.missingDeps, registries)
|
|
122
|
+
job.curlNote = `已通过 curl 通道安装 v${info.version}${stillMissing.length > 0 ? `(依赖仍未补齐:${stillMissing.join('、')},网络恢复后建议重新安装)` : '(捆绑依赖已补齐)'}`
|
|
123
|
+
if (info.boxNote) job.curlNote += `(盒子验证:${info.boxNote})`
|
|
124
|
+
job.integrity = info.integrity ?? null // ④ 下载物摘要校验结果(加法;见 install-verify.js)
|
|
125
|
+
// 0.5.41:同上(取样通道 ⇒ 带真实 tarball URL 回去真装)
|
|
126
|
+
source = { spec: info.sourceSpec ?? null, origin: 'sampled', kind: 'registry-tarball' }
|
|
127
|
+
} catch (curlError) {
|
|
128
|
+
lastError = curlError
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (installedName === null) {
|
|
132
|
+
// 通道 n+1b:GitHub release 下载安装(npm 上不存在的包,例如只发 GitHub release 的社区插件)——
|
|
133
|
+
// 按包名反查发布仓库 → 遍历最近 ≤10 条 release 的 assets 按包名挑产物 → 盒子验证 → 覆盖。
|
|
134
|
+
// 守卫②:不判 expanded / subpackageMode(理由见本函数顶部注释);预算封顶避免吃光作业时间。
|
|
135
|
+
if (budget.release > 0) {
|
|
136
|
+
budget.release -= 1
|
|
137
|
+
try {
|
|
138
|
+
const info = await ch.githubReleaseInstall(profileDir, job.repo ?? null, name, { baseUrl })
|
|
139
|
+
installedName = name
|
|
140
|
+
lastChannel = 'release'
|
|
141
|
+
const stillMissing = await ch.backfillMissingDeps(profileDir, info.missingDeps, registries)
|
|
142
|
+
// 如实记录来源:哪个仓库的哪条 release 的哪个资产(issue #3 明确要求,面板直接展示这句话)
|
|
143
|
+
const from = typeof info.sourceNote === 'string' && info.sourceNote !== '' ? `(来源:${info.sourceNote})` : ''
|
|
144
|
+
job.curlNote = `已通过 GitHub release 通道安装 v${info.version ?? '?'}${from}${stillMissing.length > 0 ? `(依赖仍未补齐:${stillMissing.join('、')},网络恢复后建议重新安装)` : '(捆绑依赖已补齐)'}`
|
|
145
|
+
if (info.boxNote) job.curlNote += `(盒子验证:${info.boxNote})`
|
|
146
|
+
// 0.5.41 接线(**本轮的核心**):GitHub release 资产(tarball URL)是取样通道 —— 把真实下载
|
|
147
|
+
// 地址带回给收口,由 `pnpm add <url>` 真装(依赖与 peer 交给包管理器,与官方 dsh plugin add 同效)。
|
|
148
|
+
source = { spec: info.sourceSpec ?? null, origin: 'sampled', kind: 'github-release' }
|
|
149
|
+
} catch (ghError) {
|
|
150
|
+
lastError = ghError
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
// 预算用尽(批次 A-⑤):原因**总是**记在 job 上供面板显示(旧代码只在 lastError === null 时写,用户看不出)
|
|
154
|
+
noteChannel(job, `release 通道因预算跳过(本作业只对前 ${RELEASE_CHANNEL_BUDGET} 个候选做按包名反查+release 扫描)`)
|
|
155
|
+
if (lastError === null) lastError = new Error(`release 通道候选预算已用尽(本作业只对前 ${RELEASE_CHANNEL_BUDGET} 个候选做按包名反查+release 扫描)`)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// 懒惰展开(批次 A-③,2026-09-27 搬家):**registry 类通道全部失败之后、git 通道之前**。
|
|
159
|
+
// 旧顺序是"所有通道(含 git)都失败 → 才展开子包":对"根包 private、未发布到 npm"的仓库
|
|
160
|
+
// (真机 zhu1090093659/dsh-web)就是 registry 404 → 直接去 clone 那个 429 MB 的巨仓
|
|
161
|
+
// (ghproxy 上 git 协议 0 B/s,白等且注定失败),而真正能装的聚合子包 @linxin666/dsh-web-all
|
|
162
|
+
// (5.97 MB)连一次机会都没有。expand() 返回新增候选数;展开失败只记备注,绝不短路。
|
|
163
|
+
if (installedName === null && expanded === false && typeof expand === 'function') {
|
|
164
|
+
try {
|
|
165
|
+
const added = await expand()
|
|
166
|
+
if (added > 0) noteChannel(job, `registry 通道未命中,已自动展开仓库子包:新增 ${added} 个候选`)
|
|
167
|
+
} catch (error) {
|
|
168
|
+
noteChannel(job, `自动展开仓库子包失败(继续走后续通道):${error instanceof Error ? error.message : String(error)}`)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (installedName === null) {
|
|
172
|
+
// 通道 n+1c(批次 D-③,2026-09-27 加法):**预设型子包的源码装配** —— 真机 dsh-routing-suite 的第三件
|
|
173
|
+
// dsh-router-standard 只存在于仓库源码里(npm 双 404、release 无资产),按包名的通道全试完才轮到它。
|
|
174
|
+
const preset = await tryPresetSourceChannel({ job, name, source: job.source, deps: sourceDeps })
|
|
175
|
+
if (preset.installed === true) { job.presetDone = true; job.presetNote = preset.note; installedName = name; lastChannel = 'preset-source' } else if (preset.error !== null && preset.error !== undefined) lastError = preset.error
|
|
176
|
+
}
|
|
177
|
+
if (installedName === null) {
|
|
178
|
+
// 通道 n+2:git 通道(GitHub 走加速代理+直连;Gitee 走对应平台)——守卫③
|
|
179
|
+
// 2026-09-27:实现搬进 domain/git-channel.js(独立预算 + 不得超过作业剩余预算;
|
|
180
|
+
// install-job.js 有 600 行硬顶,见 test-architecture-guard.mjs),此处只保留调用与错误归并。
|
|
181
|
+
const git = await tryGitChannel({ job, ch, name, profileDir, repoChannelAllowed, expanded, budget, deadline })
|
|
182
|
+
installedName = git.installedName
|
|
183
|
+
if (git.installedName !== null) lastChannel = 'git'
|
|
184
|
+
if (git.lastError !== null) lastError = git.lastError
|
|
185
|
+
}
|
|
186
|
+
// ③ 失败分类 → 只对网络类超时用更长超时定向重试**一次**;其余分类只写 job.diagnosis 提示(见 install-diagnose.js)
|
|
187
|
+
if (installedName === null && pnpmError !== null) {
|
|
188
|
+
// ★ 0.5.87:重试也必须带版本(旧代码传裸包名 ⇒ 重试装回 latest=旧版,却报"重试成功")
|
|
189
|
+
const retry = await retryPnpmOnce({ ch, profileDir, name, spec: installSpec, registries, pnpmError, job, baseTimeoutMs: 90000 })
|
|
190
|
+
installedName = retry.installedName ?? installedName
|
|
191
|
+
if (retry.installedName != null) lastChannel = 'pnpm-retry'
|
|
192
|
+
if (retry.error != null) lastError = retry.error
|
|
193
|
+
}
|
|
194
|
+
if (installedName !== null) {
|
|
195
|
+
// ★ 0.5.87 版本闸(真机 2026-10-18 的根因缺口):通道说"装上了"不算数,
|
|
196
|
+
// **落地版本 == 请求点名的版本**才算数。不相等 ⇒ 本次通道按"没装成"处理,并在 job.versionMiss
|
|
197
|
+
// 上留下如实结论;一个通道都不达标时由 install-job.js 如实失败
|
|
198
|
+
// (**不清场**:清场会把用户正在用的旧版本删掉 —— 见那里的注释)。
|
|
199
|
+
// 没点名版本(`job.version` 为空)时 versionMissOf 恒 null ⇒ 既有流程一字不变。
|
|
200
|
+
const missText = gateVersion(job, profileDir, installedName, lastChannel)
|
|
201
|
+
if (missText === null) return { installedName, lastError, source }
|
|
202
|
+
installedName = null
|
|
203
|
+
lastError = new Error(missText)
|
|
204
|
+
}
|
|
205
|
+
// Windows 原子替换失败(陈旧目录 / _tmp_ 残留)是 EPERM 类错误的根因:
|
|
206
|
+
// 清理后用主源重试一次(把 AI 人工修复经验自动化,减少 AI 兜底触发)
|
|
207
|
+
if (/EPERM|EACCES|rename/i.test(String(lastError?.message ?? ''))) {
|
|
208
|
+
const cleaned = cleanupStalePackageDir(profileDir, name)
|
|
209
|
+
if (cleaned > 0) {
|
|
210
|
+
try {
|
|
211
|
+
// ★ 0.5.87:同上 —— 重试一次也要带版本,否则等于"用 latest 覆盖掉用户点名的那一版"
|
|
212
|
+
await ch.pnpmInstall(profileDir, installSpec, registries[0])
|
|
213
|
+
// 这条重试同样是"通道声称装上了",一样要过版本闸(口径统一,不留第二条谎报路径)
|
|
214
|
+
const missText = gateVersion(job, profileDir, name, 'pnpm-retry')
|
|
215
|
+
if (missText === null) {
|
|
216
|
+
installedName = name
|
|
217
|
+
lastChannel = 'pnpm-retry'
|
|
218
|
+
} else {
|
|
219
|
+
lastError = new Error(missText)
|
|
220
|
+
}
|
|
221
|
+
} catch (error) {
|
|
222
|
+
lastError = error
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return { installedName, lastError }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 版本闸的**唯一执行点**(通道声称"装上了"之后都要过一遍):达标返回 null;
|
|
230
|
+
* 不达标把结构化结论记在 `job.versionMiss` 上、把同一句话记进通道备注,并返回那段文案。
|
|
231
|
+
* 预设源码装配(`job.presetDone`)刻意豁免:那条路本来就没有 node_modules 副本
|
|
232
|
+
* (见 preset-in-package.js —— 写补丁行/声明依赖会让补丁行指向无入口模块)。 */
|
|
233
|
+
function gateVersion(job, profileDir, name, channel) {
|
|
234
|
+
const miss = job?.presetDone === true ? null : versionMissOf(profileDir, name, job, channel)
|
|
235
|
+
if (miss === null) return null
|
|
236
|
+
job.versionMiss = miss
|
|
237
|
+
const text = versionMissText(miss)
|
|
238
|
+
noteChannel(job, text)
|
|
239
|
+
return text
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export { tryCandidateChannels, RELEASE_CHANNEL_BUDGET }
|
|
@@ -204,17 +204,22 @@ function diagnosisText(diagnosis) {
|
|
|
204
204
|
return `失败分类:${diagnosis.kind}${retried}${diagnosis.hint ? `;${diagnosis.hint}` : ''}${pkgs}`
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
-
/** ③ 的定向重试编排(**本文件唯一的非纯函数**,只被 install-
|
|
207
|
+
/** ③ 的定向重试编排(**本文件唯一的非纯函数**,只被 install-channels.js 调用):
|
|
208
208
|
* pnpm 通道的失败若分类为 network-timeout(retry === 'longer-timeout'),用**更长超时**再试**一次**;
|
|
209
209
|
* 其余分类只把 hint 写进 job.diagnosis,不重试。返回 { installedName, error, diagnosis }:
|
|
210
|
-
* 重试成功给 installedName,失败给 error(调用方据此更新 lastError,语义与原来一致)。
|
|
211
|
-
|
|
210
|
+
* 重试成功给 installedName,失败给 error(调用方据此更新 lastError,语义与原来一致)。
|
|
211
|
+
*
|
|
212
|
+
* ★ 0.5.87:新增可选 `spec` —— 用户**点名了版本**时,重试必须打同一个 `name@version` 规格。
|
|
213
|
+
* 旧实现只传 `name`:超时重试会退化成"装 latest",而镜像的 latest 可能正是**旧版** ⇒ 重试"成功"
|
|
214
|
+
* 却把人往旧版上带(真机 2026-10-18 同一根因的另一条腿)。不传 spec 时行为与改动前逐字相同。 */
|
|
215
|
+
async function retryPnpmOnce({ ch, profileDir, name, spec = null, registries, pnpmError, job, baseTimeoutMs = 90000 }) {
|
|
212
216
|
const diagnosis = classifyInstallFailure(pnpmError?.message)
|
|
213
217
|
job.diagnosis = diagnosis
|
|
214
218
|
if (diagnosis.retry !== 'longer-timeout') return { installedName: null, error: null, diagnosis }
|
|
215
219
|
const retryTimeoutMs = longerTimeoutFor(baseTimeoutMs)
|
|
220
|
+
const installSpec = typeof spec === 'string' && spec !== '' ? spec : name
|
|
216
221
|
try {
|
|
217
|
-
await ch.pnpmInstall(profileDir,
|
|
222
|
+
await ch.pnpmInstall(profileDir, installSpec, registries[0], retryTimeoutMs)
|
|
218
223
|
const ok = { ...diagnosis, retried: true, retryTimeoutMs }
|
|
219
224
|
job.diagnosis = ok
|
|
220
225
|
return { installedName: name, error: null, diagnosis: ok }
|
|
@@ -32,7 +32,9 @@ async function finalizeInstallPersistence({
|
|
|
32
32
|
// 与运行时已有行都不算,旧代码在这两条路上也不设 entryId)——撤销安装的归属判定依赖这一点。
|
|
33
33
|
job.entryId = persist.after.parts.mount.via === 'patch-row' ? (persist.rowId ?? null) : null
|
|
34
34
|
job.persisted = persist.persisted
|
|
35
|
-
|
|
35
|
+
// 0.5.84 判据降噪:把「这次不一致会不会丢插件」的结论一并下发(面板据此决定报红还是降级提示)。
|
|
36
|
+
// 纯加法:missing / actual / note 三个老字段一字未变,老客户端忽略 severity 即可。
|
|
37
|
+
job.persist = { missing: persist.missing, actual: persist.after.actual, note: persist.note, severity: persist.after?.severity ?? persist.severity ?? null }
|
|
36
38
|
if (persist.persisted !== true) job.persistNote = persist.note
|
|
37
39
|
const persistSuggested = persist.suggested ?? null
|
|
38
40
|
if (persistSuggested !== null
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
// L1 · domain —— install-job.js(安装任务编排:多通道安装 + 套装补丁 + 适配门 + 收尾)。
|
|
2
|
-
// 分层 Step 8c-2 从 lib/index.js 搬出;形参由 ctx 收窄为 ports(调用方注入的窄接口),只搬移未改逻辑。
|
|
3
|
-
// 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言)
|
|
4
|
-
|
|
5
2
|
import { dirname, join, resolve } from 'node:path'
|
|
6
3
|
import { aiRepair } from './ai-run.js'
|
|
7
4
|
import { maybeAutoAdaptCompat } from './compat.js'
|
|
8
5
|
import { getReposDir } from './repoland.js'
|
|
9
6
|
import { aiConsentFailureText, cleanupAttemptedCandidates, pnpmRemove } from './install-cleanup.js'
|
|
10
|
-
import { GIT_CHANNEL_BUDGET_MS, GIT_MIN_ATTEMPT_MS, GIT_SPEC_TIMEOUT_MS, gitChannelBudgetMs, noteChannel, formatBudgetMs
|
|
7
|
+
import { GIT_CHANNEL_BUDGET_MS, GIT_MIN_ATTEMPT_MS, GIT_SPEC_TIMEOUT_MS, gitChannelBudgetMs, noteChannel, formatBudgetMs } from './git-channel.js'
|
|
11
8
|
import { AI_CONSENT_TIMEOUT_MS, PNPM_INSTALL_TIMEOUT_MS, addBundleToManifest, backfillMissingDeps, curlManualInstall, detectBundleOnly, ensureBundlePatchIntegrity, githubReleaseInstall, pnpmInstall, raceInstallChannels, readGithubAuth, syncAggregateSubpackageVersions } from './install.js'
|
|
12
9
|
import { readBundlePatchRefNames, readExtraBundleRows } from './bundle-refs.js' // re-export(0.5.46 搬入;0.5.26 已搬 readBundlePatchRefNames)
|
|
13
|
-
import { classifyInstallFailure
|
|
10
|
+
import { classifyInstallFailure } from './install-diagnose.js'
|
|
14
11
|
import { jobLog } from './jobs.js'
|
|
15
12
|
import { gatePrivateRoot } from './repo-size.js'
|
|
16
|
-
|
|
13
|
+
// 0.5.87 搬出:单个候选包的通道序列(含版本闸)进了 domain/install-channels.js —— 导出名与调用点一字未变。
|
|
14
|
+
import { RELEASE_CHANNEL_BUDGET, tryCandidateChannels } from './install-channels.js'
|
|
15
|
+
// 0.5.87 新增:点了名的版本没装上时,用**同一份**文案如实说清(见 domain/install-version.js)。
|
|
16
|
+
import { versionMissOf, versionMissText } from './install-version.js'
|
|
17
17
|
import { settlePresetOutcome } from './preset-in-package.js'
|
|
18
|
-
import {
|
|
18
|
+
import { verifyInstalledEntry } from './install-verify.js'
|
|
19
19
|
import { applyNpmNameHint, composeProbeFailureText, expandSubpackageCandidates, fetchRepoPackage, fetchRepoPackageEx, fetchSubdirPlugins, fetchSubpackageNames, npmNameHintForRepo, packageProbeErrorText, subpackageCandidates } from './market.js'
|
|
20
20
|
import { decideNoRootManifest } from './subdir-detect.js'
|
|
21
21
|
import { runSubdirInstallJob } from './subdir-install.js'
|
|
@@ -28,15 +28,9 @@ import { orderedRegistries, readSources } from './sources.js'
|
|
|
28
28
|
import { reconcileLockfile } from './selfupdate.js'
|
|
29
29
|
import { fetchRepoMeta, runSuiteInstallJob, shouldRunSuiteInstall } from './suite.js'
|
|
30
30
|
import { execFileAsync } from '../infra/exec.js'
|
|
31
|
-
import {
|
|
31
|
+
import { startTrashCleanup, trashScanRoots } from '../infra/fsx.js'
|
|
32
32
|
import { findPatchPath, packageNameOf, resolvePackageJson } from '../infra/paths.js'
|
|
33
33
|
|
|
34
|
-
/** release 通道的候选预算(issue #3):release 通道现在按包名反查发布仓库,**每个候选**至少一次
|
|
35
|
-
* `releases?per_page=10` 调用(第一梯队仓库没命中还要多试几个候选仓库),而候选可能有十几个
|
|
36
|
-
* (聚合仓库懒惰展开后最多 19 个)——不封顶会把 8 分钟的作业时间预算吃光。
|
|
37
|
-
* 只给前 N 个候选扫 release;排在后面的候选仍照走 curl/并行竞速(按包名施工,代价小)。 */
|
|
38
|
-
const RELEASE_CHANNEL_BUDGET = 3
|
|
39
|
-
|
|
40
34
|
/** 安装通道实现集合(默认真实实现;`ctx.get('installChannels')` 可覆盖 —— 只走可选读取,不走属性访问)。
|
|
41
35
|
* 为什么留这个缝:通道守卫(哪些通道在懒惰展开之后仍应被尝试)正是 issue #3 的核心语义,
|
|
42
36
|
* 用真实通道无法离线断言"谁被调用了"——单测注入桩函数即可把语义钉死(见 test-suite-detect.mjs ⑯)。
|
|
@@ -54,166 +48,6 @@ function channelImpls(ports) {
|
|
|
54
48
|
return override !== null && typeof override === 'object' ? { ...real, ...override } : real
|
|
55
49
|
}
|
|
56
50
|
|
|
57
|
-
/** 单个候选包的通道尝试序列(通道实现由 ch 注入;job.curlNote 等展示字段在此更新)。
|
|
58
|
-
* 返回 { installedName, lastError }。三条守卫语义及理由(issue #3 要求逐条写清):
|
|
59
|
-
*
|
|
60
|
-
* ① 并行竞速(pnpm‖curl)与 curl 手动通道:**始终可试,不判 expanded**。
|
|
61
|
-
* 这两条都按 name 走 registry,与 job.repo、根包是否 private 毫无关系;懒惰展开只是让候选变多,
|
|
62
|
-
* 没有任何理由让展开后的候选失去这两条通道。旧代码的 `!expanded` 会让展开后的所有候选
|
|
63
|
-
* 直接跳到最后 → 子包只能靠 AI 兜底,正是 issue 报的"四分钟才装上"。
|
|
64
|
-
*
|
|
65
|
-
* ② GitHub release 通道:**不判 expanded,也不判 subpackageMode**(只受"是否反查到候选仓库"限制)。
|
|
66
|
-
* issue #3 之后它会按包名反查真实发布仓库——子包的产物常常发布在**另一个仓库**的 release 里
|
|
67
|
-
* (实测:dsh-routing-suite 的私根包 @dsh-external/dsh-super-injector,产物在 dsh-super-injector
|
|
68
|
-
* 仓库的 release 资产里)。按 job.repo 判断"该不该试 release"因此不再成立,代价用预算封顶。
|
|
69
|
-
*
|
|
70
|
-
* ③ git 通道:**保留 repoChannelAllowed**(它只 clone `job.repo`,候选是子包时 clone 根仓库装不出子包,
|
|
71
|
-
* 属无意义尝试)**并保留 !expanded**(同一作业里对同一个 job.repo 反复 clone 纯属浪费时间,
|
|
72
|
-
* 级联顺序也不该被破坏)。 */
|
|
73
|
-
async function tryCandidateChannels({ job, ch, name, profileDir, registries, repoChannelAllowed, budget, baseUrl = null, expanded = false, deadline = null, expand = null, sourceDeps = {} }) {
|
|
74
|
-
// 声明行的目标 profile(0.5.28):框架 0.1.7-rc.x 起预设不再靠目录发现,装配后必须往这个 profile 的补丁写 `preset-<id>` 行,否则用户看不见(见 preset-declare.js 顶部)。
|
|
75
|
-
const patchPath = join(profileDir, 'cordis.patch.yml')
|
|
76
|
-
let installedName = null
|
|
77
|
-
let lastError = null
|
|
78
|
-
// 0.5.41 接线:这次成功装上的**来源**(`{ spec, origin, kind }`)—— 取样通道(curl tarball / GitHub
|
|
79
|
-
// release 资产)知道真实下载地址,收口那一步据此走 `pnpm add <spec>` 真装;registry/git 通道都不填。
|
|
80
|
-
let source = null
|
|
81
|
-
// 加法优化:包已在 node_modules 且名字匹配时,不再重复安装/触发 EPERM,直接进入启用流程
|
|
82
|
-
// (2026-09-27 搬进 install-verify.js#alreadyInstalledTarget:本文件贴着 600 行架构硬顶)
|
|
83
|
-
const already = alreadyInstalledTarget(profileDir, name, job)
|
|
84
|
-
if (already !== null) {
|
|
85
|
-
job.curlNote = already
|
|
86
|
-
return { installedName: name, lastError: null }
|
|
87
|
-
}
|
|
88
|
-
// 通道 0:并行竞速(pnpm 与 curl 同时启动,先成功者生效;失败方 abort,不影响后续串行通道)——守卫①
|
|
89
|
-
{
|
|
90
|
-
try {
|
|
91
|
-
const raced = await ch.raceInstallChannels(profileDir, name, registries)
|
|
92
|
-
if (raced) {
|
|
93
|
-
installedName = name
|
|
94
|
-
if (raced.channel === 'curl') {
|
|
95
|
-
const racedInfo = raced.info
|
|
96
|
-
try {
|
|
97
|
-
const stillMissing = await ch.backfillMissingDeps(profileDir, racedInfo.missingDeps, registries)
|
|
98
|
-
job.curlNote = `已通过并行 curl 通道安装 v${racedInfo.version}${stillMissing.length > 0 ? `(依赖仍未补齐:${stillMissing.join('、')},网络恢复后建议重新安装)` : '(捆绑依赖已补齐)'}`
|
|
99
|
-
} catch (backfillError) {
|
|
100
|
-
// 包已装好:补齐依赖失败只影响提示,绝不能因此把这次安装判成失败
|
|
101
|
-
job.curlNote = `已通过并行 curl 通道安装 v${racedInfo.version}(捆绑依赖补齐失败:${backfillError instanceof Error ? backfillError.message : String(backfillError)};网络恢复后建议重新安装)`
|
|
102
|
-
noteChannel(job, `并行 curl 通道的依赖补齐失败(安装本身已成功):${backfillError instanceof Error ? backfillError.message : String(backfillError)}`)
|
|
103
|
-
}
|
|
104
|
-
if (racedInfo.boxNote) job.curlNote += `(盒子验证:${racedInfo.boxNote})`
|
|
105
|
-
job.integrity = racedInfo.integrity ?? null // ④ 下载物摘要校验结果(加法;见 install-verify.js)
|
|
106
|
-
// 0.5.41:curl tarball 是**取样通道**(物是解压铺进去的,不是包管理器装的)——
|
|
107
|
-
// 真实来源规格(registry tarball URL)带回收口。registry 可解析时收口仍写版本号(不倒退)。
|
|
108
|
-
source = { spec: racedInfo.sourceSpec ?? null, origin: 'sampled', kind: 'registry-tarball' }
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
} catch (raceError) {
|
|
112
|
-
lastError = raceError
|
|
113
|
-
noteChannel(job, `并行竞速通道异常(已继续尝试后续串行通道):${raceError instanceof Error ? raceError.message : String(raceError)}`)
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
// 通道 1..n:配置的软件源依次尝试(每源 90 秒封顶)
|
|
117
|
-
let pnpmError = null
|
|
118
|
-
for (let ri = 0; ri < registries.length && installedName === null; ri += 1) {
|
|
119
|
-
try {
|
|
120
|
-
await ch.pnpmInstall(profileDir, name, registries[ri])
|
|
121
|
-
installedName = name
|
|
122
|
-
break
|
|
123
|
-
} catch (error) { lastError = error; pnpmError = error }
|
|
124
|
-
}
|
|
125
|
-
if (installedName === null) {
|
|
126
|
-
// 通道 n+1:curl 手动安装(node 网络黑洞时 pnpm 下载卡死、curl 可用)——下载 registry tarball
|
|
127
|
-
// 解压到 node_modules,零依赖包可完整安装。守卫①:不判 expanded。
|
|
128
|
-
try {
|
|
129
|
-
const info = await ch.curlManualInstall(profileDir, name, registries)
|
|
130
|
-
installedName = name
|
|
131
|
-
const stillMissing = await ch.backfillMissingDeps(profileDir, info.missingDeps, registries)
|
|
132
|
-
job.curlNote = `已通过 curl 通道安装 v${info.version}${stillMissing.length > 0 ? `(依赖仍未补齐:${stillMissing.join('、')},网络恢复后建议重新安装)` : '(捆绑依赖已补齐)'}`
|
|
133
|
-
if (info.boxNote) job.curlNote += `(盒子验证:${info.boxNote})`
|
|
134
|
-
job.integrity = info.integrity ?? null // ④ 下载物摘要校验结果(加法;见 install-verify.js)
|
|
135
|
-
// 0.5.41:同上(取样通道 ⇒ 带真实 tarball URL 回去真装)
|
|
136
|
-
source = { spec: info.sourceSpec ?? null, origin: 'sampled', kind: 'registry-tarball' }
|
|
137
|
-
} catch (curlError) {
|
|
138
|
-
lastError = curlError
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
if (installedName === null) {
|
|
142
|
-
// 通道 n+1b:GitHub release 下载安装(npm 上不存在的包,例如只发 GitHub release 的社区插件)——
|
|
143
|
-
// 按包名反查发布仓库 → 遍历最近 ≤10 条 release 的 assets 按包名挑产物 → 盒子验证 → 覆盖。
|
|
144
|
-
// 守卫②:不判 expanded / subpackageMode(理由见本函数顶部注释);预算封顶避免吃光作业时间。
|
|
145
|
-
if (budget.release > 0) {
|
|
146
|
-
budget.release -= 1
|
|
147
|
-
try {
|
|
148
|
-
const info = await ch.githubReleaseInstall(profileDir, job.repo ?? null, name, { baseUrl })
|
|
149
|
-
installedName = name
|
|
150
|
-
const stillMissing = await ch.backfillMissingDeps(profileDir, info.missingDeps, registries)
|
|
151
|
-
// 如实记录来源:哪个仓库的哪条 release 的哪个资产(issue #3 明确要求,面板直接展示这句话)
|
|
152
|
-
const from = typeof info.sourceNote === 'string' && info.sourceNote !== '' ? `(来源:${info.sourceNote})` : ''
|
|
153
|
-
job.curlNote = `已通过 GitHub release 通道安装 v${info.version ?? '?'}${from}${stillMissing.length > 0 ? `(依赖仍未补齐:${stillMissing.join('、')},网络恢复后建议重新安装)` : '(捆绑依赖已补齐)'}`
|
|
154
|
-
if (info.boxNote) job.curlNote += `(盒子验证:${info.boxNote})`
|
|
155
|
-
// 0.5.41 接线(**本轮的核心**):GitHub release 资产(tarball URL)是取样通道 —— 把真实下载
|
|
156
|
-
// 地址带回给收口,由 `pnpm add <url>` 真装(依赖与 peer 交给包管理器,与官方 dsh plugin add 同效)。
|
|
157
|
-
source = { spec: info.sourceSpec ?? null, origin: 'sampled', kind: 'github-release' }
|
|
158
|
-
} catch (ghError) {
|
|
159
|
-
lastError = ghError
|
|
160
|
-
}
|
|
161
|
-
} else {
|
|
162
|
-
// 预算用尽(批次 A-⑤):原因**总是**记在 job 上供面板显示(旧代码只在 lastError === null 时写,用户看不出)
|
|
163
|
-
noteChannel(job, `release 通道因预算跳过(本作业只对前 ${RELEASE_CHANNEL_BUDGET} 个候选做按包名反查+release 扫描)`)
|
|
164
|
-
if (lastError === null) lastError = new Error(`release 通道候选预算已用尽(本作业只对前 ${RELEASE_CHANNEL_BUDGET} 个候选做按包名反查+release 扫描)`)
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
// 懒惰展开(批次 A-③,2026-09-27 搬家):**registry 类通道全部失败之后、git 通道之前**。
|
|
168
|
-
// 旧顺序是"所有通道(含 git)都失败 → 才展开子包":对"根包 private、未发布到 npm"的仓库
|
|
169
|
-
// (真机 zhu1090093659/dsh-web)就是 registry 404 → 直接去 clone 那个 429 MB 的巨仓
|
|
170
|
-
// (ghproxy 上 git 协议 0 B/s,白等且注定失败),而真正能装的聚合子包 @linxin666/dsh-web-all
|
|
171
|
-
// (5.97 MB)连一次机会都没有。expand() 返回新增候选数;展开失败只记备注,绝不短路。
|
|
172
|
-
if (installedName === null && expanded === false && typeof expand === 'function') {
|
|
173
|
-
try {
|
|
174
|
-
const added = await expand()
|
|
175
|
-
if (added > 0) noteChannel(job, `registry 通道未命中,已自动展开仓库子包:新增 ${added} 个候选`)
|
|
176
|
-
} catch (error) {
|
|
177
|
-
noteChannel(job, `自动展开仓库子包失败(继续走后续通道):${error instanceof Error ? error.message : String(error)}`)
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
if (installedName === null) {
|
|
181
|
-
// 通道 n+1c(批次 D-③,2026-09-27 加法):**预设型子包的源码装配** —— 真机 dsh-routing-suite 的第三件
|
|
182
|
-
// dsh-router-standard 只存在于仓库源码里(npm 双 404、release 无资产),按包名的通道全试完才轮到它。
|
|
183
|
-
const preset = await tryPresetSourceChannel({ job, name, source: job.source, deps: sourceDeps })
|
|
184
|
-
if (preset.installed === true) { job.presetDone = true; job.presetNote = preset.note; installedName = name } else if (preset.error !== null && preset.error !== undefined) lastError = preset.error
|
|
185
|
-
}
|
|
186
|
-
if (installedName === null) {
|
|
187
|
-
// 通道 n+2:git 通道(GitHub 走加速代理+直连;Gitee 走对应平台)——守卫③
|
|
188
|
-
// 2026-09-27:实现搬进 domain/git-channel.js(独立预算 + 不得超过作业剩余预算;
|
|
189
|
-
// install-job.js 有 600 行硬顶,见 test-architecture-guard.mjs),此处只保留调用与错误归并。
|
|
190
|
-
const git = await tryGitChannel({ job, ch, name, profileDir, repoChannelAllowed, expanded, budget, deadline })
|
|
191
|
-
installedName = git.installedName
|
|
192
|
-
if (git.lastError !== null) lastError = git.lastError
|
|
193
|
-
}
|
|
194
|
-
// ③ 失败分类 → 只对网络类超时用更长超时定向重试**一次**;其余分类只写 job.diagnosis 提示(见 install-diagnose.js)
|
|
195
|
-
if (installedName === null && pnpmError !== null) {
|
|
196
|
-
const retry = await retryPnpmOnce({ ch, profileDir, name, registries, pnpmError, job, baseTimeoutMs: PNPM_INSTALL_TIMEOUT_MS })
|
|
197
|
-
installedName = retry.installedName ?? installedName
|
|
198
|
-
if (retry.error != null) lastError = retry.error
|
|
199
|
-
}
|
|
200
|
-
if (installedName !== null) return { installedName, lastError, source }
|
|
201
|
-
// Windows 原子替换失败(陈旧目录 / _tmp_ 残留)是 EPERM 类错误的根因:
|
|
202
|
-
// 清理后用主源重试一次(把 AI 人工修复经验自动化,减少 AI 兜底触发)
|
|
203
|
-
if (/EPERM|EACCES|rename/i.test(String(lastError?.message ?? ''))) {
|
|
204
|
-
const cleaned = cleanupStalePackageDir(profileDir, name)
|
|
205
|
-
if (cleaned > 0) {
|
|
206
|
-
try {
|
|
207
|
-
await ch.pnpmInstall(profileDir, name, registries[0])
|
|
208
|
-
installedName = name
|
|
209
|
-
} catch (error) {
|
|
210
|
-
lastError = error
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
return { installedName, lastError }
|
|
215
|
-
}
|
|
216
|
-
|
|
217
51
|
async function runInstallJob(job, ports, deps = {}) {
|
|
218
52
|
// 测试注入缝(2026-09-27,沿用 channelImpls 风格):市场探测与两个时间预算可替换,只为离线断言
|
|
219
53
|
// 「候选顺序、展开时机、预算边界」;生产调用方不传第三个参数,默认值与旧行为一致。
|
|
@@ -449,6 +283,28 @@ async function runInstallJob(job, ports, deps = {}) {
|
|
|
449
283
|
// 预设收口(D-③ 源码装配成功 / D-⑥ 落地物里带预设):能按预设收口就直接 return —— 见 preset-in-package.js
|
|
450
284
|
// (这两条路都**没有 node_modules 插件**,写补丁行/声明依赖会让补丁行指向无入口模块 → 启动崩溃)。
|
|
451
285
|
if (await settlePresetOutcome({ job, installedName, profileDir, repo: job.repo, deps: sourceDeps }) === true) return
|
|
286
|
+
// ★ 0.5.87(真机 2026-10-18 的根因收口):**点名了版本却没装上**⇒ 如实失败,且**不清场**。
|
|
287
|
+
// 为什么不清场:下面的失败路径会调 cleanupAttemptedCandidates,它会 rmSync 掉
|
|
288
|
+
// `node_modules/<包名>` —— 而那一刻盘上放着的是用户**现在还能用**的旧版本(本例 0.1.6-alpha.1)。
|
|
289
|
+
// 把它删掉才是真正的损失;清单 / lock / 挂载行一律不动(什么都没写成,也就谈不上"改坏清单")。
|
|
290
|
+
// 为什么不进 AI 兜底:这不是"哪一步坏了",而是"目标那一版现在拿不到"(多为镜像 dist-tags.latest
|
|
291
|
+
// 陈旧 / 版本刚发布镜像还没同步)——等 10 分钟授权再让模型乱试,只会把旧版本也搅乱。
|
|
292
|
+
if (installedName === null && job.versionMiss != null) {
|
|
293
|
+
job.status = 'failed'
|
|
294
|
+
job.stage = 'version-check'
|
|
295
|
+
job.error = versionMissText(job.versionMiss)
|
|
296
|
+
job.diagnosis = {
|
|
297
|
+
kind: 'version-not-reached',
|
|
298
|
+
hint: '目标版本没有落到磁盘上:多半是镜像的 dist-tags.latest 仍指向旧版,或该版本尚未同步到镜像。'
|
|
299
|
+
+ '盘上现有的那一版已原样保留(未清理、未改清单);换一个 registry 或稍后再试即可。',
|
|
300
|
+
retry: 'later',
|
|
301
|
+
packages: [job.versionMiss.packageName],
|
|
302
|
+
}
|
|
303
|
+
job.lastError = job.error
|
|
304
|
+
// 盘上现在那一版(如实下发;面板不必再自己去读)
|
|
305
|
+
job.installedVersion = job.versionMiss.actual
|
|
306
|
+
return
|
|
307
|
+
}
|
|
452
308
|
if (installedName === null) {
|
|
453
309
|
if (job.diagnosis == null) job.diagnosis = classifyInstallFailure(lastError?.message) // 分类提示兜底(加法)
|
|
454
310
|
// 本地 AI 兜底会调用模型 API、产生费用:挂起等待用户明确同意后再执行
|
|
@@ -467,6 +323,20 @@ async function runInstallJob(job, ports, deps = {}) {
|
|
|
467
323
|
job.aiWait = null
|
|
468
324
|
if (decision.approved === true) {
|
|
469
325
|
await aiRepair(job, ports, profileDir, candidates, lastError?.message ?? null)
|
|
326
|
+
// ★ 0.5.87:AI 兜底也必须过版本闸 —— 它只校验"候选包能解析/进了 bundle 层"(见 ai-run.js 顶部
|
|
327
|
+
// 那段),**不认版本**。带版本的更新走完 AI 后如果盘上仍不是目标版本,就是同一种谎报:
|
|
328
|
+
// 改成如实失败(保留它在盘上装出来的那一版,不清场)。
|
|
329
|
+
if (job.status === 'done' && job.versionMiss == null) {
|
|
330
|
+
const miss = versionMissOf(profileDir, String(job.packageName ?? ''), job, 'ai-repair')
|
|
331
|
+
if (miss !== null) {
|
|
332
|
+
job.versionMiss = miss
|
|
333
|
+
job.status = 'failed'
|
|
334
|
+
job.stage = 'version-check'
|
|
335
|
+
job.error = versionMissText(miss)
|
|
336
|
+
job.lastError = job.error
|
|
337
|
+
job.installedVersion = miss.actual
|
|
338
|
+
}
|
|
339
|
+
}
|
|
470
340
|
} else {
|
|
471
341
|
job.status = 'failed'
|
|
472
342
|
// 失败/取消时清场并如实汇报(见 cleanupAttemptedCandidates 注释):
|
|
@@ -490,6 +360,10 @@ async function runInstallJob(job, ports, deps = {}) {
|
|
|
490
360
|
// ④ 装后校验(2026-09-26 加法,见 install-verify.js):只**提示**、绝不改成功判定 ——
|
|
491
361
|
// 入口文件缺失 / 补丁行指向别的包 / bundle 引用缺失 / 镜像未同步,都会写进 job.entryCheck 供面板显示
|
|
492
362
|
job.entryCheck = verifyInstalledEntry(profileDir, installedName)
|
|
363
|
+
// ★ 0.5.87:把**真正落到磁盘的版本**单独记在作业上(`job.version` 是请求点名的目标版本,两者
|
|
364
|
+
// 必须分开:真机 2026-10-18 就是"version=目标版本、盘上还是旧版"却报成功)。取值复用上面那份
|
|
365
|
+
// 只读校验结果,不再读第二遍盘。
|
|
366
|
+
job.installedVersion = job.entryCheck?.version ?? null
|
|
493
367
|
// 聚合包子包版本对齐(确保更新后所有子包也到新版声明版本)
|
|
494
368
|
let syncedNames = []
|
|
495
369
|
try {
|
|
@@ -596,4 +470,4 @@ async function runInstallJob(job, ports, deps = {}) {
|
|
|
596
470
|
* 这边腾出 600 行硬顶);这里保留同名 re-export —— **对外导出名与调用点一个字都没变**。 */
|
|
597
471
|
export { readExtraBundleRows }
|
|
598
472
|
|
|
599
|
-
export { runInstallJob, pnpmRemove, cleanupAttemptedCandidates, aiConsentFailureText, tryCandidateChannels, channelImpls, RELEASE_CHANNEL_BUDGET, GIT_CHANNEL_BUDGET_MS, GIT_SPEC_TIMEOUT_MS, GIT_MIN_ATTEMPT_MS, gitChannelBudgetMs, noteChannel, formatBudgetMs }
|
|
473
|
+
export { runInstallJob, pnpmRemove, cleanupAttemptedCandidates, aiConsentFailureText, tryCandidateChannels, channelImpls, RELEASE_CHANNEL_BUDGET, GIT_CHANNEL_BUDGET_MS, GIT_SPEC_TIMEOUT_MS, GIT_MIN_ATTEMPT_MS, gitChannelBudgetMs, noteChannel, formatBudgetMs }
|