@noob-stupid/dsh-plugin-console 0.5.16 → 0.5.18

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,74 +2,31 @@
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
+ 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'
10
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'
11
14
  import { classifyInstallFailure, retryPnpmOnce } from './install-diagnose.js'
15
+ import { jobLog } from './jobs.js'
12
16
  import { verifyInstalledEntry } from './install-verify.js'
13
- import { fetchRepoPackage, fetchRepoPackageEx, fetchSubpackageNames, packageProbeErrorText, subpackageCandidates } from './market.js'
17
+ import { expandSubpackageCandidates, fetchRepoPackage, fetchRepoPackageEx, fetchSubpackageNames, npmNameHintForRepo, packageProbeErrorText, subpackageCandidates } from './market.js'
14
18
  import { declareProfileDependency } from './manifest.js'
15
19
  import { appendInsert, readPatchState, syncNameFromNote } from './patch.js'
16
20
  import { deriveEntryId, listEntries } from './runtime.js'
17
21
  import { detectSkillRepo, runSkillInstallJob } from './skills.js'
18
- import { gitCloneUrls, orderedRegistries, readSources } from './sources.js'
22
+ import { orderedRegistries, readSources } from './sources.js'
19
23
  import { reconcileLockfile } from './selfupdate.js'
20
24
  import { probeGitmodules, resolveInstallKind, runSuiteInstallJob } from './suite.js'
21
25
  import { buildPnpmEnv, execFileAsync, runPnpmWithFallback } from '../infra/exec.js'
22
- import { cleanupStalePackageDir, removeDirVerified } from '../infra/fsx.js'
26
+ import { cleanupStalePackageDir, startTrashCleanup, trashScanRoots } from '../infra/fsx.js'
23
27
  import { GITHUB_API, META_BUDGET_MS, curlJson, githubJson } from '../infra/http.js'
24
28
  import { findPatchPath, packageNameOf, resolvePackageJson } from '../infra/paths.js'
25
29
 
26
- /** 安装失败清场:把本次尝试过的候选包目录与 pnpm `_tmp_` 半成品一起清掉,并**如实汇报**清了什么、
27
- * 什么没清掉。为什么需要(2026-09-20 真装演练):11 个子包的聚合仓库跑 19 分钟后失败,node_modules 里
28
- * 留着 `@captain1275/dsh-full-stats_tmp_56272_2` 这类半成品和一个真包,而面板只报"安装失败"——
29
- * 用户既不知道有东西被落盘,也不知道要不要手动清。删除一律走 removeDirVerified(本机 C 盘/`%TEMP%`
30
- * 下 rmSync 会静默落空,不核实就会谎报干净)。 */
31
- function cleanupAttemptedCandidates(profileDir, candidates) {
32
- const cleaned = []
33
- const failed = []
34
- for (const name of candidates) {
35
- const dir = join(profileDir, 'node_modules', ...String(name).split('/'))
36
- const parent = dirname(dir)
37
- const base = basename(dir)
38
- let touched = 0
39
- if (existsSync(dir)) {
40
- const result = removeDirVerified(dir)
41
- if (result.ok) touched += 1
42
- else failed.push({ name, path: dir, error: result.error })
43
- }
44
- try {
45
- for (const entry of readdirSync(parent)) {
46
- if (!entry.startsWith(`${base}_tmp_`)) continue
47
- const tmpPath = join(parent, entry)
48
- const result = removeDirVerified(tmpPath)
49
- if (result.ok) touched += 1
50
- else failed.push({ name: `${name}(临时目录)`, path: tmpPath, error: result.error })
51
- }
52
- } catch {}
53
- if (touched > 0) cleaned.push(name)
54
- }
55
- return { cleaned, failed }
56
- }
57
-
58
- /** 授权被拒/超时后的失败文案(纯函数,单测覆盖):说清为什么失败、清理了什么、什么没清掉。
59
- * 时长取自 AI_CONSENT_TIMEOUT_MS —— 文案里的"10 分钟"不能与实际等待时间脱节。 */
60
- function aiConsentFailureText(decision, leftovers) {
61
- const base = decision?.timeout === true
62
- ? `等待授权超时(${Math.round(AI_CONSENT_TIMEOUT_MS / 60000)} 分钟),已取消本地 AI 兜底(该操作会调用模型 API 产生费用)`
63
- : '用户取消本地 AI 兜底(该操作会调用模型 API 产生费用)'
64
- const cleaned = leftovers?.cleaned ?? []
65
- const failed = leftovers?.failed ?? []
66
- const cleanedNote = cleaned.length > 0 ? `;已清理本次落盘残留:${cleaned.join('、')}` : ''
67
- const failedNote = failed.length > 0
68
- ? `;**有 ${failed.length} 项没能清理**(当前环境可能禁止删除,请手动删除):${failed.map((f) => f.path).join('、')}`
69
- : ''
70
- return `${base}${cleanedNote}${failedNote}`
71
- }
72
-
73
30
  /** release 通道的候选预算(issue #3):release 通道现在按包名反查发布仓库,**每个候选**至少一次
74
31
  * `releases?per_page=10` 调用(第一梯队仓库没命中还要多试几个候选仓库),而候选可能有十几个
75
32
  * (聚合仓库懒惰展开后最多 19 个)——不封顶会把 8 分钟的作业时间预算吃光。
@@ -83,7 +40,6 @@ const RELEASE_CHANNEL_BUDGET = 3
83
40
  * 否则替身与 cordis 语义不一致,这类缺陷还会再溜过去一次。 */
