@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.
- package/lib/server/infra/exec.js +64 -12
- package/package.json +1 -1
package/lib/server/infra/exec.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
162
|
+
if (platform === 'win32') {
|
|
122
163
|
spawnSync('taskkill', ['/F', '/T', '/PID', String(pid)], { windowsHide: true, timeout: 15000 })
|
|
123
|
-
|
|
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:
|
|
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
|
|
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.
|
|
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",
|