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

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,7 +3,7 @@
3
3
 
4
4
  import { execFile, spawn, spawnSync } from 'node:child_process'
5
5
  import { promisify } from 'node:util'
6
- import { existsSync } from 'node:fs'
6
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
7
7
  import { dirname, join } from 'node:path'
8
8
  import { homedir } from 'node:os'
9
9
 
@@ -108,22 +108,69 @@ function processAlive(pid) {
108
108
  }
109
109
  const execFileAsync = promisify(execFile)
110
110
 
111
+ /** POSIX 兜底用:读 /proc 列出某 pid 的**全部后代**(按 `/proc/<pid>/stat` 的 ppid 字段建索引 + BFS)。
112
+ * 只有"进程组 kill 失败"时才走它,所以是纯读:任何一步读不到就返回 [],绝不抛。
113
+ * 为什么不用 `pkill -P <pid>`:slim 容器/最小镜像里未必装了 procps,而 /proc 是内核接口
114
+ * (Linux / Android 都有);macOS 没有 /proc → 返回 [],调用方就退化成"只杀直接子进程"(不比旧行为差)。
115
+ * deps(procDir/readdir/readFile)只为单测注入,生产调用不传。 */
116
+ function posixDescendants(pid, { procDir = '/proc', readdir = readdirSync, readFile = readFileSync } = {}) {
117
+ let entries
118
+ try { entries = readdir(procDir) } catch { return [] }
119
+ const childrenOf = new Map()
120
+ for (const name of entries) {
121
+ if (!/^\d+$/u.test(String(name))) continue
122
+ let stat
123
+ try { stat = String(readFile(join(procDir, String(name), 'stat'), 'utf8')) } catch { continue }
124
+ // 形如 `1234 (comm 里可能有空格/括号) S 5678 …`:进程名不可信 → 从**最后**一个 ')' 之后切
125
+ const rest = stat.slice(stat.lastIndexOf(')') + 1).trim().split(/\s+/u)
126
+ const ppid = Number(rest[1]) // rest[0]=state,rest[1]=ppid
127
+ if (!Number.isInteger(ppid)) continue
128
+ const list = childrenOf.get(ppid)
129
+ if (list === undefined) childrenOf.set(ppid, [Number(name)])
130
+ else list.push(Number(name))
131
+ }
132
+ const out = []
133
+ const queue = [pid]
134
+ while (queue.length > 0) {
135
+ for (const child of childrenOf.get(queue.shift()) ?? []) {
136
+ if (child === pid || out.includes(child)) continue // 防 /proc 读歪了造出自环
137
+ out.push(child)
138
+ queue.push(child)
139
+ }
140
+ }
141
+ return out
142
+ }
143
+
111
144
  /** 结束**整棵**进程树(2026-09-26 从 domain/repoland.js 移入 infra —— pnpm 通道也要用同一份实现,
112
145
  * 不能让 git 通道和 pnpm 通道各写一份、同一个坑各踩一次)。
113
146
  * 超时/中断后必须做:git 会派生 remote-https / index-pack,pnpm 会派生 git / tar / node-gyp / 子 pnpm,
114
147
  * 只 kill 父进程会留下孤儿继续占着 .git 与 node_modules 里的文件(2026-09-26 真机实测占住
115
148
  * pack 临时文件与 shallow.lock,Windows 下直接导致目录删不掉)。
116
149
  * Windows:`taskkill /F /T /PID`(/T 连整棵子树);POSIX:`kill(-pid)`(依赖 spawn 时 detached 自成进程组)。
117
- * 导入点不变:domain/repoland.js 继续 re-export 这个名字,老调用方一个字都不用改。 */
118
- function killProcessTree(pid) {
150
+ * 导入点不变:domain/repoland.js 继续 re-export 这个名字,老调用方一个字都不用改。
151
+ *
152
+ * 2026-09-26(本次改错):POSIX 分支的兜底以前是"进程组杀不掉就直接 `kill(pid)`"——那等于承认
153
+ * **孙进程必然残留**(-pid 抛错只说明这个进程组不存在:子进程没成组、或已 setsid 带走了自己)。
154
+ * 现在兜底改成"按 /proc 的 ppid 链**从叶子往根**逐个 SIGKILL",杀不掉任何一个才返回 false
155
+ * (返回值语义不变:仍然是"有没有成功发出过 kill")。Windows 分支一个字没动。
156
+ * deps(platform/kill/…)只为单测注入 POSIX 分支,生产调用不传 → 行为与旧代码一致。 */
157
+ function killProcessTree(pid, deps = {}) {
119
158
  if (typeof pid !== 'number' || pid <= 0) return false
159
+ const platform = deps.platform ?? process.platform
160
+ const kill = deps.kill ?? process.kill
120
161
  try {
121
- if (process.platform === 'win32') {
162
+ if (platform === 'win32') {
122
163
  spawnSync('taskkill', ['/F', '/T', '/PID', String(pid)], { windowsHide: true, timeout: 15000 })
123
- } else {
124
- try { process.kill(-pid, 'SIGKILL') } catch { try { process.kill(pid, 'SIGKILL') } catch { return false } }
164
+ return true
125
165
  }
126
- return true
166
+ try { kill(-pid, 'SIGKILL'); return true } catch {}
167
+ // 进程组不存在/无权限 → 兜底按进程树逐个杀(叶子先杀,父最后杀)
168
+ let killed = false
169
+ for (const child of posixDescendants(pid, deps).reverse()) {
170
+ try { kill(child, 'SIGKILL'); killed = true } catch {}
171
+ }
172
+ try { kill(pid, 'SIGKILL'); killed = true } catch {}
173
+ return killed
127
174
  } catch { return false }
128
175
  }
129
176
 
@@ -135,7 +182,7 @@ function killProcessTree(pid) {
135
182
  * 成功路径的 resolve 形状({stdout, stderr})与既有异常字段(message/stderr/stdout/code/killed/signal)
136
183
  * 保持一致,调用方无需改动;`detached` 仅为 POSIX 成组(Windows 上保持 false,避免弹新控制台窗口)。 */
137
184
  function execFileWithKillTree(bin, argv, opts = {}, deps = {}) {
138
- const { killTree = killProcessTree, spawnFn = spawn } = deps
185
+ const { killTree = killProcessTree, spawnFn = spawn, platform = process.platform } = deps
139
186
  const timeout = Number.isFinite(opts.timeout) && opts.timeout > 0 ? opts.timeout : 0
140
187
  const maxBuffer = Number.isFinite(opts.maxBuffer) && opts.maxBuffer > 0 ? opts.maxBuffer : 1024 * 1024
141
188
  const signal = opts.signal ?? null
@@ -188,7 +235,7 @@ function execFileWithKillTree(bin, argv, opts = {}, deps = {}) {
188
235
  cwd: opts.cwd,
189
236
  env: opts.env,
190
237
  windowsHide: opts.windowsHide !== false,
191
- detached: process.platform !== 'win32', // POSIX:自成进程组,-pid 才杀得掉整棵树
238
+ detached: platform !== 'win32', // POSIX:自成进程组,-pid 才杀得掉整棵树(platform 可注入,见单测)
192
239
  })
193
240
  } catch (error) { finish(error, null); return }
194
241
  const overflow = () => {
@@ -267,10 +314,15 @@ function pnpmAddArgs(spec, registry, { fetchFlags = true, fetchTimeoutMs = 60000
267
314
  return args
268
315
  }
269
316
 
270
- /** 该版本 pnpm 不认识我们加的选项(真实文案:`[ERROR] Unknown options: 'fetch-timeout', 'fetch-retries'`)。
317
+ /** 该版本 pnpm 不认识我们加的选项。两代真实文案都要认(2026-09-26 CI 实测,缺一个就等于"装不上"):
318
+ * · pnpm 11(本机 11.21.0):`[ERROR] Unknown options: 'fetch-timeout', 'fetch-retries'`
319
+ * · pnpm 12(corepack 默认已解析到 12.6.0,Linux CI 实测 exit code=2)改成 clap 风格:
320
+ * `error: unexpected argument '--fetch-timeout' found` + `Usage: pnpm add --registry <REGISTRY> <PACKAGE_NAMES>...`
321
+ * 为什么必须补上第二条:判据漏了 pnpm 12 的文案 → 降级分支永不触发 → **装了 pnpm 12 的机器
322
+ * 任何插件都装不上**(`pnpm add --fetch-timeout` 直接 exit 2),而这正好是我们自己加固出来的失败。
271
323
  * 加固**绝不能让用户装不上**:调用方看到这个错误就去掉加固选项重试一次(见 runPnpmAdd)。 */
272
324
  function unknownPnpmOption(message) {
273
- return /Unknown option/iu.test(String(message ?? ''))
325
+ return /Unknown option|unexpected argument/iu.test(String(message ?? ''))
274
326
  }
275
327
 
276
328
  /** 跑一次 `pnpm add`(本控制台所有安装通道的唯一入口)—— 2026-09-26 新增。
@@ -307,4 +359,4 @@ const GH_BIN_CANDIDATES = [
307
359
  join(homedir(), 'scoop', 'shims', 'gh.exe'),
308
360
  ]
309
361
 
310
- export { GH_BIN_CANDIDATES, gitEnv, gitBin, processAlive, execFileAsync, resolvePnpmRunners, runPnpmWithFallback, killProcessTree, execFileWithKillTree, pnpmEnvOverrides, buildPnpmEnv, pnpmFetchArgs, pnpmAddArgs, unknownPnpmOption, runPnpmAdd }
362
+ export { GH_BIN_CANDIDATES, gitEnv, gitBin, processAlive, execFileAsync, resolvePnpmRunners, runPnpmWithFallback, killProcessTree, posixDescendants, execFileWithKillTree, pnpmEnvOverrides, buildPnpmEnv, pnpmFetchArgs, pnpmAddArgs, unknownPnpmOption, runPnpmAdd }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.15",
3
+ "version": "0.5.16",
4
4
  "description": "DSH 框架升级安全与插件升级门控:一键升级、失败自动回滚、升级后回滚上版、旧插件不适配自动禁用;内置多源插件市场为发现层,插件源全部可自定义,可指向公司内网私有源 / 私有索引 / 本地 Git 仓库,纯内网离线可用 | Framework upgrade safety & plugin version gating for DSH, with a customizable multi-source plugin market: point every source at internal mirrors or a local file:// repo for intranet-only, offline installs.",
5
5
  "repository": {
6
6
  "type": "git",