@noob-stupid/dsh-plugin-console 0.5.46 → 0.5.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,208 @@
1
+ // L0 · infra —— egress.js(**唯一出网点**:出网与起进程全部收口在这里;0.5.47 加法)
2
+ //
3
+ // 为什么要有这个模块(上一批的教训,用户已复核):
4
+ // 上一批想用"给 `node:child_process` 的 ESM 命名空间打补丁 + 读 globalThis.__probeChildCalls"
5
+ // 来证明"测试没真出网、没真起进程"。两处都是**假守卫**:
6
+ // ① ESM 下 `import { execFile } from 'node:child_process'` 拿到的是**只读命名空间绑定**,
7
+ // `Object.defineProperty` 直接 `TypeError: Cannot redefine property: execFile` —— 这条路死了;
8
+ // ② 那个计数器是**测试文件里的局部变量**,而断言读的是 `globalThis.__probeChildCalls`
9
+ // ⇒ 断言恒真空,永远"0 次",守卫一点牙都没有。
10
+ //
11
+ // 本模块给的是**可注入单点**(唯一站得住的形态):
12
+ // · 生产代码的出网/起进程**只经这里**(http.js 的每条通道都换成这里 makeEgress 造出来的包装);
13
+ // · 单测 `__setEgressStub({ curlText, githubViaGh, ... })` 注入替身 ⇒ 计数、失败、超时都能被驱动,
14
+ // 且**一个真请求都发不出去**;
15
+ // · 计数器就在**执行的那一层**(真实实现前自增),不是测试文件里的局部变量、也不是 globalThis 空标志;
16
+ // · `__setEgressTripwire(true)` 是"漏了注入"的**当场抓手**:没被 stub 覆盖的出口**先抛**,不外发;
17
+ // · `__setEgressRealImpl({...})` 由生产模块在加载时登记真实现;`gaps` 记录"哪个出口被登记过",
18
+ // 于是"生产到底接上没有"是**可断言的**(不是靠读代码猜)。
19
+ // · `egressSelfCheckSpawn()` 是**计数器正控**:真的起一次 `node -e ""`,证明"0 次"不是恒绿。
20
+ //
21
+ // 为什么用 makeEgress(impl) 而不是让本模块 import http.js:**不起循环依赖**(infra → infra 也不行,
22
+ // egress ⇄ http 会成环)。真实现由 http.js 在加载时用 __setEgressRealImpl 登记,方向单向、可静态断言。
23
+ //
24
+ // 分层:L0 · infra(不得 import domain/routes/index —— tests/test-architecture-guard.mjs 断言②)。
25
+
26
+ import { execFile as nodeExecFile } from 'node:child_process'
27
+ import { request as nodeHttpsRequest } from 'node:https'
28
+ import { promisify } from 'node:util'
29
+
30
+ /** 真实出网/起进程的调用计数(只在真实现**之前**自增;stub 命中数另有 stubCalls 记)。 */
31
+ const stats = { realNetwork: 0, realSpawn: 0, stubCalls: 0, trips: 0, gaps: [] }
32
+ /** 单测注入的替身(缺哪个键 ⇒ 那个出口按"漏注入"处理,绝不悄悄走真实现)。 */
33
+ let stub = null
34
+ /** 生产登记的真实现(键 = 出口名)。 */
35
+ let realImpl = null
36
+
37
+ /** **测试进程判定**(与 routes/components.js#isTestProcess 同一判据,理由也同一条):
38
+ * 只看**自己进程的入口脚本**是不是本仓 `tests/**`;判据落在进程自身上 ⇒ 测试**无法自解**这层保护。
39
+ * 为什么网络侧也要它:测试若把替身还原成 null("还原生产实现")再走生产路径,真请求就会发出去 ——
40
+ * 与"测试把 spawn 桩摘掉 ⇒ 弹 explorer 窗口"是同一个漏洞形状。这里把它堵死:测试进程里
41
+ * **模块加载即开抓手**,真实现**必须显式 opt-in** 才可能被走到。 */
42
+ function isTestProcess() {
43
+ try {
44
+ if (process.env.DSH_TEST_PROCESS === '1') return true
45
+ } catch {}
46
+ try {
47
+ const entry = String(process.argv[1] ?? '').replace(/\\/gu, '/')
48
+ if (entry === '') return false
49
+ return /\/tests\/(?:test-[^/]*\.mjs|run-all\.mjs|probe-[^/]*\.[cm]js)$/u.test(entry)
50
+ } catch {
51
+ return false
52
+ }
53
+ }
54
+
55
+ /** 真实现(真实出网/起进程)的**显式 opt-in**:`DSH_EGRESS_ALLOW_REAL=1`。
56
+ * 生产不设它也不受影响(生产进程不是测试进程 ⇒ 不触发下面的加载即装)。 */
57
+ function realEgressOptIn() {
58
+ try {
59
+ return process.env.DSH_EGRESS_ALLOW_REAL === '1'
60
+ } catch {
61
+ return false
62
+ }
63
+ }
64
+
65
+ // ★ 模块加载即装(测试进程里无需任何 env、无需任何调用方配合):抓手默认开着,且把"我是测试进程"
66
+ // 写进环境让子进程继承 —— 于是"测试漏了注入"的结局是**当场红**,而不是真发出去一个请求。
67
+ let tripwire = isTestProcess() && !realEgressOptIn()
68
+ if (tripwire) {
69
+ try {
70
+ process.env.DSH_TEST_PROCESS = '1'
71
+ } catch {}
72
+ }
73
+
74
+ /** 单测注入替身:传对象 = 打桩(**一个真请求/真进程都不会发生**);传 null = 还原。 */
75
+ function __setEgressStub(next) {
76
+ stub = next !== null && typeof next === 'object' ? next : null
77
+ return stub !== null
78
+ }
79
+
80
+ /** 漏注入抓手开/关。开:未被 stub 覆盖的出口抛错并记一笔 trips;关:走真实现(生产默认)。 */
81
+ function __setEgressTripwire(on) {
82
+ tripwire = on === true
83
+ return tripwire
84
+ }
85
+
86
+ /** 生产模块加载时登记真实现(唯一一次;重复登记按合并处理)。返回已登记的出口名清单。 */
87
+ function __setEgressRealImpl(next) {
88
+ realImpl = { ...(realImpl ?? {}), ...(next ?? {}) }
89
+ return Object.keys(realImpl).sort()
90
+ }
91
+
92
+ /** 计数快照(浅拷贝,测试据此断言"0 真出网 / 0 真 spawn")。 */
93
+ function egressStatsNow() {
94
+ return { ...stats, gaps: [...stats.gaps] }
95
+ }
96
+
97
+ /** 清零计数(只给测试用;生产没有任何调用点)。 */
98
+ function __resetEgressStats() {
99
+ stats.realNetwork = 0
100
+ stats.realSpawn = 0
101
+ stats.stubCalls = 0
102
+ stats.trips = 0
103
+ stats.gaps = []
104
+ }
105
+
106
+ /** 请求目标(真话记录用):请求可能以 `request(url, opts, cb)` 或 `(opts, cb)` 形态调用。 */
107
+ function networkTargetOf(args) {
108
+ const first = args[0]
109
+ if (typeof first === 'string') return first
110
+ if (first !== null && typeof first === 'object') {
111
+ const host = first.hostname ?? first.host ?? ''
112
+ const path = first.path ?? ''
113
+ if (host !== '') return `${first.protocol ?? 'https:'}//${host}${path}`
114
+ }
115
+ return '(unknown)'
116
+ }
117
+
118
+ /** 目标是**回环地址**吗(127.0.0.1 / localhost / ::1)。
119
+ * 红线防的是**外网**请求与真实进程;而"传输层语义"这类测试(本仓既有做法:本地起坏代理复现
120
+ * 200 空 body / 垃圾页 / 404)本来就要真起 curl 打回环 —— 那是允许的,不应当成"漏注入"。
121
+ * 注意:只有**能看出目标**的出口(curl/jsDelivr/https 系列)才有这条豁免;`execFileAsync`
122
+ * 这种看不出目标的(pnpm/git/gh 二进制)一律拦,测试要真跑必须显式 opt-in。 */
123
+ function isLoopbackTarget(args) {
124
+ const target = networkTargetOf(args).toLowerCase()
125
+ return /^(?:https?|git\+https?):\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?::\d+)?(?:[/?#]|$)/u.test(target)
126
+ }
127
+
128
+ /** 未注入出口的处理:tripwire 开着 ⇒ 抛(当场的抓手);关着 ⇒ 用自己的真实现(没有再登记就抛)。 */
129
+ function unwired(name, key, args) {
130
+ stats.gaps.push(name)
131
+ if (tripwire && !isLoopbackTarget(args)) {
132
+ stats.trips += 1
133
+ throw new Error(`测试红线:出口 ${name} 未被替身覆盖 —— 本套禁止任何真实出网/真实进程(请给 __setEgressStub 补上 ${key};只打回环的传输层测试可设 DSH_EGRESS_ALLOW_REAL=1)`)
134
+ }
135
+ const impl = realImpl?.[key]
136
+ if (typeof impl !== 'function') throw new Error(`出网出口 ${name} 没有真实现(生产模块没登记?)`)
137
+ return impl
138
+ }
139
+
140
+ /** 造一支出网包装:**stub 优先 → 记一笔 → 真实现**。顺序写在代码里,可读也可静态断言。
141
+ * `kind`:'network' 记出网计数,'spawn' 记起进程计数(正控自检据此断言计数器不是死的)。
142
+ * `fallback`:可选真实现(调用方直接给函数);给了就用它,否则查 realImpl 登记表。
143
+ * 为什么允许直接给:`node:https` 的请求原语也收在本模块里(见 egressHttpsRequestOnce),
144
+ * 于是"只有 egress.js 认识 node:https"是**静态可判**的,而不是靠一句注释约定。 */
145
+ function makeEgress(name, kind = 'network', fallback = null) {
146
+ const wrapper = async (...args) => {
147
+ if (stub !== null && typeof stub[name] === 'function') {
148
+ stats.stubCalls += 1
149
+ return await stub[name](...args)
150
+ }
151
+ const impl = typeof fallback === 'function' ? fallback : unwired(name, name, args)
152
+ if (kind === 'network') countNetwork(networkTargetOf(args))
153
+ else countSpawn()
154
+ return await impl(...args)
155
+ }
156
+ wrapper.egressName = name
157
+ return wrapper
158
+ }
159
+
160
+ /** 真话记录:**真的**要把一个请求发出去了(只被 makeEgress 与正控调用)。 */
161
+ function countNetwork(target) {
162
+ stats.realNetwork += 1
163
+ return target
164
+ }
165
+
166
+ /** 真话记录:**真的**要创建一个进程了(只被 makeEgress 与正控调用)。 */
167
+ function countSpawn() {
168
+ stats.realSpawn += 1
169
+ }
170
+
171
+ /** 正控用:不带任何注入,**真的**起一次进程(`node -e ""`,无窗口无副作用),并把计数交给调用方。
172
+ * 返回 `{ ok, counted }`:counted === 1 证明 spawn 计数不是死的(否则"0 次"就是假绿)。 */
173
+ async function egressSelfCheckSpawn({ spawnFn = null } = {}) {
174
+ const before = stats.realSpawn
175
+ const impl = typeof spawnFn === 'function' ? spawnFn : realImpl?.execFileAsync
176
+ if (typeof impl !== 'function') return { ok: false, counted: 0, reason: '没有登记 execFileAsync 真实现' }
177
+ countSpawn()
178
+ try {
179
+ await impl(process.execPath, ['-e', ''], { windowsHide: true, timeout: 20000 })
180
+ } catch {
181
+ return { ok: stats.realSpawn - before === 1, counted: stats.realSpawn - before }
182
+ }
183
+ return { ok: stats.realSpawn - before === 1, counted: stats.realSpawn - before }
184
+ }
185
+
186
+ /** 正控用:**真的**把一次出网记到(并可真的发出一次请求)。测试只给**回环地址**,
187
+ * 于是"计数器不是死的"与"零外网"可以同时成立。 */
188
+ function egressSelfCheckNetwork(target, { requestFn = null, send = false } = {}) {
189
+ const before = stats.realNetwork
190
+ countNetwork(networkTargetOf([target]))
191
+ const impl = typeof requestFn === 'function' ? requestFn : realImpl?.httpsRequestOnce
192
+ if (send === true) {
193
+ if (typeof impl !== 'function') return { counted: stats.realNetwork - before, sent: false, reason: '没有登记 httpsRequestOnce 真实现' }
194
+ return { counted: stats.realNetwork - before, sent: true, request: () => impl(target, { method: 'GET' }) }
195
+ }
196
+ return { counted: stats.realNetwork - before, sent: false }
197
+ }
198
+
199
+ /** **真的**发一次 https 请求(node:https 的原语收在本模块里:于是"只有 egress.js 认识
200
+ * node:https"是静态可判的)。http.js 的真实 `githubRequestOnce` 用它;单测注入替身后它不会被调用。 */
201
+ function egressHttpsRequestOnce(url, options) {
202
+ return nodeHttpsRequest(url, options)
203
+ }
204
+
205
+ export {
206
+ __setEgressStub, __setEgressTripwire, __setEgressRealImpl, __resetEgressStats, egressStatsNow,
207
+ makeEgress, egressHttpsRequestOnce, egressSelfCheckSpawn, egressSelfCheckNetwork,
208
+ }
@@ -7,6 +7,9 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'
7
7
  import { dirname, join } from 'node:path'
8
8
  import { homedir } from 'node:os'
9
9
  import { selectPnpmRunners } from './pnpm-runner-pick.js'
10
+ import { releaseAgeGateHit, releaseAgeBlockedSpecs, releaseAgeGateError } from './pnpm-release-gate.js'
11
+ import { RUNTIME_PNPM_BIN_RE, RUNTIME_BIN_RE, desktopRuntimeCandidates, dshRuntimeCandidates } from './pnpm-runtime-discovery.js'
12
+ import { CMD_META_RE, cmdCommandLine, cmdCorepackCommand, cmdShellArgv } from './cmd-line.js'
10
13
 
11
14
  /** git 可执行名(跨平台)。事故(2026-09-20,另一位用户:Android + proot Ubuntu):
12
15
  * 「仓库落地」硬编码 `git.exe` → 非 Windows 环境 spawn git.exe ENOENT,克隆必然失败。 */
@@ -14,70 +17,6 @@ function gitBin() {
14
17
  return process.platform === 'win32' ? 'git.exe' : 'git'
15
18
  }
16
19
 
17
- /** PATH 条目是不是桌面端运行时的 `<…>\runtime\pnpm\bin` / `<…>\runtime\bin`(两种分隔符都认)。 */
18
- const RUNTIME_PNPM_BIN_RE = /[\\/]runtime[\\/]pnpm[\\/]bin[\\/]?$/u
19
- const RUNTIME_BIN_RE = /[\\/]runtime[\\/]bin[\\/]?$/u
20
-
21
- /** cmd.exe 的元字符:`^` 是 cmd 自己的转义符(**引号内照样生效** —— 本机实测 `"a^b"` 到程序手里变成 `ab`),
22
- * `& | < > ( )` 在引号内**仍然是 cmd 的操作符**(实测 `"a&b"` 会在 `&` 处断开,把 `b` 当命令执行)。
23
- * 所以段内这些字符必须逐个加 `^` 转义。 */
24
- const CMD_META_RE = /[\^&|<>()]/gu
25
-
26
- /** 把一串命令段拼成**一条 cmd.exe 命令字符串**(纯函数,导出给单测)。
27
- *
28
- * 2026-09-27 改错(桌面端实例"所有 pnpm 操作都跑不了"的两处根因之一):
29
- * 旧代码是 `['corepack','pnpm',...args].map((a) => JSON.stringify(a)).join(' ')` —— JSON.stringify 加的是
30
- * **真引号**,而这一整串又是作为一个 argv 元素交给 Node 的 spawn;Windows 上 Node/libuv 按 MSVCRT 规则
31
- * 把参数里的 `"` 转义成 `\"`,cmd 实际收到 `\"corepack\" \"pnpm\"` → `'\"corepack\"' 不是内部或外部命令`。
32
- * 本机对着一个只回显参数的 .cmd 实测三种形态:
33
- * · `"a" "b"` + 默认转义 → 收到 `\"a\" \"b\"` → 失败(旧缺陷)
34
- * · `"a" "b"` + windowsVerbatimArguments → `/s` 会把首尾两个引号一起剥掉 → 也失败
35
- * · `""a" "b""` + windowsVerbatimArguments → 正确解析成两个参数 ✓(含空格 / `&` / `|` / `>` 的参数
36
- * 在段内加 `^` 转义后也原样到达)
37
- * 所以本函数产出的是"**内层**命令字符串"(形如 `corepack pnpm add x`,**永不含 `\"`**;只有含空白的段才带引号 —— 2026-09-28 改错,见函数内注释),
38
- * 最外层那对引号由 cmdShellArgv 负责加。
39
- * 已知边界(如实记,不假装覆盖):`%VAR%` 与开启延迟展开时的 `!VAR!` 是 cmd 自己的展开,
40
- * **任何引号/`^` 都挡不住**;我们生成的 argv(包名、registry、布尔选项、绝对路径)不含这两个字符,
41
- * 真出现时该通道会失败并如实报错(不会静默跑成别的命令)。 */
42
- function cmdCommandLine(parts) {
43
- return parts
44
- .map((part) => String(part))
45
- .map((text) => {
46
- // 2026-09-28 改错(真机:`cmd-corepack` 一条都跑不动 → pnpm-runners / suite-fallback /
47
- // install-smoke 三套红,报 `cmd-corepack :: status=1 out=""`):
48
- // `corepack` 在 Windows 上常是 **cmd 批处理 shim**(`<node 安装目录>\corepack.cmd` →
49
- // `node …\corepack\dist\corepack.js %*`)。对批处理,cmd **不会剥掉参数外层的引号**,
50
- // 引号会随 `%*` 一路进到 node 的 argv —— 于是 corepack 收到的是 `"pnpm"` / `"--version"`
51
- // (带引号当成了包管理器名)→ 直接崩:status=1、stdout 空。
52
- // 真 cmd.exe + windowsVerbatimArguments 实测(同一台机):
53
- // · `""corepack" "pnpm" "--version""` → status=1、stdout=""(现行形态,坏)
54
- // · `"corepack pnpm --version"` → status=0、stdout="11.21.0"(按需引号,好)
55
- // 所以**只为含空白的段加引号**;其余裸串 + `^` 转义 cmd 元字符(裸串里 `^` 是标准且可靠的转义)。
56
- // 含空白的段仍走「引号 + 段内 ^ 转义」,与既有行为一致,不改语义。
57
- // 已知边界(如实记):段内含 `"` 时无法在 cmd 里安全表达 —— 我们生成的 argv(包名/registry/布尔
58
- // 选项/绝对路径)不含裸引号;真出现时该通道会失败并如实报错,不会静默跑成别的命令。
59
- // 「干净 token」(无空白、无 cmd 元字符)→ 裸串。**只有这种情况**能去引号:
60
- // 含空白的段必须带引号;含 `& | < > ( ) ^` 的段,实测**裸串 + `^` 转义会失效**
61
- // (真机 ⑥:裸的 `a^&b` 被 cmd 当分隔符断开,收到 `[a`),必须沿用「引号 + 段内 ^」的既有形态。
62
- const clean = !/[\s]/u.test(text) && !/[\^&|<>()]/u.test(text)
63
- if (clean) return text
64
- return `"${text.replace(CMD_META_RE, (ch) => `^${ch}`)}"`
65
- })
66
- .join(' ')
67
- }
68
-
69
- /** corepack 形态的命令字符串(纯函数,导出给单测断言"不含 `\"` 转义")。 */
70
- function cmdCorepackCommand(args) {
71
- return cmdCommandLine(['corepack', 'pnpm', ...args])
72
- }
73
-
74
- /** 交给 cmd.exe 的完整 argv(`/d /s /c` + **整条再包一层引号**的内层命令字符串)。
75
- * 必须与 `verbatim: true`(windowsVerbatimArguments)配套:Node 不再做任何引号转义,
76
- * cmd 的 `/s` 剥掉最外层那一对,剩下的正好是我们要的命令行。 */
77
- function cmdShellArgv(commandLine) {
78
- return ['/d', '/s', '/c', `"${commandLine}"`]
79
- }
80
-
81
20
  /** pnpm 执行方式定位(跨平台,纯函数便于单测 → 按优先级返回列表,逐个尝试)。
82
21
  * 事故(2026-09-20,同一位用户,node v24 + Linux):只按 Windows 布局找 corepack.js
83
22
  * (`<node bin>/node_modules/corepack/dist/corepack.js`),而 Linux 的 npm 全局布局在
@@ -98,7 +37,7 @@ function cmdShellArgv(commandLine) {
98
37
  * 作为**新候选插在 node-corepack 之后、cmd-corepack 之前**:既有候选与顺序一个都没动。
99
38
  * 兜底语义保持不变:只有"执行方式本身不可用"(ENOENT / Cannot find module)才换下一个,
100
39
  * 真正的失败立即抛出(见 runPnpmWithFallback)。
101
- * 全部候选都走注入的 `exists`;`env/resourcesPath/delimiter/nodeBin` 四个注入点只为离线单测,
40
+ * 全部候选都走注入的 `exists`;`env/resourcesPath/delimiter/nodeBin/home` 五个注入点只为离线单测,
102
41
  * 生产调用方一个字都不用改(默认值就是当前进程的事实)。 */
103
42
  function resolvePnpmRunners({
104
43
  platform = process.platform,
@@ -109,6 +48,7 @@ function resolvePnpmRunners({
109
48
  resourcesPath = process.resourcesPath,
110
49
  delimiter = platform === 'win32' ? ';' : ':',
111
50
  nodeBin = 'node',
51
+ home = homedir(),
112
52
  } = {}) {
113
53
  const binDir = dirname(execPath)
114
54
  const corepackCandidates = [
@@ -127,29 +67,26 @@ function resolvePnpmRunners({
127
67
  }
128
68
  }
129
69
 
130
- // ── 新候选 ①/③:桌面端(Electron)自带运行时 ──────────────────────────────────
131
- // ① Electron 的 `process.resourcesPath`(桌面端 = `<安装目录>\resources`);
132
- // ③ 环境线索:PATH 条目里的 `<…>\runtime\pnpm\bin` 与 `<…>\runtime\bin`(桌面端 host 就是把
133
- // `resources\runtime\bin` 拼进 profile 的 PATH 的,其同级就是 `runtime\pnpm\bin\pnpm.mjs`)。
70
+ // ── 候选发现:桌面端自带运行时 与 DSH 运行时装(细节见 infra/pnpm-runtime-discovery.js)────
134
71
  const desktopNode = typeof env?.DSH_DESKTOP_NODE_EXECUTABLE === 'string' && env.DSH_DESKTOP_NODE_EXECUTABLE !== ''
135
72
  ? env.DSH_DESKTOP_NODE_EXECUTABLE
136
73
  : execPath
137
- const runtimeMjs = []
138
- if (typeof resourcesPath === 'string' && resourcesPath !== '') runtimeMjs.push(join(resourcesPath, 'runtime', 'pnpm', 'bin', 'pnpm.mjs'))
139
74
  const pathValue = typeof env?.PATH === 'string' && env.PATH !== '' ? env.PATH : (typeof env?.Path === 'string' ? env.Path : '')
140
75
  const pathDirs = String(pathValue).split(delimiter).map((d) => d.trim()).filter((d) => d !== '')
141
- for (const dir of pathDirs) {
142
- if (RUNTIME_PNPM_BIN_RE.test(dir)) runtimeMjs.push(join(dir, 'pnpm.mjs'))
143
- else if (RUNTIME_BIN_RE.test(dir)) runtimeMjs.push(join(dirname(dir), 'pnpm', 'bin', 'pnpm.mjs'))
76
+
77
+ // ① DSH 运行时装那份(**普通 node 进程也找得到** ⇒ 缺陷②的正解;见模块头注释)
78
+ for (const js of dshRuntimeCandidates({ env, home })) {
79
+ if (!exists(js)) continue
80
+ // 用普通 **node** 跑,**不带** ELECTRON_RUN_AS_NODE(那不是 Electron 二进制,带了反而污染语义)
81
+ add({ kind: 'runtime-pnpm-mjs', note: `${nodeBin} ${js} pnpm`, run: (args) => ({ bin: nodeBin, argv: [js, ...args] }) })
144
82
  }
145
- for (const js of runtimeMjs) {
83
+ // ② 桌面端(Electron)自带运行时那份。ELECTRON_RUN_AS_NODE 必须显式带上:不带它,
84
+ // Electron 二进制会当 **GUI 应用**启动 —— 等于弹一个新窗口,退出码还可能是 0("假成功"更糟)。
85
+ for (const js of desktopRuntimeCandidates({ resourcesPath, pathDirs, delimiter })) {
146
86
  if (!exists(js)) continue
147
87
  add({
148
88
  kind: 'desktop-pnpm-mjs',
149
89
  note: `${desktopNode} --expose-internals ${js} pnpm`,
150
- // 照抄桌面端 host 的形态(command=execPath,args=['--expose-internals', <pnpm.mjs>])。
151
- // ELECTRON_RUN_AS_NODE 必须显式带上:不带它,Electron 二进制会当 **GUI 应用**启动 ——
152
- // 等于弹一个新窗口,而且退出码可能是 0("假成功",比失败更糟)。
153
90
  run: (args) => ({ bin: desktopNode, argv: ['--expose-internals', js, ...args], env: { ELECTRON_RUN_AS_NODE: '1' } }),
154
91
  })
155
92
  }
@@ -506,12 +443,19 @@ function execFileWithKillTree(bin, argv, opts = {}, deps = {}) {
506
443
  * 所以不是 env 前缀的问题,是这两个键不在 pnpm 的 env 白名单里);
507
444
  * 真正生效的是 `pnpm add --fetch-timeout / --fetch-retries` 两个 CLI 选项(见 pnpmFetchArgs)。
508
445
  * 仍然照需求注入这三个 env(CI 与非交互确实有用、registry 镜像照旧、fetch_* 是 npm 系标准写法,
509
- * 对 npm/后续 pnpm 版本仍可能生效),但**不把健壮性只押在 env 上**。 */
446
+ * 对 npm/后续 pnpm 版本仍可能生效),但**不把健壮性只押在 env 上**。
447
+ *
448
+ * ★ 2026-10-13(0.5.48,实测"装 ds-bot 时无 TTY 永久挂住"):补 `npm_config_ci=true` ——
449
+ * pnpm 判"能不能弹交互"用的是**它自己的 ci 配置项**(`opts3.ci`,env 形态即 `npm_config_ci`),
450
+ * **不是** `process.env.CI`;只给 `CI=true` 时 `canPrompt` 可能仍为真 ⇒ 弹提问 ⇒ 读 stdin 到 EOF
451
+ * ⇒ 桌面壳的管道永不关闭 ⇒ 永久挂住。取证与完整链条见 infra/pnpm-release-gate.js 头注释。 */
510
452
  function pnpmEnvOverrides(registry) {
511
453
  const overrides = {
512
454
  npm_config_fetch_timeout: '60000',
513
455
  npm_config_fetch_retries: '1',
514
456
  CI: 'true',
457
+ // pnpm 自己的 ci 位(非交互的权威开关);`CI=true` 单独不够,理由见上。
458
+ npm_config_ci: 'true',
515
459
  }
516
460
  if (typeof registry === 'string' && registry !== '') overrides.COREPACK_NPM_REGISTRY = registry
517
461
  return overrides
@@ -542,11 +486,17 @@ function pnpmFetchArgs(timeoutMs = 60000, retries = 1) {
542
486
 
543
487
  /** pnpm add 的完整参数(纯函数,单测覆盖)。registry 只在是**非空字符串**时才拼 ——
544
488
  * 旧代码无条件拼 `--registry ${registry}`,`git:` 规格那条路传的是 undefined,命令行长成
545
- * `--registry undefined`(今天恰好还能装,但那是"垃圾参数被忽略",不是设计)。 */
489
+ * `--registry undefined`(今天恰好还能装,但那是"垃圾参数被忽略",不是设计)。
490
+ *
491
+ * ★ 2026-10-13(0.5.48):追加 `--config.ci=true` —— pnpm **官方支持的非交互开关**
492
+ * (`--config.<key>=<value>` 是 pnpm 设任意配置项的通用语法,**不是**绕过闸门的手段)。
493
+ * 理由与取证见 infra/pnpm-release-gate.js 头注释:命中供应链闸门时它会弹提问等人回答,
494
+ * 而无 TTY 的子进程 stdin 是永不关闭的管道 ⇒ **永久挂住**;钉死 ci 位 ⇒ 改成快速如实失败。 */
546
495
  function pnpmAddArgs(spec, registry, { fetchFlags = true, fetchTimeoutMs = 60000, fetchRetries = 1 } = {}) {
547
496
  const args = ['add', spec]
548
497
  if (typeof registry === 'string' && registry !== '') args.push('--registry', registry)
549
498
  if (fetchFlags) args.push(...pnpmFetchArgs(fetchTimeoutMs, fetchRetries))
499
+ args.push('--config.ci=true')
550
500
  return args
551
501
  }
552
502
 
@@ -563,11 +513,18 @@ function unknownPnpmOption(message) {
563
513
 
564
514
  /** 跑一次 `pnpm add`(本控制台所有安装通道的唯一入口)—— 2026-09-26 新增。
565
515
  * 相对旧的 `runPnpmWithFallback(['add', spec, '--registry', registry], { execOpts })`,只加三样:
566
- * ① env 走 buildPnpmEnv(追加 fetch 超时/重试 + CI=true;registry 镜像与禁交互照旧);
516
+ * ① env 走 buildPnpmEnv(追加 fetch 超时/重试 + CI/ci=true;registry 镜像与禁交互照旧);
567
517
  * ② 参数走 pnpmAddArgs(追加 `--fetch-timeout/--fetch-retries`:实测 pnpm 11 不读同名 env,只认这两个选项);
568
518
  * ③ 遇到 `Unknown options` 就去掉加固选项重试一次 —— 加固不允许变成"装不上"。
569
519
  * 失败语义不变:真实失败照旧抛出(含 pnpm 原始 stderr),成功不返回值。
570
- * `deps.run` 只是单测注入口(默认 runPnpmWithFallback),生产调用方不传。 */
520
+ * `deps.run` 只是单测注入口(默认 runPnpmWithFallback),生产调用方不传。
521
+ *
522
+ * ★ 2026-10-13(0.5.48,两个真缺陷一起修):
523
+ * · **供应链闸命中 ⇒ 快速如实失败**(判据/错误文案见 infra/pnpm-release-gate.js):闸门命中即
524
+ * 翻译成带解法的错误立刻抛,**不重试、更不绕过**(不忽略锁文件校验、不强制装)。
525
+ * · **显式传 profileDir**:以前只靠 `execOpts.cwd` 兜出 profileDir(**隐式**耦合)⇒ 一旦调用方
526
+ * 换了 cwd 形态,就会落回 corepack 那版 pnpm(本机 11.21.0)而不是 profile 声明的那版(11.7.0)
527
+ * ⇒ 版本错配 ⇒ Windows 上 EPERM。现在两处调用都显式传,判据不依赖 cwd 这个副作用。 */
571
528
  async function runPnpmAdd({ profileDir, spec, registry, timeout = 90000, signal = null, deps = {} } = {}) {
572
529
  const run = typeof deps.run === 'function' ? deps.run : runPnpmWithFallback
573
530
  const execOpts = (ms) => ({
@@ -578,11 +535,20 @@ async function runPnpmAdd({ profileDir, spec, registry, timeout = 90000, signal
578
535
  env: buildPnpmEnv(registry),
579
536
  ...(signal ? { signal } : {}),
580
537
  })
538
+ // profileDir 显式下传(不再依赖 execOpts.cwd 的隐式兜底):见上面 ★ 第二条。
539
+ const runOpts = (ms) => ({ execOpts: execOpts(ms), profileDir })
581
540
  try {
582
- await run(pnpmAddArgs(spec, registry), { execOpts: execOpts(timeout) })
541
+ await run(pnpmAddArgs(spec, registry), runOpts(timeout))
583
542
  } catch (error) {
543
+ // 闸门命中:立刻抛出带解法的错误 —— 不重试(重试也是同样的结局,只会白白多挂一轮)。
544
+ if (releaseAgeGateHit(error?.message)) throw releaseAgeGateError({ spec, profileDir, message: error.message })
584
545
  if (!unknownPnpmOption(error?.message)) throw error
585
- await run(pnpmAddArgs(spec, registry, { fetchFlags: false }), { execOpts: execOpts(timeout) })
546
+ try {
547
+ await run(pnpmAddArgs(spec, registry, { fetchFlags: false }), runOpts(timeout))
548
+ } catch (retryError) {
549
+ if (releaseAgeGateHit(retryError?.message)) throw releaseAgeGateError({ spec, profileDir, message: retryError.message })
550
+ throw retryError
551
+ }
586
552
  }
587
553
  }
588
554
 
@@ -595,4 +561,4 @@ const GH_BIN_CANDIDATES = [
595
561
  join(homedir(), 'scoop', 'shims', 'gh.exe'),
596
562
  ]
597
563
 
598
- export { GH_BIN_CANDIDATES, gitEnv, gitBin, processAlive, execFileAsync, resolvePnpmRunners, pnpmRunnersFor, runPnpmWithFallback, killProcessTree, posixDescendants, execFileWithKillTree, KILL_WAIT_MS, KILL_WAIT_POLL_MS, GIT_LOW_SPEED_ENV, pnpmEnvOverrides, buildPnpmEnv, pnpmFetchArgs, pnpmAddArgs, unknownPnpmOption, runPnpmAdd, cmdCommandLine, cmdCorepackCommand, cmdShellArgv }
564
+ export { GH_BIN_CANDIDATES, gitEnv, gitBin, processAlive, execFileAsync, resolvePnpmRunners, pnpmRunnersFor, runPnpmWithFallback, killProcessTree, posixDescendants, execFileWithKillTree, KILL_WAIT_MS, KILL_WAIT_POLL_MS, GIT_LOW_SPEED_ENV, pnpmEnvOverrides, buildPnpmEnv, pnpmFetchArgs, pnpmAddArgs, unknownPnpmOption, runPnpmAdd, releaseAgeGateHit, releaseAgeBlockedSpecs, releaseAgeGateError, cmdCommandLine, cmdCorepackCommand, cmdShellArgv }