@noob-stupid/dsh-plugin-console 0.5.17 → 0.5.19

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.
@@ -2,80 +2,30 @@
2
2
  // 分层 Step 8c-2 从 lib/index.js 搬出;形参由 ctx 收窄为 ports(调用方注入的窄接口),只搬移未改逻辑。
3
3
  // 分组见 D:\dsh\dsh-plugin-hub-plan\architecture.zh.md 三
4
4
 
5
- import { readFileSync, existsSync, readdirSync } from 'node:fs'
5
+ import { readFileSync, existsSync } from 'node:fs'
6
6
  import { readFile } from 'node:fs/promises'
7
- import { basename, dirname, join, resolve } from 'node:path'
7
+ import { dirname, join, resolve } from 'node:path'
8
8
  import { aiRepair } from './ai-run.js'
9
9
  import { maybeAutoAdaptCompat } from './compat.js'
10
10
  import { getReposDir } from './repoland.js'
11
+ import { aiConsentFailureText, cleanupAttemptedCandidates } from './install-cleanup.js'
12
+ import { GIT_CHANNEL_BUDGET_MS, GIT_MIN_ATTEMPT_MS, GIT_SPEC_TIMEOUT_MS, gitChannelBudgetMs, noteChannel, formatBudgetMs, tryGitChannel } from './git-channel.js'
11
13
  import { DEFAULT_BUNDLES, AI_CONSENT_TIMEOUT_MS, PNPM_INSTALL_TIMEOUT_MS, addBundleToManifest, backfillMissingDeps, curlManualInstall, detectBundleOnly, ensureBundlePatchIntegrity, githubReleaseInstall, pnpmInstall, raceInstallChannels, readBundlePatchRefNames, readGithubAuth, syncAggregateSubpackageVersions } from './install.js'
12
14
  import { classifyInstallFailure, retryPnpmOnce } from './install-diagnose.js'
13
15
  import { jobLog } from './jobs.js'
14
16
  import { verifyInstalledEntry } from './install-verify.js'
15
- import { fetchRepoPackage, fetchRepoPackageEx, fetchSubpackageNames, packageProbeErrorText, subpackageCandidates } from './market.js'
17
+ import { expandSubpackageCandidates, fetchRepoPackage, fetchRepoPackageEx, fetchSubpackageNames, npmNameHintForRepo, packageProbeErrorText, subpackageCandidates } from './market.js'
16
18
  import { declareProfileDependency } from './manifest.js'
17
19
  import { appendInsert, readPatchState, syncNameFromNote } from './patch.js'
18
20
  import { deriveEntryId, listEntries } from './runtime.js'
19
21
  import { detectSkillRepo, runSkillInstallJob } from './skills.js'
20
- import { gitCloneUrls, orderedRegistries, readSources } from './sources.js'
22
+ import { orderedRegistries, readSources } from './sources.js'
21
23
  import { reconcileLockfile } from './selfupdate.js'
22
- import { probeGitmodules, resolveInstallKind, runSuiteInstallJob } from './suite.js'
24
+ import { fetchRepoMeta, runSuiteInstallJob, shouldRunSuiteInstall } from './suite.js'
23
25
  import { buildPnpmEnv, execFileAsync, runPnpmWithFallback } from '../infra/exec.js'
24
- import { cleanupStalePackageDir, disposeDir, disposeNote, startTrashCleanup, trashScanRoots } from '../infra/fsx.js'
25
- import { GITHUB_API, META_BUDGET_MS, curlJson, githubJson } from '../infra/http.js'
26
+ import { cleanupStalePackageDir, startTrashCleanup, trashScanRoots } from '../infra/fsx.js'
26
27
  import { findPatchPath, packageNameOf, resolvePackageJson } from '../infra/paths.js'
27
28
 
