@noob-stupid/dsh-plugin-console 0.5.85 → 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.
@@ -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-job.js 调用):
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
- async function retryPnpmOnce({ ch, profileDir, name, registries, pnpmError, job, baseTimeoutMs = 90000 }) {
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, name, registries[0], retryTimeoutMs)
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 }
@@ -4,15 +4,18 @@ import { aiRepair } from './ai-run.js'
4
4
  import { maybeAutoAdaptCompat } from './compat.js'
5
5
  import { getReposDir } from './repoland.js'
6
6
  import { aiConsentFailureText, cleanupAttemptedCandidates, pnpmRemove } from './install-cleanup.js'
7
- import { GIT_CHANNEL_BUDGET_MS, GIT_MIN_ATTEMPT_MS, GIT_SPEC_TIMEOUT_MS, gitChannelBudgetMs, noteChannel, formatBudgetMs, tryGitChannel } from './git-channel.js'
7
+ import { GIT_CHANNEL_BUDGET_MS, GIT_MIN_ATTEMPT_MS, GIT_SPEC_TIMEOUT_MS, gitChannelBudgetMs, noteChannel, formatBudgetMs } from './git-channel.js'
8
8
  import { AI_CONSENT_TIMEOUT_MS, PNPM_INSTALL_TIMEOUT_MS, addBundleToManifest, backfillMissingDeps, curlManualInstall, detectBundleOnly, ensureBundlePatchIntegrity, githubReleaseInstall, pnpmInstall, raceInstallChannels, readGithubAuth, syncAggregateSubpackageVersions } from './install.js'
9
9
  import { readBundlePatchRefNames, readExtraBundleRows } from './bundle-refs.js' // re-export(0.5.46 搬入;0.5.26 已搬 readBundlePatchRefNames)
10
- import { classifyInstallFailure, retryPnpmOnce } from './install-diagnose.js'
10
+ import { classifyInstallFailure } from './install-diagnose.js'
11
11
  import { jobLog } from './jobs.js'
12
12
  import { gatePrivateRoot } from './repo-size.js'
13
- import { tryPresetSourceChannel } from './preset-source.js'
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'
14
17
  import { settlePresetOutcome } from './preset-in-package.js'
15
- import { alreadyInstalledTarget, verifyInstalledEntry } from './install-verify.js'
18
+ import { verifyInstalledEntry } from './install-verify.js'
16
19
  import { applyNpmNameHint, composeProbeFailureText, expandSubpackageCandidates, fetchRepoPackage, fetchRepoPackageEx, fetchSubdirPlugins, fetchSubpackageNames, npmNameHintForRepo, packageProbeErrorText, subpackageCandidates } from './market.js'
17
20
  import { decideNoRootManifest } from './subdir-detect.js'
18
21
  import { runSubdirInstallJob } from './subdir-install.js'
@@ -25,15 +28,9 @@ import { orderedRegistries, readSources } from './sources.js'
25
28
  import { reconcileLockfile } from './selfupdate.js'
26
29
  import { fetchRepoMeta, runSuiteInstallJob, shouldRunSuiteInstall } from './suite.js'
27
30
  import { execFileAsync } from '../infra/exec.js'
28
- import { cleanupStalePackageDir, startTrashCleanup, trashScanRoots } from '../infra/fsx.js'
31
+ import { startTrashCleanup, trashScanRoots } from '../infra/fsx.js'
29
32
  import { findPatchPath, packageNameOf, resolvePackageJson } from '../infra/paths.js'
30
33
 
