@noob-stupid/dsh-plugin-console 0.5.14 → 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/client.js +95 -8
- package/lib/server/domain/dep-source.js +4 -0
- package/lib/server/domain/framework.js +89 -2
- package/lib/server/domain/install-diagnose.js +76 -6
- package/lib/server/domain/lockfile-health.js +433 -0
- package/lib/server/domain/repoland.js +141 -46
- package/lib/server/domain/sources.js +29 -1
- package/lib/server/infra/exec.js +64 -12
- package/lib/server/infra/fsx.js +50 -1
- package/lib/server/routes/framework-upgrade.js +2 -1
- package/lib/server/routes/framework.js +26 -2
- package/lib/server/routes/index.js +4 -0
- package/lib/server/routes/lockfile.js +54 -0
- package/package.json +1 -1
|
@@ -5,10 +5,10 @@ import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, rename
|
|
|
5
5
|
import { spawn } from 'node:child_process'
|
|
6
6
|
import { dirname, join } from 'node:path'
|
|
7
7
|
import { homedir } from 'node:os'
|
|
8
|
-
import {
|
|
8
|
+
import { gitCloneCandidates } from './sources.js'
|
|
9
9
|
import { execFileAsync, gitEnv, killProcessTree } from '../infra/exec.js'
|
|
10
|
-
import {
|
|
11
|
-
import { repoLandConfFile } from '../infra/paths.js'
|
|
10
|
+
import { removeDirVerifiedWithRetry } from '../infra/fsx.js'
|
|
11
|
+
import { dshHome, repoLandConfFile } from '../infra/paths.js'
|
|
12
12
|
|
|
13
13
|
/** 仓库落地根目录(可配置,默认 ~/.dsh/repos)。 */
|
|
14
14
|
let reposDirCache = null
|
|
@@ -71,6 +71,44 @@ function gitErrorDetail(error) {
|
|
|
71
71
|
.join(' | ')
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/** 「上次成功的 git 源」记忆(2026-09-26 加法):进程内缓存 + 状态文件双保险(两个实例/重启后依然有效)。
|
|
75
|
+
* 为什么需要:本机直连 github.com 不通(curl 000)→ 探活会把直连跳过,**唯一可用源就是 ghproxy**;
|
|
76
|
+
* 一旦 ghproxy 超时,整次安装就彻底失败。记住上次成功的源并优先使用,能省掉一轮无谓的探活/失败等待。 */
|
|
77
|
+
const GIT_SOURCE_MEMO_FILE = () => join(dshHome(), 'plugin-console', 'git-source-memo.json')
|
|
78
|
+
let gitSourceMemoCache = null
|
|
79
|
+
|
|
80
|
+
function readGitSourceMemo() {
|
|
81
|
+
if (gitSourceMemoCache !== null) return gitSourceMemoCache
|
|
82
|
+
try {
|
|
83
|
+
const raw = JSON.parse(readFileSync(GIT_SOURCE_MEMO_FILE(), 'utf8'))
|
|
84
|
+
gitSourceMemoCache = typeof raw?.template === 'string' ? raw.template : ''
|
|
85
|
+
} catch {
|
|
86
|
+
gitSourceMemoCache = ''
|
|
87
|
+
}
|
|
88
|
+
return gitSourceMemoCache
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function rememberGitSource(template) {
|
|
92
|
+
const tpl = String(template ?? '')
|
|
93
|
+
if (tpl === '') return
|
|
94
|
+
gitSourceMemoCache = tpl
|
|
95
|
+
try {
|
|
96
|
+
mkdirSync(dirname(GIT_SOURCE_MEMO_FILE()), { recursive: true })
|
|
97
|
+
writeFileSync(GIT_SOURCE_MEMO_FILE(), JSON.stringify({ template: tpl, at: Date.now() }, null, 2), 'utf8')
|
|
98
|
+
} catch {}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 把「上次成功过的源」提到最前(其余顺序不变)。纯函数,单测覆盖。 */
|
|
102
|
+
function orderGitCandidates(candidates, preferredTemplate) {
|
|
103
|
+
const list = Array.isArray(candidates) ? [...candidates] : []
|
|
104
|
+
const tpl = typeof preferredTemplate === 'string' ? preferredTemplate : ''
|
|
105
|
+
if (tpl === '') return list
|
|
106
|
+
const at = list.findIndex((c) => c !== null && c !== undefined && c.urlTemplate === tpl)
|
|
107
|
+
if (at <= 0) return list
|
|
108
|
+
const [hit] = list.splice(at, 1)
|
|
109
|
+
return [hit, ...list]
|
|
110
|
+
}
|
|
111
|
+
|
|
74
112
|
/** 逐条尝试的错误汇总(纯函数,单测覆盖):报**第一个**错误(真实原因)+ 尝试清单。
|
|
75
113
|
* 2026-09-26 真机(官方桌面端里装 git 源插件):ghproxy 卡死 → 我们的超时到了但**没杀 git 进程**,
|
|
76
114
|
* 于是 `git clone` / `git remote-https` / `index-pack --shallow-file …\.git\shallow.lock` 常驻,
|
|
@@ -81,6 +119,7 @@ function summarizeCloneErrors(errors) {
|
|
|
81
119
|
const tried = errors.map((e) => {
|
|
82
120
|
if (e.unclean === true) return `${e.url}(残留目录被占用,已跳过重试)`
|
|
83
121
|
if (e.skipped === true) return `${e.url}(探活失败,已跳过)`
|
|
122
|
+
if (e.retrying === true) return `${e.url}(超时,已改用更长超时重试)`
|
|
84
123
|
if (e.timedOut === true) return `${e.url}(超时,进程已结束)`
|
|
85
124
|
return /already exists and is not an empty directory/u.test(e.message) ? `${e.url}(目录非空)` : e.url
|
|
86
125
|
}).join(';')
|
|
@@ -89,7 +128,8 @@ function summarizeCloneErrors(errors) {
|
|
|
89
128
|
const stuckNote = stuck.length === 0
|
|
90
129
|
? ''
|
|
91
130
|
: `;注意:${stuck[0].message}。可手动删除后重试:Remove-Item -Recurse -Force '${stuck[0].dir}'`
|
|
92
|
-
|
|
131
|
+
const sourceCount = new Set(errors.map((e) => e.url)).size
|
|
132
|
+
return `git clone 失败(首个错误:${first?.message ?? '未知'}${detail !== '' ? `;git 说:${detail}` : ''});已尝试 ${sourceCount} 个源(共 ${errors.length} 次尝试):${tried}${stuckNote}`
|
|
93
133
|
}
|
|
94
134
|
|
|
95
135
|
/** 结束**整棵**进程树。超时/中断后必须做:git 会派生 remote-https / index-pack 子进程,
|
|
@@ -97,10 +137,26 @@ function summarizeCloneErrors(errors) {
|
|
|
97
137
|
* 2026-09-26(本次):实现搬进 infra/exec.js(pnpm 通道复用同一份,domain 不再各留一份拷贝),
|
|
98
138
|
* 这里保留同名 re-export —— **对外导出名与调用点一个字都没变**。 */
|
|
99
139
|
|
|
100
|
-
/**
|
|
101
|
-
|
|
140
|
+
/** 等子进程真的退出(轮询 exitCode,并监听 close/exit),超时返回 false。
|
|
141
|
+
* 为什么必须有:Windows 上「taskkill /T /F 返回了」≠「句柄已经释放」——删目录要在进程真的没了之后再动手,
|
|
142
|
+
* 否则会出现「杀树明明成功(git 进程 0)却报残留被占用」的假失败(2026-09-26 真机)。 */
|
|
143
|
+
function waitChildExit(child, timeoutMs, pollMs = 60) {
|
|
102
144
|
return new Promise((resolve) => {
|
|
145
|
+
if (child === null || child === undefined) { resolve(true); return }
|
|
146
|
+
if (typeof child.exitCode === 'number') { resolve(true); return }
|
|
103
147
|
let settled = false
|
|
148
|
+
const finish = (ok) => { if (!settled) { settled = true; clearTimeout(timer); clearInterval(ticker); resolve(ok) } }
|
|
149
|
+
const timer = setTimeout(() => finish(false), timeoutMs)
|
|
150
|
+
const ticker = setInterval(() => { if (child.exitCode !== undefined && child.exitCode !== null) finish(true) }, pollMs)
|
|
151
|
+
try { child.on?.('close', () => finish(true)); child.on?.('exit', () => finish(true)) } catch {}
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 跑一次 git clone:支持超时,且**超时即杀掉整棵树并等它真的退出**。返回 { code, stderr, timedOut, pid, exited }。 */
|
|
156
|
+
function runGitClone(url, dest, timeout, { spawnFn = spawn, killTree = killProcessTree, exitWaitMs = 800, exitWaitMs2 = 300 } = {}) {
|
|
157
|
+
return new Promise((resolve) => {
|
|
158
|
+
let settled = false
|
|
159
|
+
let timedOut = false
|
|
104
160
|
const finish = (payload) => { if (!settled) { settled = true; resolve(payload) } }
|
|
105
161
|
let child = null
|
|
106
162
|
let stderr = ''
|
|
@@ -111,16 +167,24 @@ function runGitClone(url, dest, timeout, { spawnFn = spawn, killTree = killProce
|
|
|
111
167
|
detached: process.platform !== 'win32', // POSIX:自成进程组,便于 -pid 整体杀
|
|
112
168
|
})
|
|
113
169
|
} catch (error) {
|
|
114
|
-
finish({ code: -1, stderr: String(error?.message ?? error), timedOut: false, pid: null })
|
|
170
|
+
finish({ code: -1, stderr: String(error?.message ?? error), timedOut: false, pid: null, exited: true })
|
|
115
171
|
return
|
|
116
172
|
}
|
|
117
|
-
const timer = setTimeout(() => {
|
|
173
|
+
const timer = setTimeout(async () => {
|
|
174
|
+
timedOut = true
|
|
118
175
|
killTree(child.pid)
|
|
119
|
-
|
|
176
|
+
// ① 先等整棵树退出(句柄释放)② 还没退就再杀一次、再等(Windows 上偶发第一次 taskkill 未落地)
|
|
177
|
+
let exited = await waitChildExit(child, exitWaitMs)
|
|
178
|
+
if (!exited) {
|
|
179
|
+
try { killTree(child.pid) } catch {}
|
|
180
|
+
exited = await waitChildExit(child, exitWaitMs2)
|
|
181
|
+
}
|
|
182
|
+
finish({ code: -1, stderr: stderr.trim(), timedOut: true, pid: child.pid, exited })
|
|
120
183
|
}, timeout)
|
|
121
184
|
child.stderr?.on?.('data', (chunk) => { stderr += String(chunk) })
|
|
122
|
-
child.on('error', (error) => { clearTimeout(timer); finish({ code: -1, stderr: String(error?.message ?? error), timedOut: false, pid: child.pid }) })
|
|
123
|
-
|
|
185
|
+
child.on('error', (error) => { clearTimeout(timer); if (timedOut) return; finish({ code: -1, stderr: String(error?.message ?? error), timedOut: false, pid: child.pid, exited: true }) })
|
|
186
|
+
// 超时分支自己收尾(要先把退出等完),这里的 close 不能再抢答
|
|
187
|
+
child.on('close', (code) => { clearTimeout(timer); if (timedOut) return; finish({ code: typeof code === 'number' ? code : -1, stderr: stderr.trim(), timedOut: false, pid: child.pid, exited: true }) })
|
|
124
188
|
})
|
|
125
189
|
}
|
|
126
190
|
|
|
@@ -132,58 +196,89 @@ async function probeSourceAlive(url, timeoutMs = 4000) {
|
|
|
132
196
|
} catch { return false }
|
|
133
197
|
}
|
|
134
198
|
|
|
135
|
-
/** git clone(镜像→直连;gitee 直连),返回 { url, attempt, dir } 或抛错。
|
|
199
|
+
/** git clone(镜像→直连;gitee 直连),返回 { url, attempt, dir, source, retried } 或抛错。
|
|
136
200
|
* 每次尝试都用**全新唯一目录** `.tryN`,成功后才 rename 到 dest —— 这样即使上一轮残留目录被占用,
|
|
137
|
-
* 也不会再出现"一个源失败 → 后面所有源都因目录非空而无效"的连锁失效。
|
|
201
|
+
* 也不会再出现"一个源失败 → 后面所有源都因目录非空而无效"的连锁失效。
|
|
202
|
+
* 2026-09-26 真机加法:① 超时杀树后**等进程真的退出**(runGitClone 内两轮 kill+wait);
|
|
203
|
+
* ② 失败后的清理走 removeDirVerifiedWithRetry(3 轮 × 250ms)——
|
|
204
|
+
* 杀树成功但 Windows 句柄晚一拍释放时,不再"一次定生死"、也不再误报"残留被占用";
|
|
205
|
+
* ③ 源策略:**同一个源超时后用更长超时(默认 1.75 倍)重试一次**,
|
|
206
|
+
* 并把**上次成功过的源**提到最前来试(本机直连 github 不通、唯一可用源就是 ghproxy,
|
|
207
|
+
* 一旦它超时就"整次安装彻底失败"——现在先重试它一次,再谈别的源)。 */
|
|
138
208
|
async function gitCloneRepo(repo, dest, source = 'github', timeout = 180000, deps = {}) {
|
|
139
209
|
const {
|
|
140
210
|
spawnFn = spawn,
|
|
141
211
|
killTree = killProcessTree,
|
|
142
212
|
probe = probeSourceAlive,
|
|
143
|
-
removeDir =
|
|
213
|
+
removeDir = removeDirVerifiedWithRetry,
|
|
144
214
|
renameDir = renameSync,
|
|
215
|
+
exitWaitMs = 800,
|
|
216
|
+
retryFactor = 1.75,
|
|
217
|
+
readMemo = readGitSourceMemo,
|
|
218
|
+
writeMemo = rememberGitSource,
|
|
145
219
|
} = deps
|
|
146
|
-
|
|
220
|
+
let preferred = ''
|
|
221
|
+
try { preferred = readMemo() } catch { preferred = '' }
|
|
222
|
+
const candidates = orderGitCandidates(gitCloneCandidates(repo, source), preferred)
|
|
223
|
+
const retryTimeout = Math.max(timeout + 1, Math.round(timeout * retryFactor))
|
|
147
224
|
const errors = []
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
225
|
+
let partSeq = 0
|
|
226
|
+
for (const [sourceIndex, cand] of candidates.entries()) {
|
|
227
|
+
const url = cand.url
|
|
151
228
|
const alive = await probe(url)
|
|
152
229
|
if (!alive) {
|
|
153
|
-
|
|
230
|
+
partSeq += 1
|
|
231
|
+
errors.push({ url, message: `源探活失败(连不上):${url}`, skipped: true, dir: `${dest}.try${partSeq}` })
|
|
154
232
|
continue
|
|
155
233
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
234
|
+
// 同一个源最多两次:正常超时 → 更长超时重试一次
|
|
235
|
+
for (let round = 0; round < 2; round += 1) {
|
|
236
|
+
partSeq += 1
|
|
237
|
+
const part = `${dest}.try${partSeq}`
|
|
238
|
+
const useTimeout = round === 0 ? timeout : retryTimeout
|
|
239
|
+
try { removeDir(part) } catch {}
|
|
240
|
+
// eslint-disable-next-line no-await-in-loop
|
|
241
|
+
const res = await runGitClone(url, part, useTimeout, { spawnFn, killTree, exitWaitMs })
|
|
242
|
+
if (res.code === 0) {
|
|
243
|
+
try {
|
|
244
|
+
removeDir(dest)
|
|
245
|
+
renameDir(part, dest)
|
|
246
|
+
try { writeMemo(cand.urlTemplate) } catch {}
|
|
247
|
+
return { url, attempt: sourceIndex + 1, tries: partSeq, dir: dest, source: cand.id, retried: round > 0 }
|
|
248
|
+
} catch (error) {
|
|
249
|
+
errors.push({ url, message: `克隆成功但落地失败(${error instanceof Error ? error.message : String(error)})`, dir: part })
|
|
250
|
+
break
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// 失败:先尽力清掉半成品目录(带重试的核实删除);清不掉就**如实说明**(不再谎报"环境禁止删除",
|
|
254
|
+
// 也不再把"句柄晚一拍释放"误报成"被占用")
|
|
255
|
+
const cleared = removeDir(part)
|
|
256
|
+
if (cleared && cleared.ok === false) {
|
|
257
|
+
errors.push({
|
|
258
|
+
url,
|
|
259
|
+
message: res.timedOut === true
|
|
260
|
+
? (res.exited === false
|
|
261
|
+
? `克隆超时;git 进程在等待后仍未退出(清理已重试 ${cleared.rounds ?? cleared.attempts ?? 1} 轮),残留目录清不掉:${part}`
|
|
262
|
+
: `克隆超时;git 进程已结束,但残留目录重试 ${cleared.rounds ?? cleared.attempts ?? 1} 轮后仍清不掉(可能被其它程序/杀软占用):${part}`)
|
|
263
|
+
: `克隆失败,且残留目录重试 ${cleared.rounds ?? cleared.attempts ?? 1} 轮后仍清不掉(可能仍被 git 占用):${part}`,
|
|
264
|
+
stderr: res.stderr,
|
|
265
|
+
unclean: true,
|
|
266
|
+
timedOut: res.timedOut === true,
|
|
267
|
+
exited: res.exited !== false,
|
|
268
|
+
dir: part,
|
|
269
|
+
})
|
|
270
|
+
break // 清不掉的残留和这个源绑着,换下一个源(新目录不受影响)
|
|
271
|
+
}
|
|
272
|
+
if (res.timedOut === true && round === 0) {
|
|
273
|
+
// ★ 同源、更长超时,重试一次(真机:ghproxy 卡死时短超时不够,长超时能过)
|
|
274
|
+
errors.push({ url, message: `克隆超时(${useTimeout}ms),改用 ${retryTimeout}ms 同源重试`, stderr: res.stderr, timedOut: true, retrying: true, dir: part })
|
|
166
275
|
continue
|
|
167
276
|
}
|
|
277
|
+
errors.push({ url, message: res.timedOut === true ? `克隆超时(${url})` : (res.stderr !== '' ? res.stderr : `git clone 退出码 ${res.code}`), stderr: res.stderr, timedOut: res.timedOut === true, dir: part })
|
|
278
|
+
break
|
|
168
279
|
}
|
|
169
|
-
// 失败:先尽力清掉半成品目录;清不掉就**如实说明**(这次不再谎报"环境禁止删除")
|
|
170
|
-
const cleared = removeDir(part)
|
|
171
|
-
if (cleared && cleared.ok === false) {
|
|
172
|
-
errors.push({
|
|
173
|
-
url,
|
|
174
|
-
message: res.timedOut === true
|
|
175
|
-
? `克隆超时,且残留目录仍被 git 占用(进程已尝试结束):${part}`
|
|
176
|
-
: `克隆失败,且残留目录无法清理(可能仍被 git 占用):${part}`,
|
|
177
|
-
stderr: res.stderr,
|
|
178
|
-
unclean: true,
|
|
179
|
-
timedOut: res.timedOut === true,
|
|
180
|
-
dir: part,
|
|
181
|
-
})
|
|
182
|
-
continue // **继续试下一个源**(新目录不受影响),不再像旧代码那样直接 break
|
|
183
|
-
}
|
|
184
|
-
errors.push({ url, message: res.timedOut === true ? `克隆超时(${url})` : (res.stderr !== '' ? res.stderr : `git clone 退出码 ${res.code}`), stderr: res.stderr, timedOut: res.timedOut === true, dir: part })
|
|
185
280
|
}
|
|
186
281
|
throw new Error(summarizeCloneErrors(errors))
|
|
187
282
|
}
|
|
188
283
|
|
|
189
|
-
export { reposDirCache, getReposDir, setReposDir, listLandedRepos, gitCloneRepo, summarizeCloneErrors, killProcessTree, probeSourceAlive }
|
|
284
|
+
export { reposDirCache, getReposDir, setReposDir, listLandedRepos, gitCloneRepo, summarizeCloneErrors, orderGitCandidates, readGitSourceMemo, rememberGitSource, killProcessTree, probeSourceAlive }
|
|
@@ -289,9 +289,37 @@ function gitCloneUrls(repoFullName, source = 'github') {
|
|
|
289
289
|
return urls.length > 0 ? urls : [`https://github.com/${repoFullName}.git`]
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
+
/** Git 克隆候选(带源 id/名称/模板)——2026-09-26 加法:给「记住上次成功的 git 源并优先使用」用。
|
|
293
|
+
* `gitCloneUrls` 一个字没改(另外 5 个调用点的行为完全不变);这里只是把同一份排序逻辑
|
|
294
|
+
* 连同**源标识**一起返回,让克隆侧能把"哪个模板成功了"记下来。 */
|
|
295
|
+
function gitCloneCandidates(repoFullName, source = 'github') {
|
|
296
|
+
const full = String(repoFullName)
|
|
297
|
+
if (source === 'gitee') {
|
|
298
|
+
return [{ id: 'gitee-git', name: 'Gitee 直连', urlTemplate: 'https://gitee.com/{owner}/{repo}.git', url: `https://gitee.com/${full}.git` }]
|
|
299
|
+
}
|
|
300
|
+
const [owner = '', repo = ''] = full.split('/')
|
|
301
|
+
let list = []
|
|
302
|
+
try {
|
|
303
|
+
list = readSources().gitSources ?? []
|
|
304
|
+
} catch {
|
|
305
|
+
list = DEFAULT_SOURCES.gitSources
|
|
306
|
+
}
|
|
307
|
+
const ordered = [...list].sort((a, b) => (b.primary === true ? 1 : 0) - (a.primary === true ? 1 : 0))
|
|
308
|
+
const out = []
|
|
309
|
+
for (const s of ordered) {
|
|
310
|
+
const tpl = typeof s?.urlTemplate === 'string' ? s.urlTemplate : ''
|
|
311
|
+
if (tpl === '') continue
|
|
312
|
+
const url = tpl.replace(/\{owner\}/gu, owner).replace(/\{repo\}/gu, repo)
|
|
313
|
+
if (url === '') continue
|
|
314
|
+
out.push({ id: typeof s.id === 'string' ? s.id : '', name: typeof s.name === 'string' ? s.name : '', urlTemplate: tpl, url })
|
|
315
|
+
}
|
|
316
|
+
if (out.length === 0) out.push({ id: 'github-git', name: 'GitHub 直连', urlTemplate: 'https://github.com/{owner}/{repo}.git', url: `https://github.com/${full}.git` })
|
|
317
|
+
return out
|
|
318
|
+
}
|
|
319
|
+
|
|
292
320
|
/** Gitee OAuth 端点(第三方应用需在 gitee.com → 数据管理 → 第三方应用 创建)。 */
|
|
293
321
|
const GITEE_AUTH_URL = 'https://gitee.com/oauth/authorize'
|
|
294
322
|
|
|
295
323
|
const GITEE_TOKEN_URL = 'https://gitee.com/oauth/token'
|
|
296
324
|
const DEFAULT_SEARCH = 'dsh-plugin'
|
|
297
|
-
export { readSources, writeSources, maskSources, readSourceSecrets, writeSourceSecrets, isAllowedSourceUrl, isAllowedGitSourceUrl, readGiteeConfig, giteeStatusView, orderedRegistries, createGiteeOAuthState, consumeGiteeOAuthState, DEFAULT_SOURCES, GITEE_OAUTH_STATES, gitCloneUrls, GITEE_AUTH_URL, GITEE_TOKEN_URL, DEFAULT_SEARCH }
|
|
325
|
+
export { readSources, writeSources, maskSources, readSourceSecrets, writeSourceSecrets, isAllowedSourceUrl, isAllowedGitSourceUrl, readGiteeConfig, giteeStatusView, orderedRegistries, createGiteeOAuthState, consumeGiteeOAuthState, DEFAULT_SOURCES, GITEE_OAUTH_STATES, gitCloneUrls, gitCloneCandidates, GITEE_AUTH_URL, GITEE_TOKEN_URL, DEFAULT_SEARCH }
|
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/lib/server/infra/fsx.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { existsSync, rmSync, readdirSync, mkdirSync, copyFileSync, chmodSync, lstatSync } from 'node:fs'
|
|
5
5
|
import { dirname, join, basename } from 'node:path'
|
|
6
6
|
import { execFile } from 'node:child_process'
|
|
7
|
+
import { execFileSync } from 'node:child_process'
|
|
7
8
|
import { promisify } from 'node:util'
|
|
8
9
|
|
|
9
10
|
const execFileAsync = promisify(execFile)
|
|
@@ -160,4 +161,52 @@ async function removeDirVerifiedAsync(dir, { attempts = 2, pollMs = 600 } = {})
|
|
|
160
161
|
return { ok: false, attempts, method: null, error: detail }
|
|
161
162
|
}
|
|
162
163
|
|
|
163
|
-
|
|
164
|
+
/**
|
|
165
|
+
* 「带重试的核实删除」(2026-09-26 真机加法):专治 Windows「杀完进程但句柄晚一拍释放」。
|
|
166
|
+
* 真机证据:git 整棵树已经杀干净(`git` 进程 0),可上一次尝试里 `removeDirVerified` **一次**判失败,
|
|
167
|
+
* 就被上游当成「残留被占用」写进用户可见的错误里。
|
|
168
|
+
* 每一轮都:`removeDirVerified` → 等 pollMs → 仍不消失就**外部 `rmdir /s /q` 兜底**(同步版)→ 再核实。
|
|
169
|
+
* 为什么要外部兜底(2026-09-26 本机实测):同一个 `rmSync(…, {recursive:true, force:true, maxRetries:3})`
|
|
170
|
+
* 在 `C:\Users\<user>\AppData\Local\Temp\…` 下**不抛错、目录原封不动**,而 `cmd /c rmdir /s /q` 一次就删掉
|
|
171
|
+
* (与 removeDirVerifiedAsync 已有的兜底同源,这里补上同步版)。只有确实清不掉才回 ok:false。
|
|
172
|
+
* 注:`Atomics.wait` 是 Node 里唯一可靠的同步 sleep(本函数是同步签名,调用点都在同步清理路径上)。
|
|
173
|
+
*/
|
|
174
|
+
function sleepSync(ms) {
|
|
175
|
+
try {
|
|
176
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
|
177
|
+
} catch {}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** 外部删除兜底(同步版):实测本机 `cmd /c rmdir /s /q` 能删掉 Node `rmSync` 静默删不掉的树。 */
|
|
181
|
+
function removeViaShellSync(dir, timeoutMs = 120000) {
|
|
182
|
+
try {
|
|
183
|
+
if (process.platform === 'win32') {
|
|
184
|
+
execFileSync('cmd.exe', ['/c', 'rmdir', '/s', '/q', dir], { windowsHide: true, timeout: timeoutMs, stdio: 'ignore' })
|
|
185
|
+
return { ok: true, method: 'rmdir' }
|
|
186
|
+
}
|
|
187
|
+
execFileSync('rm', ['-rf', '--', dir], { timeout: timeoutMs, stdio: 'ignore' })
|
|
188
|
+
return { ok: true, method: 'rm' }
|
|
189
|
+
} catch (error) {
|
|
190
|
+
return { ok: false, method: null, error: error instanceof Error ? error.message : String(error) }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function removeDirVerifiedWithRetry(dir, { attempts = 3, pollMs = 250, remover = removeDirVerified, shellRemover = removeViaShellSync, viaShell = true } = {}) {
|
|
195
|
+
if (!existsSync(dir)) return { ok: true, attempts: 0, rounds: 1, method: 'already-gone', error: null }
|
|
196
|
+
let last = { ok: false, attempts: 0, error: null }
|
|
197
|
+
for (let round = 1; round <= attempts; round += 1) {
|
|
198
|
+
last = remover(dir)
|
|
199
|
+
if (last !== null && last !== undefined && last.ok === true) return { ok: true, attempts: last.attempts ?? 0, rounds: round, method: 'rmSync', error: null }
|
|
200
|
+
sleepSync(pollMs)
|
|
201
|
+
if (!existsSync(dir)) return { ok: true, attempts: last?.attempts ?? 0, rounds: round, method: 'rmSync(等待后消失)', error: null }
|
|
202
|
+
if (viaShell === true) {
|
|
203
|
+
const shell = shellRemover(dir)
|
|
204
|
+
sleepSync(pollMs)
|
|
205
|
+
if (!existsSync(dir)) return { ok: true, attempts: last?.attempts ?? 0, rounds: round, method: shell === null || shell === undefined ? null : shell.method, error: null }
|
|
206
|
+
if (shell !== null && shell !== undefined && shell.ok !== true) last = { ...last, error: shell.error ?? last?.error ?? null }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return { ok: false, attempts: last?.attempts ?? 0, rounds: attempts, method: null, error: last?.error ?? null }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export { copyTree, queuedWrite, cleanupStalePackageDir, removeDirVerified, removeDirVerifiedAsync, removeDirVerifiedWithRetry, removeViaShellSync, sleepSync, clearReadonly, waitGone, removeViaShell, writeQueue }
|
|
@@ -11,7 +11,7 @@ import { tmpdir } from 'node:os'
|
|
|
11
11
|
import { pathToFileURL } from 'node:url'
|
|
12
12
|
import { createRequire } from 'node:module'
|
|
13
13
|
import { readCompatMode, writeCompatMode } from '../domain/compat-state.js'
|
|
14
|
-
import { FRAMEWORK_BACKUP_ROOT, backupProfileSnapshot, checkpointFrameworkTree, preflightDisableIncompatible, relaunchPrelude, resolveDshBin, resolveFrameworkRootNodeModules, resolveFrameworkUpgradePlan } from '../domain/framework.js'
|
|
14
|
+
import { FRAMEWORK_BACKUP_ROOT, backupProfileSnapshot, checkpointFrameworkTree, preflightDisableIncompatible, relaunchPrelude, resolveDshBin, resolveFrameworkRootNodeModules, resolveFrameworkUpgradePlan, shellHostedRefusal } from '../domain/framework.js'
|
|
15
15
|
import { fwIntegrityCheck } from '../infra/fw-integrity-check.js'
|
|
16
16
|
import { CORE_PATCH_ROW_IDS } from '../domain/patch.js'
|
|
17
17
|
import { migrateAgentConfigsForUpgrade } from '../domain/presets.js'
|
|
@@ -51,6 +51,7 @@ async function routeFrameworkUpgrade(req, res, rc) {
|
|
|
51
51
|
// 框架升级入口:一键流程 = 备份配置快照 + 备份框架本体(回滚点)+ 自动升级
|
|
52
52
|
// (npx 缓存 dsh 本体 + profile 官方配套包)+ 失败自动回滚 + 重启提示。
|
|
53
53
|
// 升级完成重启后,框架适配逻辑自动:备份新版本快照 + 重打框架补丁 + 版本提示。
|
|
54
|
+
const refusal = shellHostedRefusal(); if (refusal !== null) { sendError(res, 409, refusal.error, refusal.details); return } // 宿主守卫:本路由的「升级后重启」步骤含 Stop-Process + Invoke-DshRelaunch,外壳托管(桌面端 Electron 承载)时禁止生成该脚本——判据见 domain/framework.js#detectHostShape
|
|
54
55
|
let current = null
|
|
55
56
|
let dshDir = null
|
|
56
57
|
// 面板自报名(自报名一致性校验用):从插件自身 package.json 读,与部署目录比对
|
|
@@ -8,7 +8,7 @@ import { dirname, join, resolve } from 'node:path'
|
|
|
8
8
|
import { tmpdir } from 'node:os'
|
|
9
9
|
import { createRequire } from 'node:module'
|
|
10
10
|
import { readCompatGate, writeCompatGate } from '../domain/compat.js'
|
|
11
|
-
import { cleanupStaleFwTasks, currentFrameworkVersion, pickFrameworkTarget, relaunchPrelude, resolveDshBin, resolveFrameworkRootNodeModules } from '../domain/framework.js'
|
|
11
|
+
import { cleanupStaleFwTasks, currentFrameworkVersion, pickFrameworkTarget, relaunchPrelude, resolveDshBin, resolveFrameworkRootNodeModules, shellHostedRefusal } from '../domain/framework.js'
|
|
12
12
|
import { readGithubAuth } from '../domain/install.js'
|
|
13
13
|
import { webPort } from '../domain/runtime.js'
|
|
14
14
|
import { fetchJsonUrl } from '../infra/http.js'
|
|
@@ -17,6 +17,21 @@ import { dshHome, entryPkgMeta, findPatchPath, packageNameOf, pluginRoot, profil
|
|
|
17
17
|
import { isFrameworkVersionNewer, semverRangeMatchLoose, frameworkUpgradeCandidates } from '../infra/semver.js'
|
|
18
18
|
import { fwCheckCache, setFwCheckCache } from '../state.js'
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* 宿主形态守卫(2026-09-26 真机事故后的**加法分支**,只挡外壳托管这一种新形态):
|
|
22
|
+
* 桌面端里 DSH host 由 Electron 二进制承载(asar 内 dsh-desktop-host),实例里根本没有 `node bin.js web` 可拉起。
|
|
23
|
+
* 我们原先的「按端口 Stop-Process → Invoke-DshRelaunch」会杀掉 host 却拉不起来,
|
|
24
|
+
* Electron 外壳只留下 `crash-…-host.log: dsh desktop host exited with 4294967295`(真机 20:28)。
|
|
25
|
+
* 命中时:**不 kill、不 spawn、不建 schtasks**,返回结构化 409 + 面向用户短句。
|
|
26
|
+
* 返回 true = 已拒绝(调用方直接 return);false = 独立 `dsh web` 实例,原路径一字不改。
|
|
27
|
+
*/
|
|
28
|
+
function refuseWhenShellHosted(res) {
|
|
29
|
+
const refusal = shellHostedRefusal()
|
|
30
|
+
if (refusal === null) return false
|
|
31
|
+
sendError(res, 409, refusal.error, refusal.details)
|
|
32
|
+
return true
|
|
33
|
+
}
|
|
34
|
+
|
|
20
35
|
async function routeFrameworkUpgradeStatusGet(req, res, rc) {
|
|
21
36
|
const ctx = rc.ctx
|
|
22
37
|
const url = rc.url
|
|
@@ -100,6 +115,9 @@ async function routeFrameworkRelaunch(req, res, rc) {
|
|
|
100
115
|
const method = rc.method
|
|
101
116
|
// 手动拉起服务(升级期间左侧悬浮按钮调用):Start-Process node bin.js web。
|
|
102
117
|
// 端口已有监听则不重复拉起;bin.js 缺失时明确报错。
|
|
118
|
+
// 宿主守卫:外壳托管的实例里**没有** node bin.js web 这条现实路径(spawn 出来的只会是第二个实例/立刻死掉),
|
|
119
|
+
// 改为让用户在桌面端内重启。
|
|
120
|
+
if (refuseWhenShellHosted(res)) return
|
|
103
121
|
const port = webPort(ctx)
|
|
104
122
|
const binPath = resolveDshBin()
|
|
105
123
|
const nodePath = process.execPath
|
|
@@ -326,6 +344,10 @@ async function routeFrameworkRollback(req, res, rc) {
|
|
|
326
344
|
const body = rc.body
|
|
327
345
|
// 一键回滚(2026-09-04 事故后的新能力):读 framework-rollback.json(升级时写入),
|
|
328
346
|
// 生成分离脚本:停服 → 全树恢复(.pnpm 自包镜像 + 顶层 scope + lock)→ 拉起 → 状态。
|
|
347
|
+
// 宿主守卫(**本机 20:28 事故的真正凶手就是这条路径**:它没有 bin.js 预检,直接按端口 Stop-Process,
|
|
348
|
+
// 杀掉 Electron 承载的 host 后 Invoke-DshRelaunch 找不到 bin.js → 外壳记 crash):
|
|
349
|
+
// 外壳托管时一律拒绝,绝不生成会杀 host 的脚本。
|
|
350
|
+
if (refuseWhenShellHosted(res)) return
|
|
329
351
|
let rec = null
|
|
330
352
|
try { rec = JSON.parse(readFileSync(join(dshHome(), 'plugin-console', 'framework-rollback.json'), 'utf8')) } catch {}
|
|
331
353
|
if (rec === null || typeof rec.checkpointDir !== 'string' || typeof rec.fwRoot !== 'string'
|
|
@@ -430,6 +452,8 @@ async function routeRestart(req, res, rc) {
|
|
|
430
452
|
// ② **守护任务**(关键):主脚本动手**之前**就注册一个每分钟跑一次的独立计划任务,
|
|
431
453
|
// 服务被杀、主脚本被杀都不影响它;端口起来了它自删,起不来就继续拉(最多 5 次)
|
|
432
454
|
// ③ bin 解析用与升级/回滚同一套多级回退(node resolve → .pnpm → 顶层链接)
|
|
455
|
+
// 宿主守卫(加法分支):外壳托管时"自杀式重启"没有意义(杀了 host 没人拉起,只会产生 crash 日志)。
|
|
456
|
+
if (refuseWhenShellHosted(res)) return
|
|
433
457
|
const port = webPort(ctx)
|
|
434
458
|
const binPath = resolveDshBin()
|
|
435
459
|
const nodePath = process.execPath
|
|
@@ -552,4 +576,4 @@ async function routeFrameworkStatusClear(req, res, rc) {
|
|
|
552
576
|
}
|
|
553
577
|
sendJson(res, 200, { ok: true, removed, status: 'idle', message: null })
|
|
554
578
|
}
|
|
555
|
-
export { routeFrameworkStatusClear, routeFrameworkUpgradeStatusGet, routeFrameworkRelaunch, routeFrameworkCheck, routeCompatGate, routeCheckUpdate, routeFrameworkRollback, routeRestart }
|
|
579
|
+
export { refuseWhenShellHosted, routeFrameworkStatusClear, routeFrameworkUpgradeStatusGet, routeFrameworkRelaunch, routeFrameworkCheck, routeCompatGate, routeCheckUpdate, routeFrameworkRollback, routeRestart }
|
|
@@ -20,6 +20,7 @@ import { routeFrameworkPreflight, routeFrameworkPreflightPatch } from './framewo
|
|
|
20
20
|
import { routeCheckUpdate, routeCompatGate, routeFrameworkCheck, routeFrameworkRelaunch, routeFrameworkRollback, routeFrameworkStatusClear, routeFrameworkUpgradeStatusGet, routeRestart } from './framework.js'
|
|
21
21
|
import { routeGithubLogin, routeGithubOpenLogin } from './github-login.js'
|
|
22
22
|
import { routeInstall, routeInstallStatus } from './install.js'
|
|
23
|
+
import { routeLockfileCheck, routeLockfileRepair } from './lockfile.js'
|
|
23
24
|
import { routeEnrich, routeMarketIndex, routeRepo, routeSearch, routeSubpackages } from './market.js'
|
|
24
25
|
import { routeAdaptUnlock, routeAdaptUnlockAll, routeCleanResiduals, routeSelfUpdate, routeToggle, routeUninstall } from './plugins.js'
|
|
25
26
|
import { routeSkillRemove, routeSkillToggle, routeSkillsInstalledGet } from './skills.js'
|
|
@@ -128,6 +129,9 @@ const ROUTES = [
|
|
|
128
129
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/component/start`, handler: routeComponentStart },
|
|
129
130
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/component/stop`, handler: routeComponentStop },
|
|
130
131
|
{ methods: ['POST'], path: `${ROUTE_PREFIX}/component/status`, handler: routeComponentStatus },
|
|
132
|
+
// 依赖锁体检与重建(2026-09-27 加法):check 纯只读;repair 必须由用户显式调用
|
|
133
|
+
{ methods: ['POST'], path: `${ROUTE_PREFIX}/lockfile-check`, handler: routeLockfileCheck },
|
|
134
|
+
{ methods: ['POST'], path: `${ROUTE_PREFIX}/lockfile-repair`, handler: routeLockfileRepair },
|
|
131
135
|
|
|
132
136
|
]
|
|
133
137
|
|