28
- /** 安装失败清场:把本次尝试过的候选包目录与 pnpm `_tmp_` 半成品一起清掉,并**如实汇报**清了什么、什么没清掉
29
- * (2026-09-20 真装演练:聚合仓库跑 19 分钟后失败,node_modules 留着 `…_tmp_56272_2` 半成品,面板只说"安装失败")。
30
- * 2026-09-26(本次改错):删除一律走 disposeDir(本机 C 盘/`%TEMP%` 下 rmSync 会静默落空,不核实就会谎报干净);
31
- * 删不掉时它**改名降级成同父目录的 `.trash-<ts>`**(做法借自 2BingLing/dsh-market 的 `.bak-<ts>` + renameSync)。 */
32
- function cleanupAttemptedCandidates(profileDir, candidates) {
33
- const cleaned = []
34
- const failed = []
35
- const trashed = []
36
- const dispose = (label, dir) => {
37
- const result = disposeDir(dir)
38
- if (result.trashed === true) trashed.push({ name: label, path: dir, trashPath: result.trashPath, note: disposeNote(result) })
39
- if (result.ok === true) return 1
40
- failed.push({ name: label, path: dir, error: result.reason, note: disposeNote(result) })
41
- return 0
42
- }
43
- for (const name of candidates) {
44
- const dir = join(profileDir, 'node_modules', ...String(name).split('/'))
45
- const parent = dirname(dir)
46
- const base = basename(dir)
47
- let touched = 0
48
- if (existsSync(dir)) touched += dispose(name, dir)
49
- try {
50
- for (const entry of readdirSync(parent)) {
51
- if (!entry.startsWith(`${base}_tmp_`)) continue
52
- touched += dispose(`${name}(临时目录)`, join(parent, entry))
53
- }
54
- } catch {}
55
- if (touched > 0) cleaned.push(name)
56
- }
57
- return { cleaned, failed, trashed }
58
- }
59
-
60
- /** 授权被拒/超时后的失败文案(纯函数,单测覆盖):说清为什么失败、清理了什么、什么没清掉。
61
- * 时长取自 AI_CONSENT_TIMEOUT_MS —— 文案里的"10 分钟"不能与实际等待时间脱节。
62
- * 2026-09-26(本次改错):**不再出现「请手动删除」**——降级成 `.trash-*` 的项由后台清理接手。 */
63
- function aiConsentFailureText(decision, leftovers) {
64
- const base = decision?.timeout === true
65
- ? `等待授权超时(${Math.round(AI_CONSENT_TIMEOUT_MS / 60000)} 分钟),已取消本地 AI 兜底(该操作会调用模型 API 产生费用)`
66
- : '用户取消本地 AI 兜底(该操作会调用模型 API 产生费用)'
67
- const cleaned = leftovers?.cleaned ?? []
68
- const failed = leftovers?.failed ?? []
69
- const trashed = leftovers?.trashed ?? []
70
- const noteOf = (item) => (typeof item?.note === 'string' && item.note !== '' ? `(${item.note})` : (item?.error != null ? `(${item.error})` : ''))
71
- const cleanedNote = cleaned.length > 0 ? `;已清理本次落盘残留:${cleaned.join('、')}` : ''
72
- const trashedNote = trashed.length > 0
73
- ? `;有 ${trashed.length} 项正被占用、已改名降级为 .trash-*(${trashed.map((t) => (typeof t?.note === 'string' && t.note !== '' ? t.note : basename(String(t?.trashPath ?? '')))).join('、')})`
74
- : ''
75
- const failedNote = failed.length > 0 ? `;**有 ${failed.length} 项没能清理**:${failed.map((f) => `${f.path}${noteOf(f)}`).join('、')}` : ''
76
- return `${base}${cleanedNote}${trashedNote}${failedNote}`
77
- }
78
-
79
29
  /** release 通道的候选预算(issue #3):release 通道现在按包名反查发布仓库,**每个候选**至少一次
80
30
  * `releases?per_page=10` 调用(第一梯队仓库没命中还要多试几个候选仓库),而候选可能有十几个
81
31
  * (聚合仓库懒惰展开后最多 19 个)——不封顶会把 8 分钟的作业时间预算吃光。
@@ -89,7 +39,6 @@ const RELEASE_CHANNEL_BUDGET = 3
89
39
  * 否则替身与 cordis 语义不一致,这类缺陷还会再溜过去一次。 */
