@noob-stupid/dsh-plugin-console 0.5.13 → 0.5.15
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/ai-run.js +2 -2
- package/lib/server/domain/components.js +1 -1
- 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/skills.js +2 -2
- package/lib/server/domain/sources.js +29 -1
- 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 }
|
|
@@ -5,7 +5,7 @@ import { readFileSync, writeFileSync, existsSync, rmSync, readdirSync, mkdirSync
|
|
|
5
5
|
import { dirname, join } from 'node:path'
|
|
6
6
|
import { tmpdir } from 'node:os'
|
|
7
7
|
import { gitCloneUrls } from './sources.js'
|
|
8
|
-
import { execFileAsync, gitEnv } from '../infra/exec.js'
|
|
8
|
+
import { execFileAsync, execFileWithKillTree, gitEnv } from '../infra/exec.js'
|
|
9
9
|
import { copyTree } from '../infra/fsx.js'
|
|
10
10
|
import { GITHUB_RAW, curlJson, curlText, rawTextWithFallback } from '../infra/http.js'
|
|
11
11
|
import { dshHome } from '../infra/paths.js'
|
|
@@ -91,7 +91,7 @@ async function runSkillInstallJob(job) {
|
|
|
91
91
|
let lastError = null
|
|
92
92
|
for (const url of urls) {
|
|
93
93
|
try {
|
|
94
|
-
await
|
|
94
|
+
await execFileWithKillTree('git', ['clone', '--depth', '1', '--quiet', url, tmpDir], {
|
|
95
95
|
timeout: 120000,
|
|
96
96
|
windowsHide: true,
|
|
97
97
|
env: gitEnv(),
|
|
@@ -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/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
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// L2 · routes —— 依赖锁体检 / 重建(2026-09-27,纯加法)
|
|
2
|
+
// POST /plugin-console/lockfile-check 只读体检:清单 vs pnpm-lock.yaml vs 磁盘 + registry 解析(**不写任何文件**)
|
|
3
|
+
// POST /plugin-console/lockfile-repair **用户显式触发**的 lock 重建:pnpm install --lockfile-only
|
|
4
|
+
//
|
|
5
|
+
// 为什么要有这两条路由(真问题,隔离环境已复现):live 的 web profile 里 `plugin remove` / 任何一次 pnpm
|
|
6
|
+
// 全量解析都会失败,三条独立原因叠在一起 —— 依赖 404(npmmirror 与 npmjs 双双 404)、lock 陈旧残缺
|
|
7
|
+
// (importers 写 0.5.4 / manifest 写 0.5.14、7 个依赖缺 4 个)、供应链年龄闸(新发版本不足 24h)。
|
|
8
|
+
// 面板过去只能看到一句原始 stderr,用户无法判断"能不能修、修了会不会丢依赖"。
|
|
9
|
+
//
|
|
10
|
+
// 安全语义(写死):
|
|
11
|
+
// · check 纯只读(不写、不跑 pnpm);
|
|
12
|
+
// · repair **必须被显式调用**,且只跑 `pnpm install --lockfile-only`(argv 由 domain 的 repairArgsFor 唯一产出);
|
|
13
|
+
// · 有 404 依赖时 repair **停下并点名**,绝不为"重建成功"而静默丢弃依赖,也绝不自动绕过供应链闸。
|
|
14
|
+
// 细节与安全边界见 lib/server/domain/lockfile-health.js 头注释。
|
|
15
|
+
|
|
16
|
+
import { runLockfileCheck, runLockfileRepair } from '../domain/lockfile-health.js'
|
|
17
|
+
import { orderedRegistries, readSources } from '../domain/sources.js'
|
|
18
|
+
import { sendError, sendJson } from '../infra/httpd.js'
|
|
19
|
+
import { profileDirOf } from '../infra/paths.js'
|
|
20
|
+
|
|
21
|
+
/** 主源优先的 registry 列表(与安装通道同一份来源配置:用户可在「源管理」里改)。 */
|
|
22
|
+
function registryList() {
|
|
23
|
+
try {
|
|
24
|
+
const list = orderedRegistries(readSources())
|
|
25
|
+
return Array.isArray(list) && list.length > 0 ? list : ['https://registry.npmmirror.com']
|
|
26
|
+
} catch {
|
|
27
|
+
return ['https://registry.npmmirror.com']
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function lockProfileDir(rc, res) {
|
|
32
|
+
const dir = profileDirOf(rc.ctx)
|
|
33
|
+
if (typeof dir !== 'string' || dir === '') {
|
|
34
|
+
sendError(res, 400, '无法定位 profile 目录(读不到 cordis.yml 的 include 条目)')
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
return dir
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function routeLockfileCheck(req, res, rc) {
|
|
41
|
+
const profileDir = lockProfileDir(rc, res)
|
|
42
|
+
if (profileDir === null) return
|
|
43
|
+
const view = await runLockfileCheck({ profileDir, registries: registryList() })
|
|
44
|
+
sendJson(res, 200, view)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function routeLockfileRepair(req, res, rc) {
|
|
48
|
+
const profileDir = lockProfileDir(rc, res)
|
|
49
|
+
if (profileDir === null) return
|
|
50
|
+
const result = await runLockfileRepair({ profileDir, registries: registryList() })
|
|
51
|
+
sendJson(res, 200, result)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export { routeLockfileCheck, routeLockfileRepair }
|
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.15",
|
|
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",
|