@noob-stupid/dsh-plugin-console 0.5.23 → 0.5.25

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.
@@ -3,6 +3,8 @@
3
3
  // 而"git 通道怎么用预算"本来就是一块自洽的策略,独立成模块后 install-job 只留一行调用。
4
4
  // 抽出只搬移 + 收窄形参,**语义一字未改**(原实现见 commit 8deb112 之前的 tryCandidateChannels 尾段)。
5
5
 
6
+ import { existsSync, readFileSync } from 'node:fs'
7
+ import { join } from 'node:path'
6
8
  import { gitCloneUrls } from './sources.js'
7
9
 
8
10
  /** git 通道的**独立预算**(2026-09-27 加法):默认 120 秒,可用环境变量
@@ -38,6 +40,19 @@ function formatBudgetMs(ms) {
38
40
  return n >= 10000 ? `${Math.round(n / 1000)} 秒` : `${Math.round(n)} 毫秒`
39
41
  }
40
42
 
43
+ /** 读回 node_modules 里那个包**真实的** package.json.name(读不到返回 null)。
44
+ * 用途见 tryGitChannel 里的核实:git 规格装出来的是**仓库根包**,名字未必等于候选包名。 */
45
+ function installedPackageName(profileDir, name) {
46
+ try {
47
+ const p = join(profileDir, 'node_modules', name, 'package.json')
48
+ if (!existsSync(p)) return null
49
+ const pkg = JSON.parse(readFileSync(p, 'utf8'))
50
+ return typeof pkg?.name === 'string' && pkg.name !== '' ? pkg.name : null
51
+ } catch {
52
+ return null
53
+ }
54
+ }
55
+
41
56
  /** git 通道(守卫③):只对**请求里那个仓库**试 git 规格,且只在"同一作业第一次"试。
42
57
  * 返回 { installedName, lastError }(与 tryCandidateChannels 的返回同形,便于原地替换)。
43
58
  * 预算规则(2026-09-27):
@@ -45,7 +60,7 @@ function formatBudgetMs(ms) {
45
60
  * · 单个规格的 pnpm 超时 = min(60 秒, 剩余);
46
61
  * · 预算耗尽就停手,并在 job 上留一条面板可见的原因(不静默跳过)。
47
62
  * 依赖注入:ch(通道实现)与 budget 由调用方传入 —— 与 install-job 的注入缝同一套语义。 */