90
40
  function channelImpls(ports) {
91
41
  const real = { pnpmInstall, curlManualInstall, raceInstallChannels, githubReleaseInstall, backfillMissingDeps }
92
- // 测试注入缝(2026-09-22 事故的修法,**别改回属性访问**):
93
42
  // 生产路径上 ports 就是 cordis 的 ctx 代理,属性式读取未 inject 的名字会**同步抛**
94
43
  // `cannot get property "installChannels" without inject` —— 0.3.59 因此每一次安装都在这里失败。
95
44
  // 正规写法是 ctx.get('installChannels')(Cordis 的可选读取,未声明也不抛,与本文件其它
@@ -118,7 +67,7 @@ function channelImpls(ports) {
118
67
  * ③ git 通道:**保留 repoChannelAllowed**(它只 clone `job.repo`,候选是子包时 clone 根仓库装不出子包,
119
68
  * 属无意义尝试)**并保留 !expanded**(同一作业里对同一个 job.repo 反复 clone 纯属浪费时间,
120
69
  * 级联顺序也不该被破坏)。 */
121
- async function tryCandidateChannels({ job, ch, name, profileDir, registries, repoChannelAllowed, budget, baseUrl = null, expanded = false }) {
70
+ async function tryCandidateChannels({ job, ch, name, profileDir, registries, repoChannelAllowed, budget, baseUrl = null, expanded = false, deadline = null, expand = null }) {
122
71
  let installedName = null
123
72
  let lastError = null
124
73
  // 加法优化:包已在 node_modules 且名字匹配时,不再重复安装/触发 EPERM,直接进入启用流程
@@ -134,16 +83,27 @@ async function tryCandidateChannels({ job, ch, name, profileDir, registries, rep
134
83
  }
135
84
  // 通道 0:并行竞速(pnpm 与 curl 同时启动,先成功者生效;失败方 abort,不影响后续串行通道)——守卫①
136
85
  {
137
- const raced = await ch.raceInstallChannels(profileDir, name, registries)
138
- if (raced) {
139
- installedName = name
140
- if (raced.channel === 'curl') {
141
- const racedInfo = raced.info
142
- const stillMissing = await ch.backfillMissingDeps(profileDir, racedInfo.missingDeps, registries)
143
- job.curlNote = `已通过并行 curl 通道安装 v${racedInfo.version}${stillMissing.length > 0 ? `(依赖仍未补齐:${stillMissing.join('、')},网络恢复后建议重新安装)` : '(捆绑依赖已补齐)'}`
144
- if (racedInfo.boxNote) job.curlNote += `(盒子验证:${racedInfo.boxNote})`
145
- job.integrity = racedInfo.integrity ?? null // ④ 下载物摘要校验结果(加法;见 install-verify.js)
86
+ try {
87
+ const raced = await ch.raceInstallChannels(profileDir, name, registries)
88
+ if (raced) {
89
+ installedName = name
90
+ if (raced.channel === 'curl') {
91
+ const racedInfo = raced.info
92
+ try {
93
+ const stillMissing = await ch.backfillMissingDeps(profileDir, racedInfo.missingDeps, registries)
94
+ job.curlNote = `已通过并行 curl 通道安装 v${racedInfo.version}${stillMissing.length > 0 ? `(依赖仍未补齐:${stillMissing.join('、')},网络恢复后建议重新安装)` : '(捆绑依赖已补齐)'}`
95
+ } catch (backfillError) {
96
+ // 包已装好:补齐依赖失败只影响提示,绝不能因此把这次安装判成失败
97
+ job.curlNote = `已通过并行 curl 通道安装 v${racedInfo.version}(捆绑依赖补齐失败:${backfillError instanceof Error ? backfillError.message : String(backfillError)};网络恢复后建议重新安装)`
98
+ noteChannel(job, `并行 curl 通道的依赖补齐失败(安装本身已成功):${backfillError instanceof Error ? backfillError.message : String(backfillError)}`)
99
+ }
100
+ if (racedInfo.boxNote) job.curlNote += `(盒子验证:${racedInfo.boxNote})`
101
+ job.integrity = racedInfo.integrity ?? null // ④ 下载物摘要校验结果(加法;见 install-verify.js)
102
+ }
146
103
  }
104
+ } catch (raceError) {
105
+ lastError = raceError
106
+ noteChannel(job, `并行竞速通道异常(已继续尝试后续串行通道):${raceError instanceof Error ? raceError.message : String(raceError)}`)
147
107
  }
148
108
  }
149
109
  // 通道 1..n:配置的软件源依次尝试(每源 90 秒封顶)
@@ -186,31 +146,33 @@ async function tryCandidateChannels({ job, ch, name, profileDir, registries, rep
186
146
  } catch (ghError) {
187
147
  lastError = ghError
188
148
  }
189
- } else if (lastError === null) {
190
- // 预算用尽:不覆盖真实错误(面板/AI 兜底要看的是 curl·pnpm 的失败原因),只在无更具体错误时说明
191
- lastError = new Error(`release 通道候选预算已用尽(本作业只对前 ${RELEASE_CHANNEL_BUDGET} 个候选做按包名反查+release 扫描)`)
149
+ } else {
150
+ // 预算用尽(批次 A-⑤):原因**总是**记在 job 上供面板显示(旧代码只在 lastError === null 时写,用户看不出)
151
+ noteChannel(job, `release 通道因预算跳过(本作业只对前 ${RELEASE_CHANNEL_BUDGET} 个候选做按包名反查+release 扫描)`)
152
+ if (lastError === null) lastError = new Error(`release 通道候选预算已用尽(本作业只对前 ${RELEASE_CHANNEL_BUDGET} 个候选做按包名反查+release 扫描)`)
192
153
  }
193
154
  }
194
- if (installedName === null) {
195
- // 通道 n+2:git 通道(GitHub 走加速代理+直连;Gitee 走对应平台;各 60 秒封顶)——守卫③
196
- if (repoChannelAllowed && !expanded) {
197
- const gitSpecs = job.source === 'gitee'
198
- ? [`git+https://gitee.com/${job.repo}.git`]
199
- : [
200
- ...gitCloneUrls(job.repo).map((u) => `git+${u}`),
201
- `github:${job.repo}`,
202
- ]
203
- for (const spec of gitSpecs) {
204
- try {
205
- await ch.pnpmInstall(profileDir, spec, undefined, 60000)
206
- installedName = name
207
- break
208
- } catch (gitError) {
209
- lastError = gitError
210
- }
211
- }
155
+ // 懒惰展开(批次 A-③,2026-09-27 搬家):**registry 类通道全部失败之后、git 通道之前**。
156
+ // 旧顺序是"所有通道(含 git)都失败 → 才展开子包":对"根包 private、未发布到 npm"的仓库
157
+ // (真机 zhu1090093659/dsh-web)就是 registry 404 → 直接去 clone 那个 429 MB 的巨仓
158
+ // (ghproxy 上 git 协议 0 B/s,白等且注定失败),而真正能装的聚合子包 @linxin666/dsh-web-all
159
+ // (5.97 MB)连一次机会都没有。expand() 返回新增候选数;展开失败只记备注,绝不短路。
160
+ if (installedName === null && expanded === false && typeof expand === 'function') {
161
+ try {
162
+ const added = await expand()
163
+ if (added > 0) noteChannel(job, `registry 通道未命中,已自动展开仓库子包:新增 ${added} 个候选`)
164
+ } catch (error) {
165
+ noteChannel(job, `自动展开仓库子包失败(继续走后续通道):${error instanceof Error ? error.message : String(error)}`)
212
166
  }
213
167
  }
168
+ if (installedName === null) {
169
+ // 通道 n+2:git 通道(GitHub 走加速代理+直连;Gitee 走对应平台)——守卫③
170
+ // 2026-09-27:实现搬进 domain/git-channel.js(独立预算 + 不得超过作业剩余预算;
171
+ // install-job.js 有 600 行硬顶,见 test-architecture-guard.mjs),此处只保留调用与错误归并。
172
+ const git = await tryGitChannel({ job, ch, name, profileDir, repoChannelAllowed, expanded, budget, deadline })
173
+ installedName = git.installedName
174
+ if (git.lastError !== null) lastError = git.lastError
175
+ }
214
176
  // ③ 失败分类 → 只对网络类超时用更长超时定向重试**一次**;其余分类只写 job.diagnosis 提示(见 install-diagnose.js)
215
177
  if (installedName === null && pnpmError !== null) {
216
178
  const retry = await retryPnpmOnce({ ch, profileDir, name, registries, pnpmError, job, baseTimeoutMs: PNPM_INSTALL_TIMEOUT_MS })
@@ -234,12 +196,28 @@ async function tryCandidateChannels({ job, ch, name, profileDir, registries, rep
234
196
  return { installedName, lastError }
235
197
  }
236
198
 
237
- async function runInstallJob(job, ports) {
199
+ async function runInstallJob(job, ports, deps = {}) {
200
+ // 测试注入缝(2026-09-27,沿用 channelImpls 风格):市场探测与两个时间预算可替换,只为离线断言
201
+ // 「候选顺序、展开时机、预算边界」;生产调用方不传第三个参数,默认值与旧行为一致。
202
+ const probes = {
203
+ fetchRepoPackage,
204
+ fetchRepoPackageEx,
205
+ fetchSubpackageNames,
206
+ subpackageCandidates,
207
+ npmNameHint: npmNameHintForRepo,
208
+ ...(deps.marketProbes ?? {}),
209
+ }
210
+ // 展开实现必须跟随**注入的** fetchSubpackageNames(否则注入的探测桩会被绕过、悄悄去打真网络 ——
211
+ // 2026-09-27 全量测试实测到的一次 30 秒空等就是这么来的)
212
+ if (deps.marketProbes?.expandSubpackages === undefined) {
213
+ probes.expandSubpackages = (opts = {}) => expandSubpackageCandidates({ ...opts, deps: { fetchSubpackageNames: probes.fetchSubpackageNames } })
214
+ }
215
+ const jobBudgetMs = Number.isFinite(deps.jobBudgetMs) && deps.jobBudgetMs > 0 ? deps.jobBudgetMs : 8 * 60 * 1000
216
+ const aiConsentTimeoutMs = Number.isFinite(deps.aiConsentTimeoutMs) && deps.aiConsentTimeoutMs > 0 ? deps.aiConsentTimeoutMs : AI_CONSENT_TIMEOUT_MS
238
217
  try {
239
218
  job.stage = 'preparing'
240
- // 2026-09-06 事故(室友机器):对 deepseek-ai/deepseek-harness(框架本体仓库)点「添加到本地/安装」
241
- // 会按 bundle 规则注册其 patch,其中 deepseek-ai-dsh-root 等框架级行的包只存在于 npx 缓存/框架树,
242
- // profile node_modules 不存在 → 整服务启动崩溃。直接拦截。
219
+ // 2026-09-06 事故(室友机器):对 deepseek-ai/deepseek-harness(框架本体仓库)点安装会按 bundle 规则
220
+ // 注册其 patch,其中框架级行的包只存在于 npx 缓存/框架树 → profile 里不存在 → 整服务启动崩溃。直接拦截。
243
221
  const repoNorm = String(job.repo ?? '').toLowerCase().replace(/^git\+/u, '').replace(/\.git$/u, '')
244
222
  if (repoNorm === 'deepseek-ai/deepseek-harness') {
245
223
  job.status = 'failed'
@@ -248,12 +226,26 @@ async function runInstallJob(job, ports) {
248
226
  }
249
227
  let candidates = [job.packageName].filter((name) => typeof name === 'string' && name !== '')
250
228
  let subpackageMode = false
229
+ // ★ 市场索引的首选候选(批次 C-⑪):索引带 npmName 时直接按包名走 registry,连"读根 package.json →
230
+ // 展开子包"这一轮都省掉(本机 api.github.com 不可达时那条路根本走不通)。老索引无该字段 → 行为不变。
231
+ if (candidates.length === 0 && job.repo) {
232
+ const npmHint = probes.npmNameHint(job.repo)
233
+ if (npmHint !== null) {
234
+ candidates = [npmHint]
235
+ job.npmNameHint = npmHint
236
+ job.packageName = npmHint
237
+ // git 通道只 clone 整个仓库(真机 dsh-web 429 MB / ghproxy 0 B/s),而且装不出这个子包 → 禁掉
238
+ job.gitChannelBlocked = true
239
+ noteChannel(job, `市场索引给出首选候选:${npmHint}(按包名直装,省掉"探测根包 + 展开子包")`)
240
+ noteChannel(job, '已跳过 git 克隆通道:git 只 clone 整个仓库、装不出这个包(真机 dsh-web = 429 MB)')
241
+ }
242
+ }
251
243
  if (candidates.length === 0) {
252
- // 套装兜底:submodule 聚合仓库(根 .gitmodules 内容校验通过)→ 自动转套装安装,
253
- // 不依赖前端标记(搜索结果 enrich 是异步的、索引浏览条目无 enrich)。
254
- // 判据是**内容**(resolveInstallKind),不是"探测非 null":后者会把代理/CDN 对不存在文件回的
255
- // 2xx 空 body、垃圾页当成套装(2026-09-19 用户反馈的「未找到 .gitmodules」事故根因)。
256
- if (resolveInstallKind(job.kind, await probeGitmodules(job.repo)) === 'suite') {
244
+ // 套装判定(0.5.19 改错,唯一入口在 suite.js 的 shouldRunSuiteInstall):**先看根包是否已发布**,
245
+ // 再按 .gitmodules 的**内容**判是否套装(不是"探测非 null":代理/CDN 对不存在的文件会回 2xx 空 body、
246
+ // 垃圾页,2026-09-19「未找到 .gitmodules」事故的口径不变)。根包/子包任一已发布到 registry 时优先
247
+ // 插件通道 —— 真机 dsh-web:429 MB 套装克隆 vs 5.97 MB 的子包 @linxin666/dsh-web-all。
248
+ if (await shouldRunSuiteInstall(job, probes) === true) {
257
249
  job.kind = 'suite'
258
250
  const suiteResult = await runSuiteInstallJob(job, ports)
259
251
  // 套装装配出的普通插件是 copyTree 铺进去的(不在 lock 里)→ 同样对账,避免之后被 pnpm 还原/清理
@@ -271,20 +263,15 @@ async function runInstallJob(job, ports) {
271
263
  // clone 后才发现没有 .gitmodules(探测假阳性 / 仓库已重构)→ 回落普通安装,不给用户一个失败
272
264
  if (suiteResult?.notASuite !== true) return
273
265
  job.kind = 'plugin'
274
- job.suiteNote = '探测到的 .gitmodules 与仓库实际内容不符(不是 submodule 套装仓库),已自动回落普通插件安装'
266
+ job.suiteNote = suiteResult?.reason ?? '探测到的 .gitmodules 与仓库实际内容不符(不是 submodule 套装仓库),已自动回落普通插件安装'
275
267
  }
276
- // 兜底:宿主端自行拉取仓库元数据(githubJson 与 curl 竞速 + 8s 超时降级,黑洞期不卡 40s)。
277
- // 8s 而非旧值 3s:IPv6 无路由的环境里单条通道就要 5.4s,3s 预算必输 → branch 恒为 main,
278
- // 默认分支为 dev 的仓库会取错分支(2026-09-20 另一位用户实测)。
279
- const meta = await Promise.race([
280
- Promise.any([
281
- githubJson(`${GITHUB_API}/repos/${job.repo}`),
282
- curlJson(`${GITHUB_API}/repos/${job.repo}`, 12000, {}, { ipv4: true }),
283
- ]),
284
- new Promise((resolve) => setTimeout(() => resolve(null), META_BUDGET_MS)),
285
- ]).catch(() => null)
268
+ // 仓库元数据(默认分支)探测搬进 suite.js 的 fetchRepoMeta(githubJson 与 curl 竞速 + 8s 降级,
269
+ // 黑洞期不卡 40s;8s 而非 3s 的理由见那里的注释)。套装判定时已经探过一次 → 这里复用缓存,不重复联网。
270
+ const meta = job.repoMeta !== undefined ? job.repoMeta : await fetchRepoMeta(job.repo)
286
271
  const branch = meta?.default_branch ?? 'main'
287
- const { pkg, reason } = await fetchRepoPackageEx(job.repo, branch)
272
+ job.defaultBranch = branch // 批次 B-⑥:后续读子包一律以它打头(默认分支是 dev 的仓库不再读不到子包)
273
+ const rootProbe = job.rootPkgProbe !== undefined ? job.rootPkgProbe : await probes.fetchRepoPackageEx(job.repo, branch)
274
+ const { pkg, reason } = rootProbe
288
275
  if (pkg === null) {
289
276
  // 无 package.json:先探测是否技能仓库(含 SKILL.md)→ 自动转技能安装;
290
277
  // 否则标记 hint=repo-land,前端给出「仓库落地」一键入口(克隆到本地目录)。
@@ -304,17 +291,23 @@ async function runInstallJob(job, ports) {
304
291
  if (pkg.private === true) {
305
292
  // 私有 monorepo 根:自动列出子包作为候选(把人工修复经验自动化),聚合包优先
306
293
  subpackageMode = true
294
+ // 批次 A-③(2026-09-27):根包 private = **没发布到 npm** → registry 通道必然 404,
295
+ // 而 git 通道会去 clone 整个仓库(真机:dsh-web 429 MB、ghproxy 下 0 B/s)。
296
+ // 对该候选禁用 git,把时间让给"展开子包 → 按包名直装"这条真正能成功的路。
297
+ job.privateRoot = true
298
+ job.gitChannelBlocked = true
299
+ noteChannel(job, '根包未发布到 npm(private: true),已跳过 git 克隆通道(避免白拉整个仓库)')
307
300
  // 2026-09-20 事故(用户点装 zhu1090093659/dsh-web,报"未发现子包"):该仓库根包确实
308
301
  // private: true,但 main/dev 各有 22 个子包目录。失败原因是当时市场索引源全挂、网络受限,
309
302
  // subpackageCandidates() 读不到列表 —— 旧代码只有"有没有子包"一个出口,把**没读到**
310
303
  // 报成了**不存在**,直接把用户带偏。教训:探测失败必须与确定性结论分开表达。
311
- let subs = await subpackageCandidates(job.repo, branch)
304
+ let subs = job.subpackageProbe !== undefined ? job.subpackageProbe : await probes.subpackageCandidates(job.repo, branch)
312
305
  if (subs.length === 0) {
313
306
  // 读不到时先换一条分支重试:meta 探测失败时 branch 恒为 main,而默认分支为 dev 的仓库
314
307
  // (本例 dsh-web 就是 dev 为默认分支)main 上的子包布局可能不同/为空;
315
308
  // 换分支几乎零成本,却能把"分支取错"这一类假失败挡在报错之前。
316
309
  const altBranch = branch === 'main' ? 'dev' : 'main'
317
- subs = await subpackageCandidates(job.repo, altBranch)
310
+ subs = await probes.subpackageCandidates(job.repo, altBranch)
318
311
  if (subs.length > 0) job.subpackageNote = `子包列表取自 ${altBranch} 分支(默认分支探测可能失败)`
319
312
  }
320
313
  if (subs.length === 0) {
@@ -336,12 +329,17 @@ async function runInstallJob(job, ports) {
336
329
  job.packageName = pkg.name
337
330
  }
338
331
  // 给了根包名但根包实际是 private 聚合仓库(如直接填 dsh-web-ui):
339
- // 与仓库模式同路径——直接展开子包(聚合包优先),跳过 git 装根包的无意义尝试
340
- if (!subpackageMode && job.repo && job.packageName !== null) {
341
- const rootPkg = await fetchRepoPackage(job.repo, 'main')
332
+ // 与仓库模式同路径——直接展开子包(聚合包优先),跳过 git 装根包的无意义尝试。
333
+ // 批次 C-⑪:候选来自索引 npmName 时跳过这一步(该装哪个包索引已给答案,再问 GitHub 就把"省掉探测"白省了)
334
+ if (!subpackageMode && job.repo && job.packageName !== null && job.npmNameHint === undefined) {
335
+ const rootPkg = await probes.fetchRepoPackage(job.repo, 'main')
342
336
  if (rootPkg !== null && rootPkg.private === true) {
343
337
  subpackageMode = true
344
- const subs = await subpackageCandidates(job.repo, 'main', readGithubAuth().token)
338
+ // 同上游分支:根包未发布 → 禁用 git 通道(批次 A-③)
339
+ job.privateRoot = true
340
+ job.gitChannelBlocked = true
341
+ noteChannel(job, '根包未发布到 npm(private: true),已跳过 git 克隆通道(避免白拉整个仓库)')
342
+ const subs = await probes.subpackageCandidates(job.repo, 'main', readGithubAuth().token)
345
343
  if (subs.length > 0) {
346
344
  candidates = [...subs.filter((name) => !candidates.includes(name)), ...candidates]
347
345
  job.subpackages = subs
@@ -361,7 +359,7 @@ async function runInstallJob(job, ports) {
361
359
  let expanded = false
362
360
  // 可配置软件源:主→备依次尝试(默认 npmmirror → npmjs,可增删自定义/内网源)
363
361
  const registries = orderedRegistries(readSources())
364
- const deadline = Date.now() + 8 * 60 * 1000
362
+ const deadline = Date.now() + jobBudgetMs
365
363
  // 子包级进度(2026-09-20 真装实测:11 个子包的聚合仓库跑了 19 分钟,job.stage 一直停在
366
364
  // installing,面板只有一个不动的进度条)。candidateTotal/Index/Name 每轮开始时更新,
367
365
  // 由 installJobView 折算成 progress{index,total,name} 下发给面板。
@@ -380,33 +378,30 @@ async function runInstallJob(job, ports) {
380
378
  // ⚠️ 本行必须在 `const name` **之后**求值:旧代码把它写在 name 声明之前,`name === job.packageName`
381
379
  // 一被求值就命中 TDZ(ReferenceError: Cannot access 'name' before initialization)——
382
380
  // 私有聚合根(subpackageMode=true 且 packageName 非空)安装必失败,且报错文案完全指不到真正原因。
383
- const repoChannelAllowed = !subpackageMode || job.packageName === null || name === job.packageName
384
- const attempt = await tryCandidateChannels({ job, ch, name, profileDir, registries, repoChannelAllowed, budget, baseUrl: ports.baseUrl ?? null, expanded })
381
+ // 批次 A-③:再叠加 gitChannelBlocked —— 根包未发布(或首选候选来自索引)时不试 git(只 clone 整仓,纯白等)
382
+ const repoChannelAllowed = (!subpackageMode || job.packageName === null || name === job.packageName) && job.gitChannelBlocked !== true
383
+ // 懒惰展开的注入实现(只展开一次:expanded 由这里翻转,后续轮次传进去的就是 true)
384
+ const expandSubpackages = async () => {
385
+ if (expanded || !job.repo) return 0
386
+ expanded = true
387
+ // 批次 B-⑥:以**真实默认分支**打头(fetchSubpackageNames 内部再回退 main / master)
388
+ const extra = await probes.expandSubpackages({ repo: job.repo, branch: job.defaultBranch ?? 'main', auth: readGithubAuth().token, existing: candidates })
389
+ if (extra.length === 0) return 0
390
+ candidates = [...candidates, ...extra]
391
+ // 懒惰展开后候选变多:总数要跟着更新,否则面板会显示"第 9/1 个"
392
+ job.candidateTotal = candidates.length
393
+ if (!Array.isArray(job.subpackages)) job.subpackages = []
394
+ for (const e of extra) if (!job.subpackages.includes(e)) job.subpackages.push(e)
395
+ return extra.length
396
+ }
397
+ const attempt = await tryCandidateChannels({
398
+ job, ch, name, profileDir, registries, repoChannelAllowed, budget,
399
+ baseUrl: ports.baseUrl ?? null, expanded, deadline,
400
+ expand: (expanded || job.npmNameHint !== undefined) ? null : expandSubpackages,
401
+ })
385
402
  installedName = attempt.installedName
386
403
  lastError = attempt.lastError
387
404
  if (installedName !== null) break
388
- // 懒惰展开:registry 与 git 通道都失败时,自动发现仓库子包继续尝试(聚合包优先),
389
- // 覆盖"给了根包名但根包未发布"的场景——AI 兜底只处理真正无解的案例
390
- if (!expanded && job.repo) {
391
- expanded = true
392
- let subs = await fetchSubpackageNames(job.repo, 'main', readGithubAuth().token)
393
- if (subs.length === 0) subs = await fetchSubpackageNames(job.repo, 'master', readGithubAuth().token)
394
- if (subs.length > 0) {
395
- const extra = subs
396
- .slice()
397
- .sort((a, b) => Number(/(^|-)all$/u.test(b.name) || /-all-/u.test(b.name)) - Number(/(^|-)all$/u.test(a.name) || /-all-/u.test(a.name)))
398
- .map((sub) => sub.name)
399
- .filter((n) => !candidates.includes(n))
400
- .slice(0, 8)
401
- if (extra.length > 0) {
402
- candidates = [...candidates, ...extra]
403
- // 懒惰展开后候选变多:总数要跟着更新,否则面板会显示"第 9/1 个"
404
- job.candidateTotal = candidates.length
405
- if (!Array.isArray(job.subpackages)) job.subpackages = []
406
- for (const e of extra) if (!job.subpackages.includes(e)) job.subpackages.push(e)
407
- }
408
- }
409
- }
410
405
  }
411
406
  if (installedName === null) {
412
407
  if (job.diagnosis == null) job.diagnosis = classifyInstallFailure(lastError?.message) // 分类提示兜底(加法)
@@ -415,12 +410,12 @@ async function runInstallJob(job, ports) {
415
410
  job.aiPending = { lastError: lastError?.message ?? null }
416
411
  // 等授权期间要能展示"为什么卡住、还能等多久":请求时间 + 超时上限 + 最后一个确定性错误
417
412
  job.aiPendingSince = Date.now()
418
- job.aiConsentTimeoutMs = AI_CONSENT_TIMEOUT_MS
413
+ job.aiConsentTimeoutMs = aiConsentTimeoutMs
419
414
  job.lastError = lastError?.message ?? null
420
415
  job.aiWait = new Promise((resolve) => { job.aiPending.resolver = resolve })
421
416
  const decision = await Promise.race([
422
417
  job.aiWait,
423
- new Promise((resolve) => setTimeout(() => resolve({ approved: false, timeout: true }), AI_CONSENT_TIMEOUT_MS)),
418
+ new Promise((resolve) => setTimeout(() => resolve({ approved: false, timeout: true }), aiConsentTimeoutMs)),
424
419
  ])
425
420
  job.aiPending = null
426
421
  job.aiWait = null
@@ -432,7 +427,7 @@ async function runInstallJob(job, ports) {
432
427
  // 不能让用户面对"面板说失败、磁盘上却留了半个包和 _tmp_ 残留"的糊涂账。
433
428
  const leftovers = cleanupAttemptedCandidates(profileDir, candidates)
434
429
  job.leftovers = leftovers
435
- job.error = aiConsentFailureText(decision, leftovers)
430
+ job.error = aiConsentFailureText(decision, leftovers, aiConsentTimeoutMs)
436
431
  }
437
432
  return
438
433
  }
@@ -596,4 +591,4 @@ async function readExtraBundleRows(profileDir) {
596
591
  } catch {}
597
592
  return rows
598
593
  }
599
- export { runInstallJob, pnpmRemove, readExtraBundleRows, cleanupAttemptedCandidates, aiConsentFailureText, tryCandidateChannels, channelImpls, RELEASE_CHANNEL_BUDGET }
594
+ export { runInstallJob, pnpmRemove, readExtraBundleRows, cleanupAttemptedCandidates, aiConsentFailureText, tryCandidateChannels, channelImpls, RELEASE_CHANNEL_BUDGET, GIT_CHANNEL_BUDGET_MS, GIT_SPEC_TIMEOUT_MS, GIT_MIN_ATTEMPT_MS, gitChannelBudgetMs, noteChannel, formatBudgetMs }
@@ -536,6 +536,8 @@ function installJobView(job) {
536
536
  lockVersion: job.lockVersion ?? null,
537
537
  lockNote: job.lockNote ?? null,
538
538
  depNote: job.depNote ?? null, // 依赖来源写回说明(缺陷②:release 专属包按 link: 记录时给出可见解释,绝不静默)
539
+ // 通道备注(2026-09-27 加法):通道"为什么没试/为什么只试了一部分"如实下发(release/git 预算用尽、根包未发布禁 git…)
540
+ channelNotes: Array.isArray(job.channelNotes) && job.channelNotes.length > 0 ? job.channelNotes : null,
539
541
  compatNote: job.compatNote ?? null,
540
542
  kind: job.kind ?? 'plugin',
541
543
  skillName: job.skillName ?? null,
@@ -6,7 +6,7 @@ import { readFile } from 'node:fs/promises'
6
6
  import { dirname, join } from 'node:path'
7
7
  import { detectSkillRepo } from './skills.js'
8
8
  import { FETCH_OK, FETCH_UNREACHABLE, GITHUB_API, curlJson, curlText, fetchJsonUrl, githubJson, looksLikeGitmodules, rawTextFetch, rawTextWithFallback } from '../infra/http.js'
9
- import { ENRICH_CACHE_FILE, baseDirOf, resolvePackageJson } from '../infra/paths.js'
9
+ import { ENRICH_CACHE_FILE, baseDirOf, marketIndexCacheFile, resolvePackageJson } from '../infra/paths.js'
10
10
 
11
11
  function readEnrichCache() {
12
12
  try {
@@ -368,16 +368,27 @@ async function readPluginDetails(moduleName, baseUrl, profileDir) {
368
368
  }
369
369
  }
370
370
 
371
- /** 服务端列出仓库子包(git trees 递归 + 并行读 package.json 的 name)。 */
372
- async function fetchSubpackageNames(repo, branch, auth) {
371
+ /** 读子包时要按顺序试哪些分支(纯函数,单测覆盖 —— 批次 B-⑥,2026-09-27 改错)。
372
+ * 旧代码把分支**写死**成 'main'(读不到再手工换 'master'):默认分支是 dev 的仓库
373
+ * (真机:zhu1090093659/dsh-web 的默认分支就是 dev)永远读不到子包列表,
374
+ * 于是「未发现子包」这类假失败又回来了。现在一律用**已经拿到的**默认分支打头,
375
+ * 再回退 main / master(去重、保序)。 */
376
+ function subpackageBranchOrder(branch, defaults = ['main', 'master']) {
377
+ const list = Array.isArray(branch) ? branch : [branch]
378
+ const merged = [...list, ...defaults].filter((b) => typeof b === 'string' && b.trim() !== '')
379
+ return [...new Set(merged.map((b) => b.trim()))]
380
+ }
381
+
382
+ /** 单个分支上读子包列表(git trees 递归 + 并行读 package.json 的 name)。 */
383
+ async function fetchSubpackageNamesOnBranch(repo, branch, auth, { ghJson, rawText }) {
373
384
  try {
374
- const data = await githubJson(`${GITHUB_API}/repos/${repo}/git/trees/${encodeURIComponent(branch)}?recursive=1`, undefined, auth)
385
+ const data = await ghJson(`${GITHUB_API}/repos/${repo}/git/trees/${encodeURIComponent(branch)}?recursive=1`, undefined, auth)
375
386
  const paths = (data.tree ?? [])
376
387
  .filter((node) => node.type === 'blob' && /^(?!node_modules\/)[^/]+(?:\/[^/]+)?\/package\.json$/u.test(node.path))
377
388
  .map((node) => node.path)
378
389
  // 并行读取:黑洞期单条最坏 40s,24 条串行会拖到十几分钟
379
390
  const results = await Promise.all(paths.slice(0, 24).map(async (path) => {
380
- const bodyText = await rawTextWithFallback(repo, branch, path)
391
+ const bodyText = await rawText(repo, branch, path)
381
392
  if (bodyText === null) return null
382
393
  try {
383
394
  const pkg = JSON.parse(bodyText)
@@ -391,6 +402,61 @@ async function fetchSubpackageNames(repo, branch, auth) {
391
402
  }
392
403
  }
393
404
 
405
+ /** 从市场索引里查这个仓库的**首选 npm 包名**(批次 C-⑪,2026-09-27 加法)。
406
+ * 数据源与 /market-index 完全一致:routes/market.js 每次成功拉到索引都会写一份落盘缓存
407
+ * (infra/paths.marketIndexCacheFile),这里读它 —— 与内存缓存等价、且跨重启有效。
408
+ * 为什么值得这一步:真机 dsh-web 的根包 private(没发布),旧流程要先探测 package.json、
409
+ * 再展开子包才能找到 @linxin666/dsh-web-all;而本机 api.github.com 不可达时**这两步都做不了**。
410
+ * 兼容性:老索引没有 npmName 字段 → 返回 null → 行为与改动前完全一致。
411
+ * deps(readFile/cacheFile)只为单测注入,生产调用不传。 */
412
+ function npmNameHintForRepo(repo, deps = {}) {
413
+ const full = String(repo ?? '').trim().replace(/\.git$/u, '')
414
+ if (full === '') return null
415
+ const read = deps.readFile ?? ((file) => readFileSync(file, 'utf8'))
416
+ const file = deps.cacheFile ?? marketIndexCacheFile()
417
+ try {
418
+ const parsed = JSON.parse(read(file))
419
+ const items = Array.isArray(parsed?.data?.items) ? parsed.data.items : (Array.isArray(parsed?.items) ? parsed.items : [])
420
+ const hit = items.find((it) => it !== null && typeof it.fullName === 'string' && it.fullName.toLowerCase() === full.toLowerCase())
421
+ if (hit === undefined) return null
422
+ const npmName = typeof hit.npmName === 'string' ? hit.npmName.trim() : ''
423
+ if (npmName === '') return null
424
+ // 只接受合法 npm 包名(索引是外部数据,不能拿它去拼命令)
425
+ return /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/u.test(npmName) ? npmName : null
426
+ } catch {
427
+ return null
428
+ }
429
+ }
430
+
431
+ /** 懒惰展开(批次 A-③ 的实现;2026-09-27 从 install-job.js 搬进 market.js —— 那边触到 600 行硬顶):
432
+ * 读仓库子包列表 → **聚合包优先**(名字以 all 结尾/-all- 形式)→ 去掉已有候选 → 限量返回。
433
+ * 分支由调用方给(install-job 传 meta.default_branch),fetchSubpackageNames 自己会回退 main/master。 */
434
+ async function expandSubpackageCandidates({ repo, branch = 'main', auth = null, existing = [], limit = 8, deps = {} } = {}) {
435
+ const fetchNames = deps.fetchSubpackageNames ?? fetchSubpackageNames
436
+ const subs = await fetchNames(repo, branch, auth, deps.fetchDeps ?? {})
437
+ if (subs.length === 0) return []
438
+ const isAll = (n) => /(^|-)all$/u.test(n) || /-all-/u.test(n)
439
+ return subs
440
+ .map((sub) => sub.name)
441
+ .filter((n) => !existing.includes(n))
442
+ .sort((a, b) => Number(isAll(b)) - Number(isAll(a)))
443
+ .slice(0, limit)
444
+ }
445
+
446
+ /** 服务端列出仓库子包(git trees 递归 + 并行读 package.json 的 name)。
447
+ * branch 可以是**单个分支名**(老调用点不变)或**分支数组**(按序试);两种形态都会自动
448
+ * 追加 main / master 兜底(见 subpackageBranchOrder)。deps 只为单测注入,生产调用不传。 */
449
+ async function fetchSubpackageNames(repo, branch, auth, deps = {}) {
450
+ const ghJson = deps.githubJson ?? githubJson
451
+ const rawText = deps.rawText ?? rawTextWithFallback
452
+ for (const b of subpackageBranchOrder(branch)) {
453
+ // eslint-disable-next-line no-await-in-loop
454
+ const subs = await fetchSubpackageNamesOnBranch(repo, b, auth, { ghJson, rawText })
455
+ if (subs.length > 0) return subs
456
+ }
457
+ return []
458
+ }
459
+
394
460
  /** 子包候选:聚合包(名字带 all)优先,上限 8 个。
395
461
  * 2026-09-20 真装演练:`@dsh-suite/all` 这种 **scope 根形式**(`/all` 结尾)不被
396
462
  * `(^|-)all$` 命中,聚合包没能排到最前(那次纯属仓库目录顺序碰巧第一)。补上 `/all$`。 */
@@ -406,4 +472,4 @@ async function subpackageCandidates(repo, branch, auth) {
406
472
 
407
473
  const ENRICH_CACHE_TTL = 24 * 60 * 60 * 1000
408
474
 
409
- export { readEnrichCache, writeEnrichCache, enrichItemOne, enrichItems, normalizePlatformItems, githubRepoInfo, fetchRepoPackage, fetchRepoPackageEx, packageProbeErrorText, searchNpmPackages, searchSubpackageItems, parseRepoFromUrl, hasDirectNameHit, summarizeReadme, readPluginDetails, fetchSubpackageNames, subpackageCandidates, ENRICH_CACHE_TTL }
475
+ export { readEnrichCache, writeEnrichCache, enrichItemOne, enrichItems, normalizePlatformItems, githubRepoInfo, fetchRepoPackage, fetchRepoPackageEx, packageProbeErrorText, searchNpmPackages, searchSubpackageItems, parseRepoFromUrl, hasDirectNameHit, summarizeReadme, readPluginDetails, fetchSubpackageNames, fetchSubpackageNamesOnBranch, subpackageBranchOrder, subpackageCandidates, expandSubpackageCandidates, npmNameHintForRepo, ENRICH_CACHE_TTL }