31
- /** release 通道的候选预算(issue #3):release 通道现在按包名反查发布仓库,**每个候选**至少一次
32
- * `releases?per_page=10` 调用(第一梯队仓库没命中还要多试几个候选仓库),而候选可能有十几个
33
- * (聚合仓库懒惰展开后最多 19 个)——不封顶会把 8 分钟的作业时间预算吃光。
34
- * 只给前 N 个候选扫 release;排在后面的候选仍照走 curl/并行竞速(按包名施工,代价小)。 */
35
- const RELEASE_CHANNEL_BUDGET = 3
36
-
37
34
  /** 安装通道实现集合(默认真实实现;`ctx.get('installChannels')` 可覆盖 —— 只走可选读取,不走属性访问)。
38
35
  * 为什么留这个缝:通道守卫(哪些通道在懒惰展开之后仍应被尝试)正是 issue #3 的核心语义,
39
36
  * 用真实通道无法离线断言"谁被调用了"——单测注入桩函数即可把语义钉死(见 test-suite-detect.mjs ⑯)。
@@ -51,169 +48,6 @@ function channelImpls(ports) {
51
48
  return override !== null && typeof override === 'object' ? { ...real, ...override } : real
52
49
  }
53
50
 
54
- /** 单个候选包的通道尝试序列(通道实现由 ch 注入;job.curlNote 等展示字段在此更新)。
55
- * 返回 { installedName, lastError }。三条守卫语义及理由(issue #3 要求逐条写清):
56
- *
57
- * ① 并行竞速(pnpm‖curl)与 curl 手动通道:**始终可试,不判 expanded**。
58
- * 这两条都按 name 走 registry,与 job.repo、根包是否 private 毫无关系;懒惰展开只是让候选变多,
59
- * 没有任何理由让展开后的候选失去这两条通道。旧代码的 `!expanded` 会让展开后的所有候选
60
- * 直接跳到最后 → 子包只能靠 AI 兜底,正是 issue 报的"四分钟才装上"。
61
- *
62
- * ② GitHub release 通道:**不判 expanded,也不判 subpackageMode**(只受"是否反查到候选仓库"限制)。
63
- * issue #3 之后它会按包名反查真实发布仓库——子包的产物常常发布在**另一个仓库**的 release 里
64
- * (实测:dsh-routing-suite 的私根包 @dsh-external/dsh-super-injector,产物在 dsh-super-injector
65
- * 仓库的 release 资产里)。按 job.repo 判断"该不该试 release"因此不再成立,代价用预算封顶。
66
- *
67
- * ③ git 通道:**保留 repoChannelAllowed**(它只 clone `job.repo`,候选是子包时 clone 根仓库装不出子包,
68
- * 属无意义尝试)**并保留 !expanded**(同一作业里对同一个 job.repo 反复 clone 纯属浪费时间,
69
- * 级联顺序也不该被破坏)。 */
70
- async function tryCandidateChannels({ job, ch, name, profileDir, registries, repoChannelAllowed, budget, baseUrl = null, expanded = false, deadline = null, expand = null, sourceDeps = {} }) {
71
- const installSpec = (typeof job?.version === 'string' && job.version !== '' && name === job.packageName)
72
- ? `${name}@${job.version}`
73
- : name // ★ 0.5.81:规格与身份分离 —— name 仍是身份;点名包+指定版本时规格才带 @version
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, installSpec, 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, installSpec, 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 }
@@ -31,6 +31,9 @@ function pickJobFields(job) {
31
31
  status: typeof job.status === 'string' ? job.status : null,
32
32
  stage: typeof job.stage === 'string' ? job.stage : null,
33
33
  version: typeof job.version === 'string' ? job.version : null,
34
+ // ★ 0.5.87:目标版本(上一行)与**真正落盘的版本**是两件事 —— 真机 2026-10-18 就是
35
+ // "version=0.2.1-rc.2、盘上还是 0.1.6-alpha.1"却记成 done,事后没人能从这条记录里看出没落地。
36
+ installedVersion: typeof job.installedVersion === 'string' ? job.installedVersion : null,
34
37
  error: typeof job.error === 'string' ? job.error.slice(0, 500) : null,
35
38
  source: typeof job.source === 'string' ? job.source : null,
36
39
  startedAt: Number.isFinite(Number(job.startedAt)) ? Number(job.startedAt) : null,
@@ -0,0 +1,65 @@
1
+ // L1 · domain —— install-version.js(「更新到的到底是哪一版」的**唯一判据**;2026-10-18 新增,纯加法)
2
+ //
3
+ // 真机缺陷(2026-10-18,用户实测,父任务已确认现场):
4
+ // 点「更新并适配」`@deepseek-ai/dsh-experimental-auto-review`(0.1.6-alpha.1 → 0.2.1-rc.2),
5
+ // 请求体显式带 `version: "0.2.1-rc.2"`,作业跑完**报 done、version=0.2.1-rc.2、error=null**,
6
+ // 而磁盘与清单**都还是 0.1.6-alpha.1**。
7
+ // 根因不是"版本号没传下去",而是**没人核对最终落地的是不是目标版本**:
8
+ // 该包在 registry 上的 `dist-tags.latest` 恰好就是旧版 0.1.6-alpha.1(rc 版从不打 latest),
9
+ // 于是"按最新版下载"的通道(curl)把**旧版**铺回 node_modules,凡是"按名报成功"的判据都看不出异常。
10
+ //
11
+ // 本模块只做一件此前没人做的事:**把请求里点名的版本与磁盘上真实的版本比一比**。
12
+ // 为什么不塞进 install-verify.js:那个模块的契约是"只提示、绝不改成功判定"(见它的顶部硬规矩①),
13
+ // 而这里的结论**必须能否决一次成功**——两者语义相反,放一个文件里迟早会被下一个人混用。
14
+ //
15
+ // 三条硬规矩:
16
+ // ① 只读:只读 node_modules/<包名>/package.json 的 version 字段,绝不写盘、绝不改动清单;
17
+ // ② 只对"点名了版本"的请求生效(`job.version` 为空 = 照旧按 latest,判据恒 null,行为一字不变);
18
+ // ③ 绝不猜:读不到版本时 actual 记 null,**不**据此报"不是目标版本"以外的任何结论(见 versionMissText)。
19
+ import { readInstalledVersion } from './selfupdate.js'
20
+
21
+ /** 这次作业**点名**要装的那个版本(没有点名 / 不是点名的那个包 ⇒ null)。
22
+ *
23
+ * 为什么必须逐字比包名:`job.version` 是"目标包的目标版本",而候选循环里的 `name` 可能是自动展开出的
24
+ * 任意子包(聚合仓库)。给子包套用父候选的版本号=给一个根本没发布这个版本号的包下精确规格,
25
+ * 会把"本来能装"变成"404 装不上"(与 domain/install-channels.js 顶部 ① 条同一条理由)。
26
+ * 版本号只对**用户点名的那一件**成立 —— 传进来的 name 必须与 job.packageName 逐字相等。 */
27
+ function targetVersionOf(job, name) {
28
+ const version = typeof job?.version === 'string' ? job.version.trim() : ''
29
+ if (version === '' || typeof name !== 'string' || name === '') return null
30
+ return name === job.packageName ? version : null
31
+ }
32
+
33
+ /**
34
+ * 磁盘上真实的版本 vs 目标版本(**只读**,永不抛)。
35
+ * 返回 null 表示"无需/无法判定":
36
+ * · 没点名版本(按 latest 装的请求)—— 既有流程一字不动;
37
+ * · 磁盘上就是目标版本 —— 通道这次真的把版本装对了。
38
+ * 返回 `{ kind:'version-miss', packageName, expected, actual, channel }` 表示**没装上目标版本**。
39
+ * `channel` 由调用方填(哪个通道声称装上了),只用于如实告知,不参与判定。
40
+ */
41
+ function versionMissOf(profileDir, name, job, channel = null) {
42
+ const expected = targetVersionOf(job, name)
43
+ if (expected === null) return null
44
+ const actual = readInstalledVersion(profileDir, name)
45
+ if (actual === expected) return null
46
+ return {
47
+ kind: 'version-miss',
48
+ packageName: name,
49
+ expected,
50
+ actual: typeof actual === 'string' && actual !== '' ? actual : null,
51
+ channel: typeof channel === 'string' && channel !== '' ? channel : null,
52
+ }
53
+ }
54
+
55
+ /** 一句话如实说清"哪一版没装上、盘上现在是什么"(作业 error / 通道备注共用一份文案)。 */
56
+ function versionMissText(miss) {
57
+ if (miss === null || typeof miss !== 'object') return null
58
+ const actual = miss.actual === null || miss.actual === undefined ? '(磁盘上读不到版本)' : String(miss.actual)
59
+ const via = miss.channel === null || miss.channel === undefined ? '' : `${miss.channel} 通道`
60
+ return `目标版本 ${miss.expected} 没有装上:${via === '' ? '本次' : via}落地的是 ${actual}`
61
+ + '(多半是镜像的 dist-tags.latest 还指着旧版,按"最新版"下载就会拿到它)。'
62
+ + '已保留磁盘上现有的这一版、清单未改动;可换源后重试,或稍等镜像同步。'
63
+ }
64
+
65
+ export { targetVersionOf, versionMissOf, versionMissText }