48
- async function tryGitChannel({ job, ch, name, profileDir, repoChannelAllowed, expanded = false, budget = {}, deadline = null }) {
63
+ async function tryGitChannel({ job, ch, name, profileDir, repoChannelAllowed, expanded = false, budget = {}, deadline = null, verifyName = null }) {
49
64
  let installedName = null
50
65
  let lastError = null
51
66
  // gitChannelBlocked:作业层面已判定"这个仓库不该走 git"(根包未发布 / 首选候选来自市场索引)。
@@ -76,6 +91,22 @@ async function tryGitChannel({ job, ch, name, profileDir, repoChannelAllowed, ex
76
91
  try {
77
92
  // eslint-disable-next-line no-await-in-loop
78
93
  await ch.pnpmInstall(profileDir, spec, undefined, Math.min(GIT_SPEC_TIMEOUT_MS, remain))
94
+ // 装完**读回真实包名核实**(2026-09-27 批次 D-① 一并改掉的缺陷):git 规格装的是**仓库根包**,
95
+ // 候选是子包时名字根本不同(真机 dsh-routing-suite:根包 private、名字
96
+ // @dsh-external/dsh-super-injector,而候选是 dsh-router-standard)。旧代码无条件把 installedName
97
+ // 记成候选名 → 后面 appendInsert 写一条指向不存在模块的补丁行 → 下次启动服务直接崩。
98
+ // 读不到(null)时按"无法核实"放行(不误杀;桩实现/别名安装都可能读不到),但要留下备注。
99
+ const realName = typeof verifyName === 'function' ? verifyName(profileDir, name) : installedPackageName(profileDir, name)
100
+ if (realName === null) {
101
+ noteChannel(job, `git 规格装完后读不到 node_modules/${name}/package.json,无法核实包名(按成功处理,装后校验会再查)`)
102
+ installedName = name
103
+ break
104
+ }
105
+ if (realName !== name) {
106
+ lastError = new Error(`git 规格装成的是仓库根包 ${realName},不是候选 ${name}(git 只能装根包;子包请走 registry/release/源码装配通道)`)
107
+ noteChannel(job, lastError.message)
108
+ continue
109
+ }
79
110
  installedName = name
80
111
  break
81
112
  } catch (gitError) {
@@ -88,4 +119,4 @@ async function tryGitChannel({ job, ch, name, profileDir, repoChannelAllowed, ex
88
119
  return { installedName, lastError }
89
120
  }
90
121
 
91
- export { GIT_CHANNEL_BUDGET_MS, GIT_SPEC_TIMEOUT_MS, GIT_MIN_ATTEMPT_MS, gitChannelBudgetMs, noteChannel, formatBudgetMs, tryGitChannel }
122
+ export { GIT_CHANNEL_BUDGET_MS, GIT_SPEC_TIMEOUT_MS, GIT_MIN_ATTEMPT_MS, gitChannelBudgetMs, noteChannel, formatBudgetMs, installedPackageName, tryGitChannel }
@@ -9,6 +9,9 @@
9
9
  // `allowBuilds` / `ignored build scripts` 这一类**只提示,绝不自动写**用户的 allowBuilds / 构建白名单。
10
10
  // allowBuilds 决定哪些依赖可以执行安装期脚本(postinstall),自动放行 = 替用户授权任意代码执行;
11
11
  // 这是必须由人做的安全决策,所以本模块对它只产出文案,且 retry 恒为 null。
12
+ // 2026-09-27 追加:控制台现在提供一个**显式**动作(`allow-builds`,见 domain/allow-builds.js)——
13
+ // 只有用户**自己点**「允许这些构建脚本」才会写盘(改前备份 + 写后读回核实 + 只动 allowBuilds 块)。
14
+ // 本模块仍然一行都不写盘:这里的 hint 只是把用户引到那个按钮上。
12
15
  //
13
16
  // 分类依据是**失败文本**(pnpm 的 stderr / ExecError message),同一个 kind 的文案只维护一份。
14
17
 
@@ -49,6 +52,21 @@ const DIAGNOSIS_RULES = [
49
52
  hint: 'registry 的 minimumReleaseAge(供应链安全间隔)拦下了这个版本:稍后重试即可(版本"过龄"后自动放行);'
50
53
  + '确有紧急需要时由你自行放宽(pnpm 侧 --config.minimum-release-age=0)——本控制台不替你放宽安全间隔。',
51
54
  },
55
+ {
56
+ // 2026-09-27 加法:pnpm 11 的**构建脚本未获批准**硬错误(`strictDepBuilds` 默认为 true)。
57
+ // 与下面泛化的 allowBuilds 分开的理由:这一条要能**点名**是哪些包、并且说清一件事 ——
58
+ // 报这个错的时候安装其实已经完成(清单与 lock 都就位),它既不执行任何脚本、也不影响加载。
59
+ // 真机证据(2026-09-27 官方桌面端实例,pnpm 11.7.0):
60
+ // [ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: cloudflared@0.7.3, cpu-features@0.0.10, ssh2@1.17.0
61
+ // → `pnpm add link:…` exitCode=1,而清单/lock/node_modules 都已经是目标状态。
62
+ // 出路只有一条:用户**自己**点「允许这些构建脚本」(显式动作 allow-builds,写盘前先备份)。
63
+ kind: 'ignored-builds',
64
+ re: /ERR_PNPM_IGNORED_BUILDS|Ignored build scripts|ignored build scripts/iu,
65
+ retry: null,
66
+ withPackages: true,
67
+ hint: '该依赖带安装脚本(postinstall 一类)且未获批准:清单与 lock 通常已经就位,这一步不执行任何脚本、也不影响加载;'
68
+ + '如需放行,请在动作框里点「允许这些构建脚本」(只在你点击时才写入最小放行项),本控制台绝不自动放行。',
69
+ },
52
70
  {
53
71
  kind: 'allowBuilds',
54
72
  re: /allowBuilds|allow-builds|ignored build scripts|Ignored build scripts|approve-builds|ERR_PNPM_IGNORED_BUILDS/iu,
@@ -117,6 +135,29 @@ function missingPackagesFrom(text) {
117
135
  return found
118
136
  }
119
137
 
138
+ /** 从**构建脚本被忽略**的报错文本里点名依赖(纯函数,2026-09-27 加法)。
139
+ *
140
+ * pnpm 的两种文案形态:
141
+ * `[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: cloudflared@0.7.3, cpu-features@0.0.10, ssh2@1.17.0`
142
+ * `Ignored build scripts: sharp, esbuild`
143
+ * 只认冒号后面那一行;包名去掉尾部 `@版本`(scoped 包名开头的 `@` 是作用域,不能用第一个 `@` 切)。
144
+ * 认不出来就返回空数组(调用方据此保留原样文案,绝不编造包名 —— 这条与 missingPackagesFrom 同一规矩)。 */
145
+ function ignoredBuildsFrom(text) {
146
+ const raw = typeof text === 'string' ? text : String(text ?? '')
147
+ const line = raw.match(/Ignored build scripts:\s*([^\n\r]+)/iu)
148
+ if (line === null) return []
149
+ const found = []
150
+ for (const part of line[1].split(',')) {
151
+ const spec = part.trim().replace(/[.。;;,]+$/u, '')
152
+ if (spec === '') continue
153
+ const at = spec.lastIndexOf('@')
154
+ const name = at > 0 ? spec.slice(0, at) : spec
155
+ if (!/^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/iu.test(name)) continue
156
+ if (!found.includes(name)) found.push(name)
157
+ }
158
+ return found
159
+ }
160
+
120
161
  /** 按分类取短句 hint(纯函数;2026-09-27 加法):**文案只有一份**——
121
162
  * lockfile-health.js 的体检结论也走这里,避免"同一分类两处措辞"。认不出的分类返回 null。 */
122
163
  function hintForKind(kind, packages = []) {
@@ -131,7 +172,11 @@ function hintForKind(kind, packages = []) {
131
172
  * packages(2026-09-27 加法):文本里能点名的依赖包名(认不出就是空数组)——只用于展示,不参与任何判定。 */
132
173
  function classifyInstallFailure(stderrOrMessage) {
133
174
  const text = typeof stderrOrMessage === 'string' ? stderrOrMessage : String(stderrOrMessage ?? '')
134
- const packages = missingPackagesFrom(text)
175
+ // 构建脚本类报错的"涉及依赖"在 `Ignored build scripts:` 这一行里(不在 URL 里),单独认一次;
176
+ // 其余分类仍走 URL 还原(与之前一模一样)。
177
+ const urlish = missingPackagesFrom(text)
178
+ const buildish = ignoredBuildsFrom(text)
179
+ const packages = buildish.length > 0 ? buildish : urlish
135
180
  for (const rule of DIAGNOSIS_RULES) {
136
181
  if (!rule.re.test(text)) continue
137
182
  return { kind: rule.kind, hint: hintForKind(rule.kind, packages), retry: rule.retry, packages }
@@ -180,4 +225,4 @@ async function retryPnpmOnce({ ch, profileDir, name, registries, pnpmError, job,
180
225
  }
181
226
  }
182
227
 
183
- export { classifyInstallFailure, longerTimeoutFor, diagnosisText, retryPnpmOnce, missingPackagesFrom, hintForKind, DIAGNOSIS_RULES }
228
+ export { classifyInstallFailure, longerTimeoutFor, diagnosisText, retryPnpmOnce, missingPackagesFrom, ignoredBuildsFrom, hintForKind, DIAGNOSIS_RULES }
@@ -13,8 +13,10 @@ import { GIT_CHANNEL_BUDGET_MS, GIT_MIN_ATTEMPT_MS, GIT_SPEC_TIMEOUT_MS, gitChan
13
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'
14
14
  import { classifyInstallFailure, retryPnpmOnce } from './install-diagnose.js'
15
15
  import { jobLog } from './jobs.js'
16
- import { verifyInstalledEntry } from './install-verify.js'
17
- import { expandSubpackageCandidates, fetchRepoPackage, fetchRepoPackageEx, fetchSubpackageNames, npmNameHintForRepo, packageProbeErrorText, subpackageCandidates } from './market.js'
16
+ import { gatePrivateRoot } from './repo-size.js'
17
+ import { tryPresetSourceChannel } from './preset-source.js'
18
+ import { alreadyInstalledTarget, verifyInstalledEntry } from './install-verify.js'
19
+ import { applyNpmNameHint, expandSubpackageCandidates, fetchRepoPackage, fetchRepoPackageEx, fetchSubpackageNames, npmNameHintForRepo, packageProbeErrorText, subpackageCandidates } from './market.js'
18
20
  import { declareProfileDependency } from './manifest.js'
19
21
  import { appendInsert, readPatchState, syncNameFromNote } from './patch.js'
20
22
  import { deriveEntryId, listEntries } from './runtime.js'
@@ -67,19 +69,15 @@ function channelImpls(ports) {
67
69
  * ③ git 通道:**保留 repoChannelAllowed**(它只 clone `job.repo`,候选是子包时 clone 根仓库装不出子包,
68
70
  * 属无意义尝试)**并保留 !expanded**(同一作业里对同一个 job.repo 反复 clone 纯属浪费时间,
69
71
  * 级联顺序也不该被破坏)。 */
70
- async function tryCandidateChannels({ job, ch, name, profileDir, registries, repoChannelAllowed, budget, baseUrl = null, expanded = false, deadline = null, expand = null }) {
72
+ async function tryCandidateChannels({ job, ch, name, profileDir, registries, repoChannelAllowed, budget, baseUrl = null, expanded = false, deadline = null, expand = null, sourceDeps = {} }) {
71
73
  let installedName = null
72
74
  let lastError = null
73
75
  // 加法优化:包已在 node_modules 且名字匹配时,不再重复安装/触发 EPERM,直接进入启用流程
74
- const existingTarget = join(profileDir, 'node_modules', name, 'package.json')
75
- if (existsSync(existingTarget)) {
76
- try {
77
- const existingPkg = JSON.parse(readFileSync(existingTarget, 'utf8'))
78
- if (existingPkg && existingPkg.name === name && job.update !== true) {
79
- job.curlNote = `已检测到本地已安装 ${name}@${existingPkg.version ?? '?'},跳过重复下载`
80
- return { installedName: name, lastError: null }
81
- }
82
- } catch {}
76
+ // (2026-09-27 搬进 install-verify.js#alreadyInstalledTarget:本文件贴着 600 行架构硬顶)
77
+ const already = alreadyInstalledTarget(profileDir, name, job)
78
+ if (already !== null) {
79
+ job.curlNote = already
80
+ return { installedName: name, lastError: null }
83
81
  }
84
82
  // 通道 0:并行竞速(pnpm 与 curl 同时启动,先成功者生效;失败方 abort,不影响后续串行通道)——守卫①
85
83
  {
@@ -165,6 +163,12 @@ async function tryCandidateChannels({ job, ch, name, profileDir, registries, rep
165
163
  noteChannel(job, `自动展开仓库子包失败(继续走后续通道):${error instanceof Error ? error.message : String(error)}`)
166
164
  }
167
165
  }
166
+ if (installedName === null) {
167
+ // 通道 n+1c(批次 D-③,2026-09-27 加法):**预设型子包的源码装配** —— 真机 dsh-routing-suite 的第三件
168
+ // dsh-router-standard 只存在于仓库源码里(npm 双 404、release 无资产),按包名的通道全试完才轮到它。
169
+ const preset = await tryPresetSourceChannel({ job, name, source: job.source, deps: sourceDeps })
170
+ if (preset.installed === true) { job.presetDone = true; job.presetNote = preset.note; installedName = name } else if (preset.error !== null && preset.error !== undefined) lastError = preset.error
171
+ }
168
172
  if (installedName === null) {
169
173
  // 通道 n+2:git 通道(GitHub 走加速代理+直连;Gitee 走对应平台)——守卫③
170
174
  // 2026-09-27:实现搬进 domain/git-channel.js(独立预算 + 不得超过作业剩余预算;
@@ -213,6 +217,8 @@ async function runInstallJob(job, ports, deps = {}) {
213
217
  probes.expandSubpackages = (opts = {}) => expandSubpackageCandidates({ ...opts, deps: { fetchSubpackageNames: probes.fetchSubpackageNames } })
214
218
  }
215
219
  const jobBudgetMs = Number.isFinite(deps.jobBudgetMs) && deps.jobBudgetMs > 0 ? deps.jobBudgetMs : 8 * 60 * 1000
220
+ // 源码通道(体积门禁 + 稀疏取源码 + 预设装配)的注入缝:与 marketProbes 同一风格,生产调用方不传
221
+ const sourceDeps = deps.sourceDeps ?? {}
216
222
  const aiConsentTimeoutMs = Number.isFinite(deps.aiConsentTimeoutMs) && deps.aiConsentTimeoutMs > 0 ? deps.aiConsentTimeoutMs : AI_CONSENT_TIMEOUT_MS
217
223
  /** 「安装即声明」的收尾(三个调用点共用一个出口):清单写回结果 → job 上的可见字段。
218
224
  * depNote / suggestedAction 也在这里落地:面板必须能看见"清单里**实际**写成了什么",以及"没写成时点哪个按钮"。 */
@@ -237,16 +243,8 @@ async function runInstallJob(job, ports, deps = {}) {
237
243
  // ★ 市场索引的首选候选(批次 C-⑪):索引带 npmName 时直接按包名走 registry,连"读根 package.json →
238
244
  // 展开子包"这一轮都省掉(本机 api.github.com 不可达时那条路根本走不通)。老索引无该字段 → 行为不变。
239
245
  if (candidates.length === 0 && job.repo) {
240
- const npmHint = probes.npmNameHint(job.repo)
241
- if (npmHint !== null) {
242
- candidates = [npmHint]
243
- job.npmNameHint = npmHint
244
- job.packageName = npmHint
245
- // git 通道只 clone 整个仓库(真机 dsh-web 429 MB / ghproxy 0 B/s),而且装不出这个子包 → 禁掉
246
- job.gitChannelBlocked = true
247
- noteChannel(job, `市场索引给出首选候选:${npmHint}(按包名直装,省掉"探测根包 + 展开子包")`)
248
- noteChannel(job, '已跳过 git 克隆通道:git 只 clone 整个仓库、装不出这个包(真机 dsh-web = 429 MB)')
249
- }
246
+ const hinted = applyNpmNameHint(job, probes.npmNameHint(job.repo), noteChannel)
247
+ if (hinted !== null) candidates = hinted
250
248
  }
251
249
  if (candidates.length === 0) {
252
250
  // 套装判定(0.5.19 改错,唯一入口在 suite.js 的 shouldRunSuiteInstall):**先看根包是否已发布**,
@@ -299,12 +297,10 @@ async function runInstallJob(job, ports, deps = {}) {
299
297
  if (pkg.private === true) {
300
298
  // 私有 monorepo 根:自动列出子包作为候选(把人工修复经验自动化),聚合包优先
301
299
  subpackageMode = true
302
- // 批次 A-③(2026-09-27):根包 private = **没发布到 npm** → registry 通道必然 404,
303
- // 而 git 通道会去 clone 整个仓库(真机:dsh-web 429 MB、ghproxy 下 0 B/s)。
304
- // 对该候选禁用 git,把时间让给"展开子包 → 按包名直装"这条真正能成功的路。
305
- job.privateRoot = true
306
- job.gitChannelBlocked = true
307
- noteChannel(job, '根包未发布到 npm(private: true),已跳过 git 克隆通道(避免白拉整个仓库)')
300
+ // 批次 D-①(2026-09-27 **改错**):根包 private = 没发布到 npm → registry 通道必然 404。
301
+ // 源码通道能不能走改由**仓库体积**判定(批次 A-③ 的一刀切把 dsh-routing-suite 的 preset
302
+ // 子包一起禁掉了,而它只有 1334 KB);note 由 gatePrivateRoot 写入(巨仓/尺寸未知都写清原因与出路)。
303
+ await gatePrivateRoot(job, noteChannel, sourceDeps)
308
304
  // 2026-09-20 事故(用户点装 zhu1090093659/dsh-web,报"未发现子包"):该仓库根包确实
309
305
  // private: true,但 main/dev 各有 22 个子包目录。失败原因是当时市场索引源全挂、网络受限,
310
306
  // subpackageCandidates() 读不到列表 —— 旧代码只有"有没有子包"一个出口,把**没读到**
@@ -343,10 +339,8 @@ async function runInstallJob(job, ports, deps = {}) {
343
339
  const rootPkg = await probes.fetchRepoPackage(job.repo, 'main')
344
340
  if (rootPkg !== null && rootPkg.private === true) {
345
341
  subpackageMode = true
346
- // 同上游分支:根包未发布 → 禁用 git 通道(批次 A-③)
347
- job.privateRoot = true
348
- job.gitChannelBlocked = true
349
- noteChannel(job, '根包未发布到 npm(private: true),已跳过 git 克隆通道(避免白拉整个仓库)')
342
+ // 上游分支同一道判据(批次 D-①):按仓库体积决定源码通道能不能走
343
+ await gatePrivateRoot(job, noteChannel, sourceDeps)
350
344
  const subs = await probes.subpackageCandidates(job.repo, 'main', readGithubAuth().token)
351
345
  if (subs.length > 0) {
352
346
  candidates = [...subs.filter((name) => !candidates.includes(name)), ...candidates]
@@ -404,13 +398,17 @@ async function runInstallJob(job, ports, deps = {}) {
404
398
  }
405
399
  const attempt = await tryCandidateChannels({
406
400
  job, ch, name, profileDir, registries, repoChannelAllowed, budget,
407
- baseUrl: ports.baseUrl ?? null, expanded, deadline,
401
+ baseUrl: ports.baseUrl ?? null, expanded, deadline, sourceDeps,
408
402
  expand: (expanded || job.npmNameHint !== undefined) ? null : expandSubpackages,
409
403
  })
410
404
  installedName = attempt.installedName
411
405
  lastError = attempt.lastError
412
406
  if (installedName !== null) break
413
407
  }
408
+ // 预设型子包走的是"取源码 → 装配到 ~/.dsh/.agent-presets":**没有 node_modules 包、也不该写补丁行/
409
+ // 声明依赖**(否则补丁行指向不存在的模块 → 启动崩溃,2026-09-06 事故同族)。candidateDone/packageName
410
+ // 与普通成功安装对齐,面板显示的才是真正装上的那一件。
411
+ if (job.presetDone === true) { job.status = 'done'; job.stage = 'done'; job.candidateDone = true; job.packageName = job.candidateName ?? job.packageName; return }
414
412
  if (installedName === null) {
415
413
  if (job.diagnosis == null) job.diagnosis = classifyInstallFailure(lastError?.message) // 分类提示兜底(加法)
416
414
  // 本地 AI 兜底会调用模型 API、产生费用:挂起等待用户明确同意后再执行
@@ -218,4 +218,21 @@ function verifyTarballDigest(tarballPath, dist = null) {
218
218
  }
219
219
  }
220
220
 
221
- export { verifyInstalledEntry, verifyTarballDigest, entryFieldOf, MIRROR_HINT }
221
+ /** 「目标包已经在 node_modules 里、且名字就是它」→ 返回面板要显示的短句;否则返回 null。
222
+ * 2026-09-27 从 install-job.js **原样搬出**(那边贴着 600 行架构硬顶):这套判断本来就是"装前只读校验",
223
+ * 与 verifyInstalledEntry 同族,只是发生在装之前。
224
+ * 语义一个字没改:只有 package.json 能解析、name 完全相等、且不是"更新"请求时才算命中
225
+ * (命中就不再重复下载,直接进入启用流程 —— 也避免再触发一次 Windows 上的 EPERM 原子替换)。 */
226
+ function alreadyInstalledTarget(profileDir, name, job = null) {
227
+ const target = join(profileDir, 'node_modules', name, 'package.json')
228
+ if (!existsSync(target)) return null
229
+ try {
230
+ const pkg = JSON.parse(readFileSync(target, 'utf8'))
231
+ if (pkg && pkg.name === name && job?.update !== true) {
232
+ return `已检测到本地已安装 ${name}@${pkg.version ?? '?'},跳过重复下载`
233
+ }
234
+ } catch {}
235
+ return null
236
+ }
237
+
238
+ export { verifyInstalledEntry, verifyTarballDigest, entryFieldOf, alreadyInstalledTarget, MIRROR_HINT }
@@ -544,7 +544,8 @@ function installJobView(job) {
544
544
  skillDir: job.skillDir ?? null,
545
545
  skillNote: job.skillNote ?? null,
546
546
  suiteReport: job.suiteReport ?? null,
547
- suiteNote: job.suiteNote ?? null,
547
+ // 预设装配结果(2026-09-27 批次 D-③ 加法):落盘路径 + "新建会话时选择" + 每个预设的合并/备份明细
548
+ suiteNote: job.suiteNote ?? null, presetNote: job.presetNote ?? null, presetInstalled: job.presetInstalled ?? null, presetSource: job.presetSource ?? null, repoSize: job.repoSize ?? null,
548
549
  hint: job.hint ?? null,
549
550
  // 子包级/套装级进度(2026-09-20 真装实测缺口:11 个子包的聚合仓库跑了 19 分钟,面板只显示
550
551
  // "安装中",用户不知道在装第几个、还剩几个)。子包通道来自 install-job.js 候选循环维护的
@@ -564,8 +565,7 @@ function installJobView(job) {
564
565
  }
565
566
  }
566
567
 
567
- /** 从 GitHub Release 下载预构建 tgz 装配到 node_modules/<pkgName> 已搬到 domain/release-source.js
568
- * (与 #3 的 release 源解析同族);本模块不再重复导出它(全仓库无导入点,导出面由 lib/index.js 决定)。 */
568
+ /** 「从 GitHub Release 下载预构建 tgz 装配」已搬到 domain/release-source.js(与 #3 的 release 源解析同族)。 */
569
569
 
570
570
  async function syncAggregateSubpackageVersions(profileDir, packageName, registries) {
571
571
  const pkgPath = join(profileDir, 'node_modules', packageName, 'package.json')
@@ -428,6 +428,23 @@ function npmNameHintForRepo(repo, deps = {}) {
428
428
  }
429
429
  }
430
430
 
431
+ /** 市场索引首选候选落地(批次 C-⑪,2026-09-27;批次 D-③ 从 install-job 搬进 market.js,
432
+ * 那边贴着 600 行架构硬顶)。索引带 npmName 时直接按包名走 registry,连"读根 package.json →
433
+ * 展开子包"这一轮都省掉(本机 api.github.com 不可达时那条路根本走不通);老索引无该字段 → null。
434
+ * 副作用与搬移前**一字不差**:写 job.npmNameHint / job.packageName / job.gitChannelBlocked + 两条备注。 */
435
+ function applyNpmNameHint(job, hint, note = null) {
436
+ if (typeof hint !== 'string' || hint === '') return null
437
+ job.npmNameHint = hint
438
+ job.packageName = hint
439
+ // git 通道只 clone 整个仓库(真机 dsh-web 429 MB / ghproxy 0 B/s),而且装不出这个子包 → 禁掉
440
+ job.gitChannelBlocked = true
441
+ if (typeof note === 'function') {
442
+ note(job, `市场索引给出首选候选:${hint}(按包名直装,省掉"探测根包 + 展开子包")`)
443
+ note(job, '已跳过 git 克隆通道:git 只 clone 整个仓库、装不出这个包(真机 dsh-web = 429 MB)')
444
+ }
445
+ return [hint]
446
+ }
447
+
431
448
  /** 懒惰展开(批次 A-③ 的实现;2026-09-27 从 install-job.js 搬进 market.js —— 那边触到 600 行硬顶):
432
449
  * 读仓库子包列表 → **聚合包优先**(名字以 all 结尾/-all- 形式)→ 去掉已有候选 → 限量返回。
433
450
  * 分支由调用方给(install-job 传 meta.default_branch),fetchSubpackageNames 自己会回退 main/master。 */
@@ -472,4 +489,4 @@ async function subpackageCandidates(repo, branch, auth) {
472
489
 
473
490
  const ENRICH_CACHE_TTL = 24 * 60 * 60 * 1000
474
491
 
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 }
492
+ export { readEnrichCache, writeEnrichCache, enrichItemOne, enrichItems, normalizePlatformItems, githubRepoInfo, fetchRepoPackage, fetchRepoPackageEx, packageProbeErrorText, searchNpmPackages, searchSubpackageItems, parseRepoFromUrl, hasDirectNameHit, summarizeReadme, readPluginDetails, fetchSubpackageNames, fetchSubpackageNamesOnBranch, subpackageBranchOrder, subpackageCandidates, expandSubpackageCandidates, npmNameHintForRepo, applyNpmNameHint, ENRICH_CACHE_TTL }
@@ -12,7 +12,16 @@
12
12
  // · pin-dependency:把清单里的该包钉成 `link:<DSH_HOME>/plugin-src/<包名>`,再跑一次
13
13
  // `pnpm add <link:…>` 把 lock / node_modules 对齐(argv 由 infra/exec.js#pnpmAddArgs 产出);
14
14
  // · reconcile-lock:只重写 pnpm-lock.yaml,复用 lockfile-health 的 repair(argv 由 repairArgsFor
15
- // 唯一产出,**不带**任何绕过供应链闸的开关)。
15
+ // 唯一产出,**不带**任何绕过供应链闸的开关);
16
+ // · allow-builds(2026-09-27 加法,选项 2 的核心):在 profile 的 pnpm-workspace.yaml 里
17
+ // **补上具体包名**的构建放行项(domain/allow-builds.js:改前备份 + 写后读回核实 + 只动那几行)。
18
+ // 它**不跑任何命令**,更不下载/执行脚本 —— 只影响"pnpm 下次安装是否还会因未批准而报错"。
19
+ // 只有用户自己点「允许这些构建脚本」才会走到这里(绝不自动放行)。
20
+ //
21
+ // 等待预算(2026-09-27 真机实测后的改错):放行之后**第一次**安装会真的执行那些脚本
22
+ // (cloudflared 下二进制、cpu-features/ssh2 走 node-gyp 原生编译;本机实测 18m59.6s 才结束,exitCode=0)。
23
+ // 原先这里固定 180s,会把一次**成功**的安装杀成"失败"。所以:profile 处于"已放行但还没构建过"时,
24
+ // 改用更长的预算(ACTION_BUILD_TIMEOUT_MS),其余情况**保持 180s 不变**。
16
25
  //
17
26
  // 供应链闸的一次性放宽(写清代价,绝不静默):pnpm 11 的 `minimumReleaseAge` 默认 24h,
18
27
  // 本控制台自己刚发的版本 (<24h) 会让**任何** lockfile 校验失败 —— 连"把本地 link: 记进 lock"都做不成。
@@ -23,7 +32,9 @@
23
32
  // 分层:L1 domain —— 不认识 cordis ctx,IO/执行器一律可注入(便于离线单测)。
24
33
  import { existsSync, readFileSync } from 'node:fs'
25
34
  import { join } from 'node:path'
26
- import { NPM_NAME_RE } from './dep-source.js'
35
+ import { applyAllowBuilds, pendingApprovedBuilds, readWorkspaceApprovals } from './allow-builds.js'
36
+ import { NPM_NAME_RE, suggestedAllowBuildsAction } from './dep-source.js'
37
+ import { classifyInstallFailure, ignoredBuildsFrom } from './install-diagnose.js'
27
38
  import { runLockfileRepair } from './lockfile-health.js'
28
39
  import { pinProfileDependency, specOf } from './manifest.js'
29
40
  import { lockVersion } from './selfupdate.js'
@@ -31,10 +42,16 @@ import { buildPnpmEnv, pnpmAddArgs, runPnpmWithFallback, unknownPnpmOption } fro
31
42
 
32
43
  const DEFAULT_REGISTRY = 'https://registry.npmmirror.com'
33
44
  /** 动作白名单(客户端只能给这些 kind;执行分支与 argv 全在服务端)。 */
34
- const ACTION_KINDS = ['pin-dependency', 'reconcile-lock']
45
+ const ACTION_KINDS = ['pin-dependency', 'reconcile-lock', 'allow-builds']
35
46
  /** 一旦出现这些键就整条拒绝:本接口不存在"传命令"的位置。 */
36
47
  const COMMAND_KEYS = ['command', 'cmd', 'argv', 'args', 'exec', 'execute', 'shell', 'script', 'run', 'spawn', 'bin', 'shellCommand']
37
48
  const OUTPUT_LIMIT = 4000
49
+ /** pin 动作里 pnpm 那一步的等待上限(默认不变 = 180s)。 */
50
+ const ACTION_ADD_TIMEOUT_MS = 180000
51
+ /** 已放行但还没构建过时的等待上限(30 分钟):真机构建实测 18m59.6s,180s 会把它杀成失败。 */
52
+ const ACTION_BUILD_TIMEOUT_MS = 1800000
53
+ /** A-② 里用户点名的 note 文案(**逐字**,配套断言钉死)。 */
54
+ const IGNORED_BUILDS_REACHED_NOTE = '依赖已钉住(清单+lock 已就位);pnpm 因构建脚本未获批准而报错 —— 这不会执行任何脚本,也不影响加载;如需放行请点「允许这些构建脚本」'
38
55
 
39
56
  /** 校验并翻译成结构化请求。返回 { ok, error, status, action, packageName, version, profile }。 */
40
57
  function parseActionRequest(body) {
@@ -45,7 +62,7 @@ function parseActionRequest(body) {
45
62
  }
46
63
  }
47
64
  const action = typeof raw.action === 'string' ? raw.action.trim() : ''
48
- if (action === '') return { ok: false, status: 400, error: '缺少 action(只允许 pin-dependency / reconcile-lock)' }
65
+ if (action === '') return { ok: false, status: 400, error: `缺少 action(只允许 ${ACTION_KINDS.join(' / ')})` }
49
66
  if (!ACTION_KINDS.includes(action)) {
50
67
  return { ok: false, status: 400, error: `未知动作 ${action}(只允许 ${ACTION_KINDS.join(' / ')})` }
51
68
  }
@@ -54,6 +71,10 @@ function parseActionRequest(body) {
54
71
  if (packageName === '') return { ok: false, status: 400, error: 'pin-dependency 需要 packageName' }
55
72
  if (packageName.length > 214 || !NPM_NAME_RE.test(packageName)) return { ok: false, status: 400, error: 'packageName 不是合法的 npm 包名' }
56
73
  }
74
+ // allow-builds 的 packageName 可省略(缺省 = 放行当前被忽略的全部);给了就必须是合法 npm 名。
75
+ if (action === 'allow-builds' && packageName !== '' && (packageName.length > 214 || !NPM_NAME_RE.test(packageName))) {
76
+ return { ok: false, status: 400, error: 'packageName 不是合法的 npm 包名' }
77
+ }
57
78
  const version = typeof raw.version === 'string' && raw.version.length <= 120 ? raw.version : null
58
79
  const profile = typeof raw.profile === 'string' && raw.profile.length <= 64 ? raw.profile : null
59
80
  return { ok: true, error: null, status: 200, action, packageName: packageName === '' ? null : packageName, version, profile }
@@ -146,6 +167,44 @@ async function runSuggestedAction({ body, profileDir, registries = [], deps = {}
146
167
  ok: false, status: 200, action: parsed.action, packageName: parsed.packageName, profile: parsed.profile,
147
168
  registry, exitCode: null, command: null, stdout: '', stderr: '', relaxedReleaseAge: false,
148
169
  manifest: null, lock: null, notes: [], reason: null, hint: null, detail: null,
170
+ kind: parsed.action, ignoredBuilds: [], nextAction: null,
171
+ }
172
+ if (parsed.action === 'allow-builds') {
173
+ const applyAllow = typeof deps.applyAllow === 'function' ? deps.applyAllow : applyAllowBuilds
174
+ const pending = pendingApprovedBuilds(profileDir)
175
+ // 缺省 = 放行当前真被忽略的全部:优先用 pnpm 自己记的 ignoredBuilds(.modules.yaml),
176
+ // 回落到 workspace 文件里的未决占位项。**绝不**接受客户端点名一个"当前没被忽略"的包。
177
+ const blocked = pending.ignored.length > 0 ? pending.ignored : readWorkspaceApprovals(profileDir).blocked
178
+ const targets = parsed.packageName === null ? blocked : [parsed.packageName]
179
+ if (targets.length === 0) {
180
+ return { ...base, ok: false, status: 400, error: '当前没有待批准的构建脚本(pnpm 没记录被忽略的构建,workspace 里也没有未决占位项),无需放行' }
181
+ }
182
+ if (parsed.packageName !== null && !blocked.includes(parsed.packageName)) {
183
+ return { ...base, ok: false, status: 400, error: `${parsed.packageName} 不在当前被忽略的构建脚本名单里(${blocked.join('、') || '空'}),拒绝改写` }
184
+ }
185
+ const applied = await applyAllow({ profileDir, packages: targets, now: deps.now })
186
+ return {
187
+ ...base,
188
+ ok: applied.ok === true,
189
+ exitCode: applied.ok === true ? 0 : 1,
190
+ // 展示/复制的命令 = pnpm 自己的等价命令(本动作**不执行**它;服务端只写文件)
191
+ command: 'pnpm approve-builds',
192
+ changed: applied.changed === true,
193
+ added: Array.isArray(applied.added) ? applied.added : [],
194
+ file: applied.file ?? null,
195
+ form: applied.form ?? null,
196
+ fallback: applied.fallback === true,
197
+ sha256Before: applied.sha256Before ?? null,
198
+ sha256After: applied.sha256After ?? null,
199
+ backup: applied.backup ?? null,
200
+ verified: applied.verified ?? null,
201
+ allow: applied,
202
+ notes: [applied.note, applied.reason].filter((n) => typeof n === 'string' && n !== ''),
203
+ reason: applied.ok === true
204
+ ? (applied.changed === true ? `已放行:${(applied.added ?? []).join('、') || '(无新增)'}` : (applied.reason ?? '无需改动'))
205
+ : (applied.reason ?? '放行失败(未改动文件)'),
206
+ detail: applied,
207
+ }
149
208
  }
150
209
  if (parsed.action === 'reconcile-lock') {
151
210
  const view = await repair({ profileDir, registries: Array.isArray(registries) && registries.length > 0 ? registries : [registry] })
@@ -178,7 +237,13 @@ async function runSuggestedAction({ body, profileDir, registries = [], deps = {}
178
237
  return { ...base, ok: false, status: 200, manifest: { before, after: before, changed: false, pinned: false }, reason: error instanceof Error ? error.message : String(error) }
179
238
  }
180
239
  const argv = pnpmAddArgs(pinned.spec, registry)
181
- const run = await runAddWithReleaseAgeRetry({ profileDir, spec: pinned.spec, registry, runAdd, timeoutMs: deps.timeoutMs })
240
+ // 等待预算:profile 若处于"已放行但还没构建过",这一步会真的去构建(实测 18m59.6s)——用长预算;
241
+ // 其余情况一个字都不变(仍是 180s)。
242
+ const pendingBuilds = pendingApprovedBuilds(profileDir)
243
+ const timeoutMs = Number.isFinite(deps.timeoutMs) && deps.timeoutMs > 0
244
+ ? deps.timeoutMs
245
+ : (pendingBuilds.pending === true ? ACTION_BUILD_TIMEOUT_MS : ACTION_ADD_TIMEOUT_MS)
246
+ const run = await runAddWithReleaseAgeRetry({ profileDir, spec: pinned.spec, registry, runAdd, timeoutMs })
182
247
  const after = await specOf(profileDir, parsed.packageName)
183
248
  const entry = lockVersion(profileDir, parsed.packageName)
184
249
  const lockSynced = typeof entry === 'string' && entry.startsWith('link:')
@@ -186,11 +251,27 @@ async function runSuggestedAction({ body, profileDir, registries = [], deps = {}
186
251
  // 规范化成相对路径,那同样是"不经 registry 解析"的来源型 spec,判据见 domain/dep-source.js#linkSpecIsIntact)
187
252
  const pinnedOk = typeof after === 'string' && after.startsWith('link:')
188
253
  const manifests = { before, after, changed: before !== after, planned: pinned.spec, pinned: pinnedOk }
254
+ // 失败分类只认一份真源(install-diagnose 的规则表):这里只关心"是不是构建脚本未获批准"。
255
+ const failure = run.ok === true ? null : classifyInstallFailure(`${run.stderr}\n${String(run.error?.message ?? '')}`)
256
+ const ignored = failure?.kind === 'ignored-builds'
257
+ const ignoredBuilds = ignored === true ? ignoredBuildsFrom(run.stderr) : []
258
+ const nextAction = ignored === true ? suggestedAllowBuildsAction({ packages: ignoredBuilds, profile: parsed.profile, reason: 'pnpm 因构建脚本未获批准而报错' }) : null
189
259
  const notes = []
190
260
  if (run.relaxedReleaseAge) notes.push('本机 pnpm 的 24h 供应链闸(minimumReleaseAge)拦下了这次 lock 校验,已**仅对这一条命令**追加 --config.minimumReleaseAge=0 重试一次(有安全代价,未写进任何配置文件)。')
191
- // 三种"pnpm 那一步没跑成"的形态都如实记一句(2026-09-27 真机桌面端实例实测:corepack 在该进程里
192
- // 解析不到 → pnpm 那一步失败,而清单/lock 其实已达成目标;只留 stderr 会让用户自己猜发生了什么)
193
- if (!run.ok) {
261
+ if (pendingBuilds.pending === true) {
262
+ notes.push(`本 profile 已有放行项但还没构建过:本次 pnpm 那一步会真的执行构建脚本(本机实测约 19 分钟),`
263
+ + `所以等待上限提到了 ${Math.round(timeoutMs / 60000)} 分钟,不会中途把一次**成功**的安装杀掉。`)
264
+ }
265
+ if (!run.ok && ignored === true) {
266
+ // A-② 语义:清单与 lock 已到目标状态时,这类报错**不是失败** —— 它一个脚本都不执行、也不影响加载。
267
+ // exitCode 如实回报(绝不抹成 0);只把定性、涉及哪些包、以及出口(显式放行按钮)讲清楚。
268
+ notes.push(manifests.pinned && lockSynced
269
+ ? IGNORED_BUILDS_REACHED_NOTE
270
+ : `清单与 lock 还没到目标状态,而 pnpm 又因构建脚本未获批准(ERR_PNPM_IGNORED_BUILDS)中止(exitCode=${run.exitCode}):可先点「允许这些构建脚本」,再重跑本动作。`)
271
+ if (ignoredBuilds.length > 0) notes.push(`被忽略的构建脚本涉及:${ignoredBuilds.join('、')}(exitCode=${run.exitCode} 如实回报,未抹成 0)。`)
272
+ } else if (!run.ok) {
273
+ // 其余"pnpm 那一步没跑成"的形态照旧如实记一句(2026-09-27 真机桌面端实例实测:corepack 在该进程里
274
+ // 解析不到 → pnpm 那一步失败,而清单/lock 其实已达成目标;只留 stderr 会让用户自己猜发生了什么)
194
275
  notes.push(manifests.pinned
195
276
  ? (lockSynced
196
277
  ? `pnpm 那一步没跑完(exitCode=${run.exitCode},原始输出见 stderr):清单与 lock 已经是 link:,目标状态已达成。`
@@ -199,6 +280,9 @@ async function runSuggestedAction({ body, profileDir, registries = [], deps = {}
199
280
  }
200
281
  return {
201
282
  ...base,
283
+ kind: ignored === true ? 'ignored-builds' : parsed.action,
284
+ ignoredBuilds,
285
+ nextAction,
202
286
  // 只有"清单真的是 link: **且** lock 里也有这条 link"才算成功;只成一半时 partial=true 并如实说明
203
287
  ok: manifests.pinned && lockSynced,
204
288
  partial: manifests.pinned && !lockSynced,
@@ -207,13 +291,15 @@ async function runSuggestedAction({ body, profileDir, registries = [], deps = {}
207
291
  stdout: run.stdout,
208
292
  stderr: run.stderr,
209
293
  relaxedReleaseAge: run.relaxedReleaseAge,
294
+ timeoutMs,
210
295
  manifest: manifests,
211
296
  lock: { entry: entry ?? null, synced: lockSynced },
212
297
  notes,
298
+ failureKind: failure?.kind ?? null,
213
299
  reason: manifests.pinned
214
300
  ? (lockSynced ? `${parsed.packageName} 已钉住:清单与 pnpm-lock.yaml 都指向 link:${pinned.dir}` : `${parsed.packageName} 的清单已钉住,lock 未对齐`)
215
301
  : `${parsed.packageName} 的清单写回未生效(实际是 ${after ?? '(无该条目)'})`,
216
302
  }
217
303
  }
218
304
 
219
- export { ACTION_KINDS, COMMAND_KEYS, DEFAULT_REGISTRY, OUTPUT_LIMIT, isProfilePackage, isReleaseAgeBlock, parseActionRequest, runAddWithReleaseAgeRetry, runSuggestedAction }
305
+ export { ACTION_ADD_TIMEOUT_MS, ACTION_BUILD_TIMEOUT_MS, ACTION_KINDS, COMMAND_KEYS, DEFAULT_REGISTRY, IGNORED_BUILDS_REACHED_NOTE, OUTPUT_LIMIT, isProfilePackage, isReleaseAgeBlock, parseActionRequest, runAddWithReleaseAgeRetry, runSuggestedAction }