@noob-stupid/dsh-plugin-console 0.5.12 → 0.5.14
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/client.js +152 -6
- package/lib/server/domain/ai-run.js +4 -3
- package/lib/server/domain/components.js +1 -1
- package/lib/server/domain/install-diagnose.js +113 -0
- package/lib/server/domain/install-job.js +22 -6
- package/lib/server/domain/install-verify.js +221 -0
- package/lib/server/domain/install.js +19 -21
- package/lib/server/domain/repoland.js +103 -33
- package/lib/server/domain/skills.js +2 -2
- package/lib/server/infra/exec.js +208 -6
- package/package.json +1 -1
package/lib/server/infra/exec.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// 由 Step 1 搬运工具从 lib/index.js 原样切出(只移动、未改逻辑)
|
|
2
2
|
// 分组见 D:\dsh\dsh-plugin-hub-plan\architecture.zh.md §三 L0 · infra
|
|
3
3
|
|
|
4
|
-
import { execFile } from 'node:child_process'
|
|
4
|
+
import { execFile, spawn, spawnSync } from 'node:child_process'
|
|
5
5
|
import { promisify } from 'node:util'
|
|
6
6
|
import { existsSync } from 'node:fs'
|
|
7
7
|
import { dirname, join } from 'node:path'
|
|
@@ -50,21 +50,32 @@ function resolvePnpmRunners({ platform = process.platform, execPath = process.ex
|
|
|
50
50
|
* 真正的安装失败(网络、依赖冲突等)立即抛出,并附上已尝试的清单便于排查。
|
|
51
51
|
*
|
|
52
52
|
* 2026-09-24:抛错时必须**带上 pnpm 的原始 stderr** —— 之前只留 `Command failed: <命令行>`,
|
|
53
|
-
* 界面与测试里都看不到真实原因(CI 的「真装真卸冒烟」就是这么红的:只知道失败、不知道为什么)。
|
|
54
|
-
|
|
53
|
+
* 界面与测试里都看不到真实原因(CI 的「真装真卸冒烟」就是这么红的:只知道失败、不知道为什么)。
|
|
54
|
+
*
|
|
55
|
+
* 2026-09-26(本次):执行器由 `execFileAsync` 换成 `execFileWithKillTree`(**只换失败路径的实现**)——
|
|
56
|
+
* pnpm 的 fetch 超时以前只 kill 父进程,pnpm 派生的 git / tar / node-gyp / 子 pnpm 会变成孤儿继续跑,
|
|
57
|
+
* 占着 node_modules 与 .git 里的文件;现在超时/中断即杀整棵树。成功路径返回值与异常形状不变。
|
|
58
|
+
* `exec` 是给单测留的注入口(默认真实实现),生产调用方不需要传。 */
|
|
59
|
+
async function runPnpmWithFallback(args, { execOpts = {}, runners = resolvePnpmRunners(), exec = execFileWithKillTree } = {}) {
|
|
55
60
|
let lastError = null
|
|
56
61
|
const withStderr = (error) => {
|
|
57
62
|
const stderr = typeof error?.stderr === 'string' ? error.stderr.trim() : ''
|
|
58
63
|
const stdout = typeof error?.stdout === 'string' ? error.stdout.trim() : ''
|
|
59
64
|
if (stderr === '' && stdout === '') return error
|
|
60
65
|
const detail = [stderr, stdout].filter((s) => s !== '').join(' / ').split(/\r?\n/u).slice(-6).join(' ⏎ ').slice(0, 800)
|
|
61
|
-
|
|
66
|
+
const wrapped = new Error(`${String(error?.message ?? 'pnpm 执行失败')}|真实输出:${detail}`)
|
|
67
|
+
// 保真:包装新 Error 时别把"结构性信息"丢掉(是不是超时/被杀、哪个 pid、哪个退出码)。
|
|
68
|
+
// 丢了这些字段,上层就无法只对"网络类超时"做定向长超时重试(见 domain/install-diagnose.js)。
|
|
69
|
+
for (const key of ['killed', 'timedOut', 'timeoutMs', 'signal', 'code', 'pid', 'cmd']) {
|
|
70
|
+
if (error?.[key] !== undefined) wrapped[key] = error[key]
|
|
71
|
+
}
|
|
72
|
+
return wrapped
|
|
62
73
|
}
|
|
63
74
|
for (const runner of runners) {
|
|
64
75
|
const { bin, argv } = runner.run(args)
|
|
65
76
|
try {
|
|
66
77
|
// eslint-disable-next-line no-await-in-loop
|
|
67
|
-
await
|
|
78
|
+
await exec(bin, argv, execOpts)
|
|
68
79
|
return { runner }
|
|
69
80
|
} catch (error) {
|
|
70
81
|
lastError = withStderr(error)
|
|
@@ -96,6 +107,197 @@ function processAlive(pid) {
|
|
|
96
107
|
}
|
|
97
108
|
}
|
|
98
109
|
const execFileAsync = promisify(execFile)
|
|
110
|
+
|
|
111
|
+
/** 结束**整棵**进程树(2026-09-26 从 domain/repoland.js 移入 infra —— pnpm 通道也要用同一份实现,
|
|
112
|
+
* 不能让 git 通道和 pnpm 通道各写一份、同一个坑各踩一次)。
|
|
113
|
+
* 超时/中断后必须做:git 会派生 remote-https / index-pack,pnpm 会派生 git / tar / node-gyp / 子 pnpm,
|
|
114
|
+
* 只 kill 父进程会留下孤儿继续占着 .git 与 node_modules 里的文件(2026-09-26 真机实测占住
|
|
115
|
+
* pack 临时文件与 shallow.lock,Windows 下直接导致目录删不掉)。
|
|
116
|
+
* Windows:`taskkill /F /T /PID`(/T 连整棵子树);POSIX:`kill(-pid)`(依赖 spawn 时 detached 自成进程组)。
|
|
117
|
+
* 导入点不变:domain/repoland.js 继续 re-export 这个名字,老调用方一个字都不用改。 */
|
|
118
|
+
function killProcessTree(pid) {
|
|
119
|
+
if (typeof pid !== 'number' || pid <= 0) return false
|
|
120
|
+
try {
|
|
121
|
+
if (process.platform === 'win32') {
|
|
122
|
+
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 } }
|
|
125
|
+
}
|
|
126
|
+
return true
|
|
127
|
+
} catch { return false }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 带"超时/中断即杀整棵进程树"的 execFile 替身(2026-09-26 新增,**只加不改**:`execFileAsync` 原样保留,
|
|
131
|
+
* curl / tar / gh 等调用点继续用它)。
|
|
132
|
+
* 与 execFileAsync 的差别**只在失败路径**:① 超时(opts.timeout)② AbortSignal 中断 —— 两条都先
|
|
133
|
+
* `killProcessTree(child.pid)` 收掉整棵树,再把"超时多少毫秒 + 已终止的 pid"写进错误消息
|
|
134
|
+
* (旧代码超时只 kill 父进程,报错也只有一句 `Command failed: …`,用户与日志都看不出发生了什么)。
|
|
135
|
+
* 成功路径的 resolve 形状({stdout, stderr})与既有异常字段(message/stderr/stdout/code/killed/signal)
|
|
136
|
+
* 保持一致,调用方无需改动;`detached` 仅为 POSIX 成组(Windows 上保持 false,避免弹新控制台窗口)。 */
|
|
137
|
+
function execFileWithKillTree(bin, argv, opts = {}, deps = {}) {
|
|
138
|
+
const { killTree = killProcessTree, spawnFn = spawn } = deps
|
|
139
|
+
const timeout = Number.isFinite(opts.timeout) && opts.timeout > 0 ? opts.timeout : 0
|
|
140
|
+
const maxBuffer = Number.isFinite(opts.maxBuffer) && opts.maxBuffer > 0 ? opts.maxBuffer : 1024 * 1024
|
|
141
|
+
const signal = opts.signal ?? null
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
const cmd = `${bin} ${argv.join(' ')}`
|
|
144
|
+
let child = null
|
|
145
|
+
let timer = null
|
|
146
|
+
let settled = false
|
|
147
|
+
let stdout = ''
|
|
148
|
+
let stderr = ''
|
|
149
|
+
const baseError = () => {
|
|
150
|
+
const err = new Error(stderr.trim() === '' ? `Command failed: ${cmd}` : `Command failed: ${cmd}\n${stderr.trim()}`)
|
|
151
|
+
err.cmd = cmd
|
|
152
|
+
err.stdout = stdout
|
|
153
|
+
err.stderr = stderr
|
|
154
|
+
return err
|
|
155
|
+
}
|
|
156
|
+
const killTreeNow = () => {
|
|
157
|
+
try { return killTree(child?.pid) } catch { return false }
|
|
158
|
+
}
|
|
159
|
+
/** 超时/中断错误:必须说清"已经终止了整棵树、pid 是多少",否则用户只能看到一句 Command failed。 */
|
|
160
|
+
const killedError = (reason) => {
|
|
161
|
+
const err = baseError()
|
|
162
|
+
err.killed = true
|
|
163
|
+
err.signal = 'SIGTERM'
|
|
164
|
+
err.timedOut = reason === 'timeout'
|
|
165
|
+
err.timeoutMs = timeout
|
|
166
|
+
err.pid = child?.pid ?? null
|
|
167
|
+
err.message += `\n(${reason === 'timeout' ? `超时 ${timeout}ms` : '收到中断信号'}:已终止整棵进程树 pid=${child?.pid ?? '?'})`
|
|
168
|
+
return err
|
|
169
|
+
}
|
|
170
|
+
const cleanup = () => {
|
|
171
|
+
if (timer !== null) { clearTimeout(timer); timer = null }
|
|
172
|
+
if (signal !== null && typeof signal.removeEventListener === 'function') signal.removeEventListener('abort', onAbort)
|
|
173
|
+
}
|
|
174
|
+
const finish = (error, value) => {
|
|
175
|
+
if (settled) return
|
|
176
|
+
settled = true
|
|
177
|
+
cleanup()
|
|
178
|
+
if (error === null) resolve(value)
|
|
179
|
+
else reject(error)
|
|
180
|
+
}
|
|
181
|
+
const onAbort = () => { killTreeNow(); finish(killedError('abort'), null) }
|
|
182
|
+
if (signal !== null) {
|
|
183
|
+
if (signal.aborted === true) { finish(killedError('abort'), null); return }
|
|
184
|
+
if (typeof signal.addEventListener === 'function') signal.addEventListener('abort', onAbort, { once: true })
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
child = spawnFn(bin, argv, {
|
|
188
|
+
cwd: opts.cwd,
|
|
189
|
+
env: opts.env,
|
|
190
|
+
windowsHide: opts.windowsHide !== false,
|
|
191
|
+
detached: process.platform !== 'win32', // POSIX:自成进程组,-pid 才杀得掉整棵树
|
|
192
|
+
})
|
|
193
|
+
} catch (error) { finish(error, null); return }
|
|
194
|
+
const overflow = () => {
|
|
195
|
+
killTreeNow()
|
|
196
|
+
const err = baseError()
|
|
197
|
+
err.code = 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
|
|
198
|
+
err.message = `stdout maxBuffer length exceeded\n${err.message}`
|
|
199
|
+
finish(err, null)
|
|
200
|
+
}
|
|
201
|
+
child.stdout?.on?.('data', (chunk) => { stdout += String(chunk); if (stdout.length + stderr.length > maxBuffer) overflow() })
|
|
202
|
+
child.stderr?.on?.('data', (chunk) => { stderr += String(chunk); if (stdout.length + stderr.length > maxBuffer) overflow() })
|
|
203
|
+
child.on('error', (error) => { finish(error, null) })
|
|
204
|
+
child.on('close', (code) => {
|
|
205
|
+
if (code === 0) { finish(null, { stdout, stderr }); return }
|
|
206
|
+
const err = baseError()
|
|
207
|
+
err.code = typeof code === 'number' ? code : null
|
|
208
|
+
err.killed = false
|
|
209
|
+
err.signal = null
|
|
210
|
+
finish(err, null)
|
|
211
|
+
})
|
|
212
|
+
if (timeout > 0) timer = setTimeout(() => { killTreeNow(); finish(killedError('timeout'), null) }, timeout)
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** pnpm 健壮性环境覆盖(纯函数,单测可直接断言;2026-09-26 新增)。
|
|
217
|
+
* 只**追加**覆盖项,不动其它 env —— registry/镜像与 gitEnv() 的禁交互逻辑照旧,
|
|
218
|
+
* 用户自定义源/内网源不受影响:
|
|
219
|
+
* · npm_config_fetch_timeout=60000:单次 fetch 60s 封顶(避免被环境里的 0/极大值带成"卡到天荒地老");
|
|
220
|
+
* · npm_config_fetch_retries=1:抖动重试 1 次(真正的兜底是超时后由 domain/install-diagnose.js
|
|
221
|
+
* 判定的**一次**定向长超时重试);
|
|
222
|
+
* · CI=true:非交互(无 TTY 的桌面壳/子进程里,pnpm 的交互提示等不到输入就会一直挂住)。
|
|
223
|
+
*
|
|
224
|
+
* ⚠️ 实测(2026-09-26,pnpm 11.21.0 + 本地"只挂连接、永不响应"的假 registry,见 tests/test-pnpm-env.mjs):
|
|
225
|
+
* 这两个 `npm_config_fetch_*` 键 pnpm 11 **不读**(同环境下 `npm_config_registry` 会被读到,
|
|
226
|
+
* 所以不是 env 前缀的问题,是这两个键不在 pnpm 的 env 白名单里);
|
|
227
|
+
* 真正生效的是 `pnpm add --fetch-timeout / --fetch-retries` 两个 CLI 选项(见 pnpmFetchArgs)。
|
|
228
|
+
* 仍然照需求注入这三个 env(CI 与非交互确实有用、registry 镜像照旧、fetch_* 是 npm 系标准写法,
|
|
229
|
+
* 对 npm/后续 pnpm 版本仍可能生效),但**不把健壮性只押在 env 上**。 */
|
|
230
|
+
function pnpmEnvOverrides(registry) {
|
|
231
|
+
const overrides = {
|
|
232
|
+
npm_config_fetch_timeout: '60000',
|
|
233
|
+
npm_config_fetch_retries: '1',
|
|
234
|
+
CI: 'true',
|
|
235
|
+
}
|
|
236
|
+
if (typeof registry === 'string' && registry !== '') overrides.COREPACK_NPM_REGISTRY = registry
|
|
237
|
+
return overrides
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** pnpm 调用的完整 env(在调用方 base 之上覆盖;默认取 process.env)。
|
|
241
|
+
* registry 只在是**非空字符串**时才写 COREPACK_NPM_REGISTRY(git 通道不传 registry,写 undefined 无意义)。 */
|
|
242
|
+
function buildPnpmEnv(registry, base = process.env) {
|
|
243
|
+
return {
|
|
244
|
+
...base,
|
|
245
|
+
// git 通道禁止交互式凭据(与 gitEnv 同一语义):避免 Git Credential Manager 弹登录窗
|
|
246
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
247
|
+
GCM_INTERACTIVE: 'never',
|
|
248
|
+
...pnpmEnvOverrides(registry),
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** pnpm add 的健壮性选项。**只给 add**:实测 `pnpm remove --fetch-timeout=…` 会
|
|
253
|
+
* `[ERROR] Unknown options: 'fetch-timeout', 'fetch-retries'`(exit 1),所以绝不能全局加。
|
|
254
|
+
* 实测有效性:指向「只挂连接不响应」的假 registry 时,带 `--fetch-timeout=4000` 约 4.8s 退出,
|
|
255
|
+
* 不带给到 25s 仍未退出(走 pnpm 默认 60s);仅靠 env 在 pnpm 11 上无效(见 pnpmEnvOverrides 注释)。 */
|
|
256
|
+
function pnpmFetchArgs(timeoutMs = 60000, retries = 1) {
|
|
257
|
+
return [`--fetch-timeout=${timeoutMs}`, `--fetch-retries=${retries}`]
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** pnpm add 的完整参数(纯函数,单测覆盖)。registry 只在是**非空字符串**时才拼 ——
|
|
261
|
+
* 旧代码无条件拼 `--registry ${registry}`,`git:` 规格那条路传的是 undefined,命令行长成
|
|
262
|
+
* `--registry undefined`(今天恰好还能装,但那是"垃圾参数被忽略",不是设计)。 */
|
|
263
|
+
function pnpmAddArgs(spec, registry, { fetchFlags = true, fetchTimeoutMs = 60000, fetchRetries = 1 } = {}) {
|
|
264
|
+
const args = ['add', spec]
|
|
265
|
+
if (typeof registry === 'string' && registry !== '') args.push('--registry', registry)
|
|
266
|
+
if (fetchFlags) args.push(...pnpmFetchArgs(fetchTimeoutMs, fetchRetries))
|
|
267
|
+
return args
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** 该版本 pnpm 不认识我们加的选项(真实文案:`[ERROR] Unknown options: 'fetch-timeout', 'fetch-retries'`)。
|
|
271
|
+
* 加固**绝不能让用户装不上**:调用方看到这个错误就去掉加固选项重试一次(见 runPnpmAdd)。 */
|
|
272
|
+
function unknownPnpmOption(message) {
|
|
273
|
+
return /Unknown option/iu.test(String(message ?? ''))
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** 跑一次 `pnpm add`(本控制台所有安装通道的唯一入口)—— 2026-09-26 新增。
|
|
277
|
+
* 相对旧的 `runPnpmWithFallback(['add', spec, '--registry', registry], { execOpts })`,只加三样:
|
|
278
|
+
* ① env 走 buildPnpmEnv(追加 fetch 超时/重试 + CI=true;registry 镜像与禁交互照旧);
|
|
279
|
+
* ② 参数走 pnpmAddArgs(追加 `--fetch-timeout/--fetch-retries`:实测 pnpm 11 不读同名 env,只认这两个选项);
|
|
280
|
+
* ③ 遇到 `Unknown options` 就去掉加固选项重试一次 —— 加固不允许变成"装不上"。
|
|
281
|
+
* 失败语义不变:真实失败照旧抛出(含 pnpm 原始 stderr),成功不返回值。
|
|
282
|
+
* `deps.run` 只是单测注入口(默认 runPnpmWithFallback),生产调用方不传。 */
|
|
283
|
+
async function runPnpmAdd({ profileDir, spec, registry, timeout = 90000, signal = null, deps = {} } = {}) {
|
|
284
|
+
const run = typeof deps.run === 'function' ? deps.run : runPnpmWithFallback
|
|
285
|
+
const execOpts = (ms) => ({
|
|
286
|
+
cwd: profileDir,
|
|
287
|
+
timeout: ms,
|
|
288
|
+
windowsHide: true,
|
|
289
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
290
|
+
env: buildPnpmEnv(registry),
|
|
291
|
+
...(signal ? { signal } : {}),
|
|
292
|
+
})
|
|
293
|
+
try {
|
|
294
|
+
await run(pnpmAddArgs(spec, registry), { execOpts: execOpts(timeout) })
|
|
295
|
+
} catch (error) {
|
|
296
|
+
if (!unknownPnpmOption(error?.message)) throw error
|
|
297
|
+
await run(pnpmAddArgs(spec, registry, { fetchFlags: false }), { execOpts: execOpts(timeout) })
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
99
301
|
/** gh CLI 通道:api.github.com 黑洞期(node:https 全部超时)时的最后兜底。
|
|
100
302
|
* 服务进程 PATH 可能不含 gh(桌面壳环境):依次尝试 gh、常见安装路径。 */
|
|
101
303
|
const GH_BIN_CANDIDATES = [
|
|
@@ -105,4 +307,4 @@ const GH_BIN_CANDIDATES = [
|
|
|
105
307
|
join(homedir(), 'scoop', 'shims', 'gh.exe'),
|
|
106
308
|
]
|
|
107
309
|
|
|
108
|
-
export { GH_BIN_CANDIDATES, gitEnv, gitBin, processAlive, execFileAsync, resolvePnpmRunners, runPnpmWithFallback }
|
|
310
|
+
export { GH_BIN_CANDIDATES, gitEnv, gitBin, processAlive, execFileAsync, resolvePnpmRunners, runPnpmWithFallback, killProcessTree, 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.14",
|
|
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",
|