84
41
  function channelImpls(ports) {
85
42
  const real = { pnpmInstall, curlManualInstall, raceInstallChannels, githubReleaseInstall, backfillMissingDeps }
86
- // 测试注入缝(2026-09-22 事故的修法,**别改回属性访问**):
87
43
  // 生产路径上 ports 就是 cordis 的 ctx 代理,属性式读取未 inject 的名字会**同步抛**
88
44
  // `cannot get property "installChannels" without inject` —— 0.3.59 因此每一次安装都在这里失败。
89
45
  // 正规写法是 ctx.get('installChannels')(Cordis 的可选读取,未声明也不抛,与本文件其它
@@ -112,7 +68,7 @@ function channelImpls(ports) {
112
68
  * ③ git 通道:**保留 repoChannelAllowed**(它只 clone `job.repo`,候选是子包时 clone 根仓库装不出子包,
113
69
  * 属无意义尝试)**并保留 !expanded**(同一作业里对同一个 job.repo 反复 clone 纯属浪费时间,
114
70
  * 级联顺序也不该被破坏)。 */
115
- async function tryCandidateChannels({ job, ch, name, profileDir, registries, repoChannelAllowed, budget, baseUrl = null, expanded = false }) {
71
+ async function tryCandidateChannels({ job, ch, name, profileDir, registries, repoChannelAllowed, budget, baseUrl = null, expanded = false, deadline = null, expand = null }) {
116
72
  let installedName = null
117
73
  let lastError = null
118
74
  // 加法优化:包已在 node_modules 且名字匹配时,不再重复安装/触发 EPERM,直接进入启用流程
@@ -128,16 +84,27 @@ async function tryCandidateChannels({ job, ch, name, profileDir, registries, rep
128
84
  }
129
85
  // 通道 0:并行竞速(pnpm 与 curl 同时启动,先成功者生效;失败方 abort,不影响后续串行通道)——守卫①
130
86
  {
131
- const raced = await ch.raceInstallChannels(profileDir, name, registries)
132
- if (raced) {
133
- installedName = name
134
- if (raced.channel === 'curl') {
135
- const racedInfo = raced.info
136
- const stillMissing = await ch.backfillMissingDeps(profileDir, racedInfo.missingDeps, registries)
137
- job.curlNote = `已通过并行 curl 通道安装 v${racedInfo.version}${stillMissing.length > 0 ? `(依赖仍未补齐:${stillMissing.join('、')},网络恢复后建议重新安装)` : '(捆绑依赖已补齐)'}`
138
- if (racedInfo.boxNote) job.curlNote += `(盒子验证:${racedInfo.boxNote})`
139
- job.integrity = racedInfo.integrity ?? null // ④ 下载物摘要校验结果(加法;见 install-verify.js)
87
+ try {
88
+ const raced = await ch.raceInstallChannels(profileDir, name, registries)
89
+ if (raced) {
90
+ installedName = name
91
+ if (raced.channel === 'curl') {
92
+ const racedInfo = raced.info
93
+ try {
94
+ const stillMissing = await ch.backfillMissingDeps(profileDir, racedInfo.missingDeps, registries)
95
+ job.curlNote = `已通过并行 curl 通道安装 v${racedInfo.version}${stillMissing.length > 0 ? `(依赖仍未补齐:${stillMissing.join('、')},网络恢复后建议重新安装)` : '(捆绑依赖已补齐)'}`
96
+ } catch (backfillError) {
97
+ // 包已装好:补齐依赖失败只影响提示,绝不能因此把这次安装判成失败
98
+ job.curlNote = `已通过并行 curl 通道安装 v${racedInfo.version}(捆绑依赖补齐失败:${backfillError instanceof Error ? backfillError.message : String(backfillError)};网络恢复后建议重新安装)`
99
+ noteChannel(job, `并行 curl 通道的依赖补齐失败(安装本身已成功):${backfillError instanceof Error ? backfillError.message : String(backfillError)}`)
100
+ }
101
+ if (racedInfo.boxNote) job.curlNote += `(盒子验证:${racedInfo.boxNote})`
102
+ job.integrity = racedInfo.integrity ?? null // ④ 下载物摘要校验结果(加法;见 install-verify.js)
103
+ }
140
104
  }
105
+ } catch (raceError) {
106
+ lastError = raceError
107
+ noteChannel(job, `并行竞速通道异常(已继续尝试后续串行通道):${raceError instanceof Error ? raceError.message : String(raceError)}`)
141
108
  }
142
109
  }
143
110
  // 通道 1..n:配置的软件源依次尝试(每源 90 秒封顶)
@@ -180,31 +147,33 @@ async function tryCandidateChannels({ job, ch, name, profileDir, registries, rep
180
147
  } catch (ghError) {
181
148
  lastError = ghError
182
149
  }
183
- } else if (lastError === null) {
184
- // 预算用尽:不覆盖真实错误(面板/AI 兜底要看的是 curl·pnpm 的失败原因),只在无更具体错误时说明
185
- lastError = new Error(`release 通道候选预算已用尽(本作业只对前 ${RELEASE_CHANNEL_BUDGET} 个候选做按包名反查+release 扫描)`)
150
+ } else {
151
+ // 预算用尽(批次 A-⑤):原因**总是**记在 job 上供面板显示(旧代码只在 lastError === null 时写,用户看不出)
152
+ noteChannel(job, `release 通道因预算跳过(本作业只对前 ${RELEASE_CHANNEL_BUDGET} 个候选做按包名反查+release 扫描)`)
153
+ if (lastError === null) lastError = new Error(`release 通道候选预算已用尽(本作业只对前 ${RELEASE_CHANNEL_BUDGET} 个候选做按包名反查+release 扫描)`)
186
154
  }
187
155
  }
188
- if (installedName === null) {
189
- // 通道 n+2:git 通道(GitHub 走加速代理+直连;Gitee 走对应平台;各 60 秒封顶)——守卫③
190
- if (repoChannelAllowed && !expanded) {
191
- const gitSpecs = job.source === 'gitee'
192
- ? [`git+https://gitee.com/${job.repo}.git`]
193
- : [
194
- ...gitCloneUrls(job.repo).map((u) => `git+${u}`),
195
- `github:${job.repo}`,
196
- ]
197
- for (const spec of gitSpecs) {
198
- try {
199
- await ch.pnpmInstall(profileDir, spec, undefined, 60000)
200
- installedName = name
201
- break
202
- } catch (gitError) {
203
- lastError = gitError
204
- }
205
- }
156
+ // 懒惰展开(批次 A-③,2026-09-27 搬家):**registry 类通道全部失败之后、git 通道之前**。
157
+ // 旧顺序是"所有通道(含 git)都失败 → 才展开子包":对"根包 private、未发布到 npm"的仓库
158
+ // (真机 zhu1090093659/dsh-web)就是 registry 404 → 直接去 clone 那个 429 MB 的巨仓
159
+ // (ghproxy 上 git 协议 0 B/s,白等且注定失败),而真正能装的聚合子包 @linxin666/dsh-web-all
160
+ // (5.97 MB)连一次机会都没有。expand() 返回新增候选数;展开失败只记备注,绝不短路。
161
+ if (installedName === null && expanded === false && typeof expand === 'function') {
162
+ try {
163
+ const added = await expand()
164
+ if (added > 0) noteChannel(job, `registry 通道未命中,已自动展开仓库子包:新增 ${added} 个候选`)
165
+ } catch (error) {
166
+ noteChannel(job, `自动展开仓库子包失败(继续走后续通道):${error instanceof Error ? error.message : String(error)}`)
206
167
  }
207
168
  }
169
+ if (installedName === null) {
170
+ // 通道 n+2:git 通道(GitHub 走加速代理+直连;Gitee 走对应平台)——守卫③
171
+ // 2026-09-27:实现搬进 domain/git-channel.js(独立预算 + 不得超过作业剩余预算;
172
+ // install-job.js 有 600 行硬顶,见 test-architecture-guard.mjs),此处只保留调用与错误归并。
173
+ const git = await tryGitChannel({ job, ch, name, profileDir, repoChannelAllowed, expanded, budget, deadline })
174
+ installedName = git.installedName
175
+ if (git.lastError !== null) lastError = git.lastError
176
+ }
208
177
  // ③ 失败分类 → 只对网络类超时用更长超时定向重试**一次**;其余分类只写 job.diagnosis 提示(见 install-diagnose.js)
209
178
  if (installedName === null && pnpmError !== null) {
210
179
  const retry = await retryPnpmOnce({ ch, profileDir, name, registries, pnpmError, job, baseTimeoutMs: PNPM_INSTALL_TIMEOUT_MS })
@@ -228,12 +197,28 @@ async function tryCandidateChannels({ job, ch, name, profileDir, registries, rep
228
197
  return { installedName, lastError }
229
198
  }
230
199
 
231
- async function runInstallJob(job, ports) {
200
+ async function runInstallJob(job, ports, deps = {}) {
201
+ // 测试注入缝(2026-09-27,沿用 channelImpls 风格):市场探测与两个时间预算可替换,只为离线断言
202
+ // 「候选顺序、展开时机、预算边界」;生产调用方不传第三个参数,默认值与旧行为一致。
203
+ const probes = {
204
+ fetchRepoPackage,
205
+ fetchRepoPackageEx,
206
+ fetchSubpackageNames,
207
+ subpackageCandidates,
208
+ npmNameHint: npmNameHintForRepo,
209
+ ...(deps.marketProbes ?? {}),
210
+ }
211
+ // 展开实现必须跟随**注入的** fetchSubpackageNames(否则注入的探测桩会被绕过、悄悄去打真网络 ——
212
+ // 2026-09-27 全量测试实测到的一次 30 秒空等就是这么来的)
213
+ if (deps.marketProbes?.expandSubpackages === undefined) {
214
+ probes.expandSubpackages = (opts = {}) => expandSubpackageCandidates({ ...opts, deps: { fetchSubpackageNames: probes.fetchSubpackageNames } })
215
+ }
216
+ const jobBudgetMs = Number.isFinite(deps.jobBudgetMs) && deps.jobBudgetMs > 0 ? deps.jobBudgetMs : 8 * 60 * 1000
217
+ const aiConsentTimeoutMs = Number.isFinite(deps.aiConsentTimeoutMs) && deps.aiConsentTimeoutMs > 0 ? deps.aiConsentTimeoutMs : AI_CONSENT_TIMEOUT_MS
232
218
  try {
233
219
  job.stage = 'preparing'
234
- // 2026-09-06 事故(室友机器):对 deepseek-ai/deepseek-harness(框架本体仓库)点「添加到本地/安装」
235
- // 会按 bundle 规则注册其 patch,其中 deepseek-ai-dsh-root 等框架级行的包只存在于 npx 缓存/框架树,
236
- // profile node_modules 不存在 → 整服务启动崩溃。直接拦截。
220
+ // 2026-09-06 事故(室友机器):对 deepseek-ai/deepseek-harness(框架本体仓库)点安装会按 bundle 规则
221
+ // 注册其 patch,其中框架级行的包只存在于 npx 缓存/框架树 → profile 里不存在 → 整服务启动崩溃。直接拦截。
237
222
  const repoNorm = String(job.repo ?? '').toLowerCase().replace(/^git\+/u, '').replace(/\.git$/u, '')
238
223
  if (repoNorm === 'deepseek-ai/deepseek-harness') {
239
224
  job.status = 'failed'
@@ -242,9 +227,22 @@ async function runInstallJob(job, ports) {
242
227
  }
243
228
  let candidates = [job.packageName].filter((name) => typeof name === 'string' && name !== '')
244
229
  let subpackageMode = false
230
+ // ★ 市场索引的首选候选(批次 C-⑪):索引带 npmName 时直接按包名走 registry,连"读根 package.json →
231
+ // 展开子包"这一轮都省掉(本机 api.github.com 不可达时那条路根本走不通)。老索引无该字段 → 行为不变。
232
+ if (candidates.length === 0 && job.repo) {
233
+ const npmHint = probes.npmNameHint(job.repo)
234
+ if (npmHint !== null) {
235
+ candidates = [npmHint]
236
+ job.npmNameHint = npmHint
237
+ job.packageName = npmHint
238
+ // git 通道只 clone 整个仓库(真机 dsh-web 429 MB / ghproxy 0 B/s),而且装不出这个子包 → 禁掉
239
+ job.gitChannelBlocked = true
240
+ noteChannel(job, `市场索引给出首选候选:${npmHint}(按包名直装,省掉"探测根包 + 展开子包")`)
241
+ noteChannel(job, '已跳过 git 克隆通道:git 只 clone 整个仓库、装不出这个包(真机 dsh-web = 429 MB)')
242
+ }
243
+ }
245
244
  if (candidates.length === 0) {
246
- // 套装兜底:submodule 聚合仓库(根 .gitmodules 内容校验通过)→ 自动转套装安装,
247
- // 不依赖前端标记(搜索结果 enrich 是异步的、索引浏览条目无 enrich)。
245
+ // 套装兜底:submodule 聚合仓库(根 .gitmodules 内容校验通过)→ 自动转套装安装,不依赖前端标记。
248
246
  // 判据是**内容**(resolveInstallKind),不是"探测非 null":后者会把代理/CDN 对不存在文件回的
249
247
  // 2xx 空 body、垃圾页当成套装(2026-09-19 用户反馈的「未找到 .gitmodules」事故根因)。
250
248
  if (resolveInstallKind(job.kind, await probeGitmodules(job.repo)) === 'suite') {
@@ -267,9 +265,8 @@ async function runInstallJob(job, ports) {
267
265
  job.kind = 'plugin'
268
266
  job.suiteNote = '探测到的 .gitmodules 与仓库实际内容不符(不是 submodule 套装仓库),已自动回落普通插件安装'
269
267
  }
270
- // 兜底:宿主端自行拉取仓库元数据(githubJson 与 curl 竞速 + 8s 超时降级,黑洞期不卡 40s)。
271
- // 8s 而非旧值 3s:IPv6 无路由的环境里单条通道就要 5.4s,3s 预算必输 → branch 恒为 main,
272
- // 默认分支为 dev 的仓库会取错分支(2026-09-20 另一位用户实测)。
268
+ // 兜底:宿主端自行拉取仓库元数据(githubJson 与 curl 竞速 + 8s 超时降级)。8s 而非旧值 3s:
269
+ // IPv6 无路由时单条通道就要 5.4s,3s 预算必输 → branch 恒为 main(2026-09-20 另一位用户实测)。
273
270
  const meta = await Promise.race([
274
271
  Promise.any([
275
272
  githubJson(`${GITHUB_API}/repos/${job.repo}`),
@@ -278,7 +275,8 @@ async function runInstallJob(job, ports) {
278
275
  new Promise((resolve) => setTimeout(() => resolve(null), META_BUDGET_MS)),
279
276
  ]).catch(() => null)
280
277
  const branch = meta?.default_branch ?? 'main'
281
- const { pkg, reason } = await fetchRepoPackageEx(job.repo, branch)
278
+ job.defaultBranch = branch // 批次 B-⑥:后续读子包一律以它打头(默认分支是 dev 的仓库不再读不到子包)
279
+ const { pkg, reason } = await probes.fetchRepoPackageEx(job.repo, branch)
282
280
  if (pkg === null) {
283
281
  // 无 package.json:先探测是否技能仓库(含 SKILL.md)→ 自动转技能安装;
284
282
  // 否则标记 hint=repo-land,前端给出「仓库落地」一键入口(克隆到本地目录)。
@@ -298,17 +296,23 @@ async function runInstallJob(job, ports) {
298
296
  if (pkg.private === true) {
299
297
  // 私有 monorepo 根:自动列出子包作为候选(把人工修复经验自动化),聚合包优先
300
298
  subpackageMode = true
299
+ // 批次 A-③(2026-09-27):根包 private = **没发布到 npm** → registry 通道必然 404,
300
+ // 而 git 通道会去 clone 整个仓库(真机:dsh-web 429 MB、ghproxy 下 0 B/s)。
301
+ // 对该候选禁用 git,把时间让给"展开子包 → 按包名直装"这条真正能成功的路。
302
+ job.privateRoot = true
303
+ job.gitChannelBlocked = true
304
+ noteChannel(job, '根包未发布到 npm(private: true),已跳过 git 克隆通道(避免白拉整个仓库)')
301
305
  // 2026-09-20 事故(用户点装 zhu1090093659/dsh-web,报"未发现子包"):该仓库根包确实
302
306
  // private: true,但 main/dev 各有 22 个子包目录。失败原因是当时市场索引源全挂、网络受限,
303
307
  // subpackageCandidates() 读不到列表 —— 旧代码只有"有没有子包"一个出口,把**没读到**
304
308
  // 报成了**不存在**,直接把用户带偏。教训:探测失败必须与确定性结论分开表达。
305
- let subs = await subpackageCandidates(job.repo, branch)
309
+ let subs = await probes.subpackageCandidates(job.repo, branch)
306
310
  if (subs.length === 0) {
307
311
  // 读不到时先换一条分支重试:meta 探测失败时 branch 恒为 main,而默认分支为 dev 的仓库
308
312
  // (本例 dsh-web 就是 dev 为默认分支)main 上的子包布局可能不同/为空;
309
313
  // 换分支几乎零成本,却能把"分支取错"这一类假失败挡在报错之前。
310
314
  const altBranch = branch === 'main' ? 'dev' : 'main'
311
- subs = await subpackageCandidates(job.repo, altBranch)
315
+ subs = await probes.subpackageCandidates(job.repo, altBranch)
312
316
  if (subs.length > 0) job.subpackageNote = `子包列表取自 ${altBranch} 分支(默认分支探测可能失败)`
313
317
  }
314
318
  if (subs.length === 0) {
@@ -330,12 +334,17 @@ async function runInstallJob(job, ports) {
330
334
  job.packageName = pkg.name
331
335
  }
332
336
  // 给了根包名但根包实际是 private 聚合仓库(如直接填 dsh-web-ui):
333
- // 与仓库模式同路径——直接展开子包(聚合包优先),跳过 git 装根包的无意义尝试
334
- if (!subpackageMode && job.repo && job.packageName !== null) {
335
- const rootPkg = await fetchRepoPackage(job.repo, 'main')
337
+ // 与仓库模式同路径——直接展开子包(聚合包优先),跳过 git 装根包的无意义尝试。
338
+ // 批次 C-⑪:候选来自索引 npmName 时跳过这一步(该装哪个包索引已给答案,再问 GitHub 就把"省掉探测"白省了)
339
+ if (!subpackageMode && job.repo && job.packageName !== null && job.npmNameHint === undefined) {
340
+ const rootPkg = await probes.fetchRepoPackage(job.repo, 'main')
336
341
  if (rootPkg !== null && rootPkg.private === true) {
337
342
  subpackageMode = true
338
- const subs = await subpackageCandidates(job.repo, 'main', readGithubAuth().token)
343
+ // 同上游分支:根包未发布 → 禁用 git 通道(批次 A-③)
344
+ job.privateRoot = true
345
+ job.gitChannelBlocked = true
346
+ noteChannel(job, '根包未发布到 npm(private: true),已跳过 git 克隆通道(避免白拉整个仓库)')
347
+ const subs = await probes.subpackageCandidates(job.repo, 'main', readGithubAuth().token)
339
348
  if (subs.length > 0) {
340
349
  candidates = [...subs.filter((name) => !candidates.includes(name)), ...candidates]
341
350
  job.subpackages = subs
@@ -345,6 +354,8 @@ async function runInstallJob(job, ports) {
345
354
  job.stage = 'installing'
346
355
  const patchPath = findPatchPath(ports)
347
356
  const profileDir = dirname(patchPath)
357
+ // 每次安装开始前:后台清掉上次留下的 `.trash-*` 降级目录(2026-09-26 加法;即发即忘、最多 20 个 / 单个 ≤1s,绝不阻塞本次安装)
358
+ try { void startTrashCleanup({ roots: trashScanRoots({ profileDir, extra: [getReposDir()] }), log: (message) => jobLog(job, message) }) } catch {}
348
359
  const taken = new Set(listEntries(ports).map((entry) => entry.rowId))
349
360
  const patch = await readPatchState(patchPath)
350
361
  for (const id of [...patch.inserts, ...patch.disables, ...patch.forced]) taken.add(id)
@@ -353,7 +364,7 @@ async function runInstallJob(job, ports) {
353
364
  let expanded = false
354
365
  // 可配置软件源:主→备依次尝试(默认 npmmirror → npmjs,可增删自定义/内网源)
355
366
  const registries = orderedRegistries(readSources())
356
- const deadline = Date.now() + 8 * 60 * 1000
367
+ const deadline = Date.now() + jobBudgetMs
357
368
  // 子包级进度(2026-09-20 真装实测:11 个子包的聚合仓库跑了 19 分钟,job.stage 一直停在
358
369
  // installing,面板只有一个不动的进度条)。candidateTotal/Index/Name 每轮开始时更新,
359
370
  // 由 installJobView 折算成 progress{index,total,name} 下发给面板。
@@ -372,33 +383,30 @@ async function runInstallJob(job, ports) {
372
383
  // ⚠️ 本行必须在 `const name` **之后**求值:旧代码把它写在 name 声明之前,`name === job.packageName`
373
384
  // 一被求值就命中 TDZ(ReferenceError: Cannot access 'name' before initialization)——
374
385
  // 私有聚合根(subpackageMode=true 且 packageName 非空)安装必失败,且报错文案完全指不到真正原因。
375
- const repoChannelAllowed = !subpackageMode || job.packageName === null || name === job.packageName
376
- const attempt = await tryCandidateChannels({ job, ch, name, profileDir, registries, repoChannelAllowed, budget, baseUrl: ports.baseUrl ?? null, expanded })
386
+ // 批次 A-③:再叠加 gitChannelBlocked —— 根包未发布(或首选候选来自索引)时不试 git(只 clone 整仓,纯白等)
387
+ const repoChannelAllowed = (!subpackageMode || job.packageName === null || name === job.packageName) && job.gitChannelBlocked !== true
388
+ // 懒惰展开的注入实现(只展开一次:expanded 由这里翻转,后续轮次传进去的就是 true)
389
+ const expandSubpackages = async () => {
390
+ if (expanded || !job.repo) return 0
391
+ expanded = true
392
+ // 批次 B-⑥:以**真实默认分支**打头(fetchSubpackageNames 内部再回退 main / master)
393
+ const extra = await probes.expandSubpackages({ repo: job.repo, branch: job.defaultBranch ?? 'main', auth: readGithubAuth().token, existing: candidates })
394
+ if (extra.length === 0) return 0
395
+ candidates = [...candidates, ...extra]
396
+ // 懒惰展开后候选变多:总数要跟着更新,否则面板会显示"第 9/1 个"
397
+ job.candidateTotal = candidates.length
398
+ if (!Array.isArray(job.subpackages)) job.subpackages = []
399
+ for (const e of extra) if (!job.subpackages.includes(e)) job.subpackages.push(e)
400
+ return extra.length
401
+ }
402
+ const attempt = await tryCandidateChannels({
403
+ job, ch, name, profileDir, registries, repoChannelAllowed, budget,
404
+ baseUrl: ports.baseUrl ?? null, expanded, deadline,
405
+ expand: (expanded || job.npmNameHint !== undefined) ? null : expandSubpackages,
406
+ })
377
407
  installedName = attempt.installedName
378
408
  lastError = attempt.lastError
379
409
  if (installedName !== null) break
380
- // 懒惰展开:registry 与 git 通道都失败时,自动发现仓库子包继续尝试(聚合包优先),
381
- // 覆盖"给了根包名但根包未发布"的场景——AI 兜底只处理真正无解的案例
382
- if (!expanded && job.repo) {
383
- expanded = true
384
- let subs = await fetchSubpackageNames(job.repo, 'main', readGithubAuth().token)
385
- if (subs.length === 0) subs = await fetchSubpackageNames(job.repo, 'master', readGithubAuth().token)
386
- if (subs.length > 0) {
387
- const extra = subs
388
- .slice()
389
- .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)))
390
- .map((sub) => sub.name)
391
- .filter((n) => !candidates.includes(n))
392
- .slice(0, 8)
393
- if (extra.length > 0) {
394
- candidates = [...candidates, ...extra]
395
- // 懒惰展开后候选变多:总数要跟着更新,否则面板会显示"第 9/1 个"
396
- job.candidateTotal = candidates.length
397
- if (!Array.isArray(job.subpackages)) job.subpackages = []
398
- for (const e of extra) if (!job.subpackages.includes(e)) job.subpackages.push(e)
399
- }
400
- }
401
- }
402
410
  }
403
411
  if (installedName === null) {
404
412
  if (job.diagnosis == null) job.diagnosis = classifyInstallFailure(lastError?.message) // 分类提示兜底(加法)
@@ -407,12 +415,12 @@ async function runInstallJob(job, ports) {
407
415
  job.aiPending = { lastError: lastError?.message ?? null }
408
416
  // 等授权期间要能展示"为什么卡住、还能等多久":请求时间 + 超时上限 + 最后一个确定性错误
409
417
  job.aiPendingSince = Date.now()
410
- job.aiConsentTimeoutMs = AI_CONSENT_TIMEOUT_MS
418
+ job.aiConsentTimeoutMs = aiConsentTimeoutMs
411
419
  job.lastError = lastError?.message ?? null
412
420
  job.aiWait = new Promise((resolve) => { job.aiPending.resolver = resolve })
413
421
  const decision = await Promise.race([
414
422
  job.aiWait,
415
- new Promise((resolve) => setTimeout(() => resolve({ approved: false, timeout: true }), AI_CONSENT_TIMEOUT_MS)),
423
+ new Promise((resolve) => setTimeout(() => resolve({ approved: false, timeout: true }), aiConsentTimeoutMs)),
416
424
  ])
417
425
  job.aiPending = null
418
426
  job.aiWait = null
@@ -424,7 +432,7 @@ async function runInstallJob(job, ports) {
424
432
  // 不能让用户面对"面板说失败、磁盘上却留了半个包和 _tmp_ 残留"的糊涂账。
425
433
  const leftovers = cleanupAttemptedCandidates(profileDir, candidates)
426
434
  job.leftovers = leftovers
427
- job.error = aiConsentFailureText(decision, leftovers)
435
+ job.error = aiConsentFailureText(decision, leftovers, aiConsentTimeoutMs)
428
436
  }
429
437
  return
430
438
  }
@@ -588,4 +596,4 @@ async function readExtraBundleRows(profileDir) {
588
596
  } catch {}
589
597
  return rows
590
598
  }
591
- export { runInstallJob, pnpmRemove, readExtraBundleRows, cleanupAttemptedCandidates, aiConsentFailureText, tryCandidateChannels, channelImpls, RELEASE_CHANNEL_BUDGET }
599
+ 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 }