@noob-stupid/dsh-plugin-console 0.3.67 → 0.4.0

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.
Files changed (48) hide show
  1. package/lib/client.js +126 -31
  2. package/lib/index.js +60 -9687
  3. package/lib/server/domain/ai-run.js +479 -0
  4. package/lib/server/domain/ai.js +246 -0
  5. package/lib/server/domain/compat.js +474 -0
  6. package/lib/server/domain/components.js +108 -0
  7. package/lib/server/domain/dep-source.js +122 -0
  8. package/lib/server/domain/format-contract.js +265 -0
  9. package/lib/server/domain/format-scan.js +431 -0
  10. package/lib/server/domain/framework.js +393 -0
  11. package/lib/server/domain/install-job.js +561 -0
  12. package/lib/server/domain/install.js +599 -0
  13. package/lib/server/domain/jobs.js +28 -0
  14. package/lib/server/domain/market.js +409 -0
  15. package/lib/server/domain/patch.js +203 -0
  16. package/lib/server/domain/presets.js +93 -0
  17. package/lib/server/domain/quarantine.js +224 -0
  18. package/lib/server/domain/release-source.js +504 -0
  19. package/lib/server/domain/repoland.js +119 -0
  20. package/lib/server/domain/revoke.js +184 -0
  21. package/lib/server/domain/runtime.js +118 -0
  22. package/lib/server/domain/selfupdate.js +319 -0
  23. package/lib/server/domain/skills.js +234 -0
  24. package/lib/server/domain/sources.js +297 -0
  25. package/lib/server/domain/suite.js +220 -0
  26. package/lib/server/infra/exec.js +98 -0
  27. package/lib/server/infra/fsx.js +163 -0
  28. package/lib/server/infra/fw-integrity-check.js +37 -0
  29. package/lib/server/infra/http.js +373 -0
  30. package/lib/server/infra/httpd.js +51 -0
  31. package/lib/server/infra/mask.js +19 -0
  32. package/lib/server/infra/paths.js +177 -0
  33. package/lib/server/infra/semver.js +168 -0
  34. package/lib/server/routes/ai.js +172 -0
  35. package/lib/server/routes/components.js +254 -0
  36. package/lib/server/routes/framework-preflight.js +154 -0
  37. package/lib/server/routes/framework-upgrade.js +679 -0
  38. package/lib/server/routes/framework.js +544 -0
  39. package/lib/server/routes/github-login.js +198 -0
  40. package/lib/server/routes/index.js +128 -0
  41. package/lib/server/routes/install.js +116 -0
  42. package/lib/server/routes/market.js +415 -0
  43. package/lib/server/routes/plugins.js +562 -0
  44. package/lib/server/routes/skills.js +107 -0
  45. package/lib/server/routes/sources.js +437 -0
  46. package/lib/server/routes/state.js +125 -0
  47. package/lib/server/state.js +22 -0
  48. package/package.json +1 -1
@@ -0,0 +1,544 @@
1
+ // L2 · routes —— 框架(/framework-check · /check-update · /framework-upgrade · /framework-rollback · /framework-upgrade-status · /framework-relaunch · /compat-gate · /restart)
2
+ // 分层 Step 8b:从 lib/index.js 的 handle() 原样搬出(只搬移未改逻辑;缩进保持原样)
3
+
4
+ import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync } from 'node:fs'
5
+ import { writeFile } from 'node:fs/promises'
6
+ import { execFile } from 'node:child_process'
7
+ import { dirname, join, resolve } from 'node:path'
8
+ import { tmpdir } from 'node:os'
9
+ import { createRequire } from 'node:module'
10
+ import { readCompatGate, writeCompatGate } from '../domain/compat.js'
11
+ import { cleanupStaleFwTasks, currentFrameworkVersion, pickFrameworkTarget, relaunchPrelude, resolveDshBin, resolveFrameworkRootNodeModules } from '../domain/framework.js'
12
+ import { readGithubAuth } from '../domain/install.js'
13
+ import { webPort } from '../domain/runtime.js'
14
+ import { fetchJsonUrl } from '../infra/http.js'
15
+ import { sendError, sendJson } from '../infra/httpd.js'
16
+ import { dshHome, entryPkgMeta, findPatchPath, packageNameOf, pluginRoot, profileDirOf, resolvePackageJson } from '../infra/paths.js'
17
+ import { isFrameworkVersionNewer, semverRangeMatchLoose, frameworkUpgradeCandidates } from '../infra/semver.js'
18
+ import { fwCheckCache, setFwCheckCache } from '../state.js'
19
+
20
+ async function routeFrameworkUpgradeStatusGet(req, res, rc) {
21
+ const ctx = rc.ctx
22
+ const url = rc.url
23
+ const pathname = rc.pathname
24
+ const method = rc.method
25
+ // 框架升级进度(页面断连后重连恢复进度条用):读状态文件 {status|message}
26
+ let status = { status: 'idle', message: null }
27
+ try {
28
+ const f = join(dshHome(), 'plugin-console', 'fw-upgrade-state.txt')
29
+ if (existsSync(f)) {
30
+ // PS5.1 Set-Content -Encoding UTF8 会写 BOM——strip 掉,否则 status 变成 '\uFEFFdone',
31
+ // 客户端 status === 'done' 永不匹配(进度条/悬浮按钮不消失)
32
+ let raw = readFileSync(f, 'utf8')
33
+ if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1)
34
+ const [st, ...rest] = raw.split('|')
35
+ const at = statSync(f).mtimeMs
36
+ // v0.3.37:失败记录里带 stage=<崩溃前最后阶段>,界面据此把已完成的步骤显示成 ✓(而不是整列 ✕)
37
+ const stageRaw = rest.find((p) => p.startsWith('stage='))
38
+ const stage = stageRaw === undefined ? null : (stageRaw.slice(6) || null)
39
+ status = { status: st ?? 'idle', message: rest.filter((p) => !p.startsWith('stage=')).join('|') || null, stage, at }
40
+ // ── v0.3.39 状态自愈 ──────────────────────────────────────────────────
41
+ // 2026-09-11 真机:回滚脚本干完活之后**进程被 Ctrl+C 类事件结束**(计划任务 Last Result
42
+ // = 0xC000013A),终态没写成 → 界面永远卡在「回滚中…」并每 3 秒轮询。心跳 + 现实核对
43
+ // 能把它纠正回来:脚本不再心跳(>90 秒没动静)时,用「已装版本 vs 回滚记录的 from/to」
44
+ // 判断真实结果;同时清掉残留的 DSH-FW-* 计划任务。
45
+ const terminal = status.status === 'idle' || status.status === 'done' || status.status === 'failed'
46
+ if (!terminal) {
47
+ let hbAge = null
48
+ try { hbAge = Date.now() - statSync(`${f}.hb`).mtimeMs } catch { hbAge = null }
49
+ const scriptAlive = hbAge !== null && hbAge < 90000
50
+ if (!scriptAlive) {
51
+ let rec = null
52
+ let current = null
53
+ try { rec = JSON.parse(readFileSync(join(dshHome(), 'plugin-console', 'framework-rollback.json'), 'utf8')) } catch {}
54
+ try {
55
+ const localRequire = createRequire(ctx.baseUrl ?? 'file:///')
56
+ current = JSON.parse(readFileSync(localRequire.resolve('@deepseek-ai/dsh/package.json'), 'utf8')).version ?? null
57
+ } catch {}
58
+ const upgradedTo = rec !== null && typeof rec.to === 'string' && current === rec.to
59
+ const rolledBackTo = rec !== null && typeof rec.from === 'string' && current === rec.from
60
+ // 「安装阶段」不能靠 from 判定(那时本来就还是旧版本),只认 to
61
+ if (upgradedTo) {
62
+ status = { ...status, status: 'done', reconciled: { from: st, note: `脚本进程已中断,但框架已是 ${current}、服务正常 —— 实际结果:升级成功` } }
63
+ } else if (rolledBackTo && (st === 'rollback' || st === 'relaunching' || st === 'stopped')) {
64
+ status = { ...status, status: 'done', reconciled: { from: st, note: `脚本进程已中断,但框架已回到 ${current}、服务正常 —— 实际结果:回滚成功` } }
65
+ } else if (hbAge !== null) {
66
+ status = { ...status, stalled: true, note: '升级/回滚脚本已超过 90 秒没有心跳,进程可能已被结束——请用「重新检查版本」核对,必要时重启服务' }
67
+ }
68
+ if (status.reconciled !== undefined) cleanupStaleFwTasks()
69
+ }
70
+ }
71
+ // 失败但框架本体其实已经装到目标版本:明确告诉用户「升级本体成功、失败的是重启那一步」,
72
+ // 免得整列红叉让人以为白干了(2026-09-11 真机事故就是这样)。
73
+ if (status.status === 'failed') {
74
+ try {
75
+ const rr = JSON.parse(readFileSync(join(dshHome(), 'plugin-console', 'framework-rollback.json'), 'utf8'))
76
+ const cur = JSON.parse(readFileSync(join(rr.fwRoot, '@deepseek-ai', 'dsh', 'package.json'), 'utf8')).version
77
+ if (typeof rr.to === 'string' && rr.to !== '' && cur === rr.to) status.frameworkAtTarget = cur
78
+ } catch {}
79
+ }
80
+ // 残留清理:非终止状态(starting/stopped/installing/rollback/pkg/relaunching)超过 15 分钟
81
+ // 视为上次升级的残留——升级脚本要么成功(done)要么失败(failed),服务重启后不可能还在
82
+ // 中途;任务调度失败/脚本空跑时状态会永远停在 starting,重启后不应再自动恢复进度条。
83
+ // (升级进行中页面断连后用户手动重启服务属边缘情况:<15 分钟不受影响,进度仍可恢复)
84
+ if (status.status !== 'idle' && status.status !== 'done' && status.status !== 'failed'
85
+ && Date.now() - at > 15 * 60 * 1000) {
86
+ try { writeFileSync(f, 'idle|', 'utf8') } catch {}
87
+ status = { status: 'idle', message: null, at: Date.now() }
88
+ }
89
+ }
90
+ } catch {}
91
+ sendJson(res, 200, { ok: true, ...status })
92
+ return
93
+ }
94
+
95
+ async function routeFrameworkRelaunch(req, res, rc) {
96
+ const webPort = rc.deps.webPort
97
+ const ctx = rc.ctx
98
+ const url = rc.url
99
+ const pathname = rc.pathname
100
+ const method = rc.method
101
+ // 手动拉起服务(升级期间左侧悬浮按钮调用):Start-Process node bin.js web。
102
+ // 端口已有监听则不重复拉起;bin.js 缺失时明确报错。
103
+ const port = webPort(ctx)
104
+ const binPath = resolveDshBin()
105
+ const nodePath = process.execPath
106
+ if (binPath === null || !existsSync(binPath)) {
107
+ sendError(res, 500, '无法定位 DSH 启动入口(bin.js 缺失)')
108
+ return
109
+ }
110
+ try {
111
+ const ps1 = join(tmpdir(), `console-relaunch-${Date.now()}.ps1`)
112
+ const lines = [
113
+ '$ok = $false',
114
+ `try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($c.Count -gt 0) { $ok = $true } } catch {}`,
115
+ `if (-not $ok) { Start-Process -FilePath ${JSON.stringify(nodePath)} -ArgumentList ${JSON.stringify(binPath)},'web' -WindowStyle Hidden }`,
116
+ ]
117
+ writeFile(ps1, lines.join('\r\n'), 'utf8').then(
118
+ () => execFile('powershell.exe', ['-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', ps1], { windowsHide: true }, () => {}),
119
+ () => {},
120
+ )
121
+ sendJson(res, 200, { ok: true, message: '已发起手动拉起(端口无监听时自动启动服务)' })
122
+ } catch (error) {
123
+ sendError(res, 500, `拉起失败:${error instanceof Error ? error.message : String(error)}`)
124
+ }
125
+ return
126
+ }
127
+
128
+ async function routeFrameworkCheck(req, res, rc) {
129
+ const ctx = rc.ctx
130
+ const url = rc.url
131
+ const pathname = rc.pathname
132
+ const method = rc.method
133
+ const body = rc.body
134
+ // 框架版本检查(「功能包 → 框架」常驻面板用):当前版本 / latest / next / 升级目标。
135
+ // 与升级路由同一套判定规则,但**只读**(不备份、不写状态文件);5 分钟内存缓存 +
136
+ // body.refresh === true 强制重查——面板是常驻入口,不能每次打开都打 registry。
137
+ const now = Date.now()
138
+ if (body.refresh === true || fwCheckCache === null || now - fwCheckCache.at > 300000) {
139
+ // 当前版本先从本地包读出来——候选列表要拿它做「只收更新的」过滤
140
+ let current = null
141
+ try {
142
+ const localRequire = createRequire(ctx.baseUrl ?? 'file:///')
143
+ const pkg = JSON.parse(readFileSync(localRequire.resolve('@deepseek-ai/dsh/package.json'), 'utf8'))
144
+ current = typeof pkg.version === 'string' ? pkg.version : null
145
+ } catch {}
146
+ let latest = null
147
+ let next = null
148
+ let alpha = null
149
+ let versions = []
150
+ let tagDefault = null
151
+ let registryError = null
152
+ try {
153
+ const data = await fetchJsonUrl('https://registry.npmmirror.com/@deepseek-ai%2fdsh')
154
+ latest = data?.['dist-tags']?.latest ?? null
155
+ next = data?.['dist-tags']?.next ?? null
156
+ alpha = data?.['dist-tags']?.alpha ?? null
157
+ // 可选升级目标列表(用户 2026-09-23 要求:所有比当前新的版本都列出来,测试版也列)
158
+ const cand = frameworkUpgradeCandidates(data, current)
159
+ versions = cand.versions
160
+ tagDefault = cand.tagDefault
161
+ } catch (error) {
162
+ registryError = error instanceof Error ? error.message : String(error)
163
+ }
164
+ const target = pickFrameworkTarget({ current, latest, next }).target
165
+ setFwCheckCache({ at: now, data: { current, latest, next, alpha, target, versions, tagDefault, registryError } })
166
+ }
167
+ sendJson(res, 200, { ok: true, ...fwCheckCache.data, checkedAt: fwCheckCache.at })
168
+ return
169
+ }
170
+
171
+ async function routeCompatGate(req, res, rc) {
172
+ const ctx = rc.ctx
173
+ const url = rc.url
174
+ const pathname = rc.pathname
175
+ const method = rc.method
176
+ const body = rc.body
177
+ // 兼容门总开关(用户定案 2026-09-11):自动行为必须可关,关掉即回到纯手动。
178
+ // autoDisable —— 升级前是否自动禁用判定不适配的行
179
+ // autoDetect —— 打开控制台时是否自动检测「已适配」(仅提示,绝不自动解锁)
180
+ const patchBody = {}
181
+ if (typeof body.autoDisable === 'boolean') patchBody.autoDisable = body.autoDisable
182
+ if (typeof body.autoDetect === 'boolean') patchBody.autoDetect = body.autoDetect
183
+ const gateNext = Object.keys(patchBody).length > 0 ? writeCompatGate(patchBody) : readCompatGate()
184
+ sendJson(res, 200, { ok: true, compatGate: gateNext })
185
+ return
186
+ }
187
+
188
+ async function routeCheckUpdate(req, res, rc) {
189
+ const currentFrameworkVersion = rc.deps.currentFrameworkVersion
190
+ const ctx = rc.ctx
191
+ const url = rc.url
192
+ const pathname = rc.pathname
193
+ const method = rc.method
194
+ const body = rc.body
195
+ // 检测已安装插件是否有新版本:curl registry 元数据取 dist-tags.latest(node 网络黑洞时 curl 可用)。
196
+ // 聚合包(有 dependencies)额外对比子包版本:声明版本 vs 本地 node_modules 实际版本,
197
+ // 返回 depsOutdated 提示"更新本包需同步子包",避免半更新混搭导致启动冲突。
198
+ const packageName = packageNameOf(typeof body.packageName === 'string' ? body.packageName.trim() : '')
199
+ if (!packageName) {
200
+ sendError(res, 400, 'packageName 不能为空')
201
+ return
202
+ }
203
+ let latest = null
204
+ let next = null
205
+ let beta = null
206
+ let depsOutdated = []
207
+ let error = null
208
+ let source = 'npm'
209
+ try {
210
+ const encoded = packageName.startsWith('@')
211
+ ? `@${encodeURIComponent(packageName.slice(1).split('/')[0])}%2f${encodeURIComponent(packageName.split('/').slice(1).join('/'))}`
212
+ : encodeURIComponent(packageName)
213
+ const data = await fetchJsonUrl(`https://registry.npmmirror.com/${encoded}`)
214
+ latest = data?.['dist-tags']?.latest ?? null
215
+ next = data?.['dist-tags']?.next ?? null
216
+ beta = data?.['dist-tags']?.beta ?? null
217
+ if (latest === null) throw new Error(`registry 无 dist-tags.latest(${packageName})`)
218
+ // 子包配套检查:最新版声明依赖 vs 本地实际版本
219
+ const patchPath = findPatchPath(ctx)
220
+ const profileDir = dirname(patchPath)
221
+ const declared = latest ? data?.versions?.[latest]?.dependencies ?? {} : {}
222
+ const keys = typeof declared === 'object' ? Object.keys(declared) : []
223
+ for (const dep of keys) {
224
+ const required = String(declared[dep] ?? '').replace(/^[\^~>=< ]+/u, '')
225
+ if (!required) continue
226
+ let current = null
227
+ try {
228
+ const pkgPath = join(profileDir, 'node_modules', dep, 'package.json')
229
+ if (existsSync(pkgPath)) {
230
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
231
+ current = typeof pkg.version === 'string' ? pkg.version : null
232
+ }
233
+ } catch {}
234
+ if (current !== null && current !== required) {
235
+ depsOutdated.push({ name: dep, current, required })
236
+ }
237
+ }
238
+ } catch (err) {
239
+ // GitHub 发布回退(自身/未发布到 npm 的插件):npm registry 404/无 latest 时,
240
+ // 从已装包 package.json 的 repository 字段反查 GitHub 最新版本。
241
+ // 通道顺序:GitHub API(带 token)→ jsDelivr 版本 API(GitHub 黑洞期可用,已验证 200)。
242
+ // 覆盖 dsh-plugin-console(本面板)这类"源码在 GitHub、npm 上不存在"的宿主插件。
243
+ let fallbackError = err instanceof Error ? err.message : String(err)
244
+ try {
245
+ const meta = entryPkgMeta(packageName, ctx.baseUrl ?? 'file:///', profileDirOf(ctx))
246
+ const repo = typeof meta?.repository === 'string'
247
+ ? meta.repository.replace(/^git\+/u, '').replace(/\.git$/u, '')
248
+ : (meta?.repository && typeof meta.repository === 'object' ? meta.repository.url : null)
249
+ const m = typeof repo === 'string' ? repo.match(/github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?$/u) : null
250
+ if (m) {
251
+ let tag = null
252
+ // 通道 1:GitHub API(匿名可读,限流 60/h;token 时 5000/h)
253
+ try {
254
+ const auth = readGithubAuth()
255
+ const headers = { 'User-Agent': 'dsh-plugin-console' }
256
+ if (auth.token) headers.Authorization = `token ${auth.token}`
257
+ const release = await fetchJsonUrl(`https://api.github.com/repos/${m[1]}/releases/latest`, 12000, headers)
258
+ if (typeof release?.tag_name === 'string') tag = release.tag_name
259
+ } catch {}
260
+ // 通道 2:jsDelivr 版本列表(GitHub 直连黑洞时可用;取最高版本号)
261
+ if (tag === null) {
262
+ try {
263
+ const data = await fetchJsonUrl(`https://data.jsdelivr.com/v1/packages/gh/${m[1]}`, 12000)
264
+ const versions = Array.isArray(data?.versions) ? data.versions.map((v) => String(v.version ?? '')) : []
265
+ // 按语义版本号排序取最高(v 前缀剥离后比较)
266
+ const parsed = versions
267
+ .map((v) => ({ raw: v, ver: v.replace(/^v/iu, '') }))
268
+ .filter((x) => /^\d+\.\d+\.\d+/u.test(x.ver))
269
+ .sort((a, b) => {
270
+ const pa = a.ver.split(/[.-]/u).map((n) => (Number.isFinite(Number(n)) ? Number(n) : n))
271
+ const pb = b.ver.split(/[.-]/u).map((n) => (Number.isFinite(Number(n)) ? Number(n) : n))
272
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
273
+ const x = pa[i] ?? -1; const y = pb[i] ?? -1
274
+ if (x !== y) return typeof x === 'number' && typeof y === 'number' ? x - y : String(x) < String(y) ? -1 : 1
275
+ }
276
+ return 0
277
+ })
278
+ if (parsed.length > 0) tag = parsed[parsed.length - 1].raw
279
+ } catch {}
280
+ }
281
+ if (tag !== null) {
282
+ latest = tag.replace(/^v/iu, '')
283
+ next = null
284
+ source = 'github'
285
+ error = null
286
+ fallbackError = null
287
+ }
288
+ }
289
+ if (fallbackError !== null && latest === null) error = `npm 与 GitHub 均未检测到版本(${fallbackError})`
290
+ } catch (fbErr) {
291
+ error = `npm registry 查询失败且 GitHub 回退不可用(${fallbackError};${fbErr instanceof Error ? fbErr.message : String(fbErr)})`
292
+ }
293
+ }
294
+ // migrate 换名检测(2026-09-04 缺陷修复):本地包声明 dsh.migrate.to 时查目标包最新版/引擎声明,
295
+ // 识别「项目已改名/迁移发布」的更新(如 @linxin666/dsh-web-ui-all → @linxin666/dsh-web-all 0.3.14)
296
+ let migrate = null
297
+ try {
298
+ const localPath = resolvePackageJson(packageName, profileDir)
299
+ if (localPath !== null) {
300
+ const localPkg = JSON.parse(readFileSync(localPath, 'utf8'))
301
+ const to = typeof localPkg.dsh?.migrate?.to === 'string' ? localPkg.dsh.migrate.to : null
302
+ if (to !== null && to !== '' && to !== packageName) {
303
+ const encTo = to.startsWith('@')
304
+ ? `@${encodeURIComponent(to.slice(1).split('/')[0])}%2f${encodeURIComponent(to.split('/').slice(1).join('/'))}`
305
+ : encodeURIComponent(to)
306
+ const meta = await fetchJsonUrl(`https://registry.npmmirror.com/${encTo}`)
307
+ const toLatest = meta?.['dist-tags']?.latest ?? meta?.['dist-tags']?.next ?? null
308
+ const toPkg = toLatest !== null ? (meta?.versions?.[toLatest] ?? null) : null
309
+ const engine = toPkg?.dsh?.engines?.dsh ?? toPkg?.engines?.dsh ?? null
310
+ const fwVer = currentFrameworkVersion(ctx)
311
+ const compatible = fwVer !== null && (engine === null || semverRangeMatchLoose(fwVer, engine))
312
+ migrate = { to, latest: toLatest, engine, compatible }
313
+ }
314
+ }
315
+ } catch {}
316
+ sendJson(res, 200, { ok: true, packageName, latest, next, beta, depsOutdated, error, source, migrate })
317
+ return
318
+ }
319
+
320
+ async function routeFrameworkRollback(req, res, rc) {
321
+ const webPort = rc.deps.webPort
322
+ const ctx = rc.ctx
323
+ const url = rc.url
324
+ const pathname = rc.pathname
325
+ const method = rc.method
326
+ const body = rc.body
327
+ // 一键回滚(2026-09-04 事故后的新能力):读 framework-rollback.json(升级时写入),
328
+ // 生成分离脚本:停服 → 全树恢复(.pnpm 自包镜像 + 顶层 scope + lock)→ 拉起 → 状态。
329
+ let rec = null
330
+ try { rec = JSON.parse(readFileSync(join(dshHome(), 'plugin-console', 'framework-rollback.json'), 'utf8')) } catch {}
331
+ if (rec === null || typeof rec.checkpointDir !== 'string' || typeof rec.fwRoot !== 'string'
332
+ || !existsSync(join(rec.checkpointDir, '.pnpm')) || !existsSync(join(rec.fwRoot, '.pnpm'))) {
333
+ sendError(res, 409, '没有可用的框架全树回滚点(framework-rollback.json 缺失或 checkpoint 已清理)')
334
+ return
335
+ }
336
+ const port = webPort(ctx)
337
+ const nodePath = process.execPath
338
+ const taskName = `DSH-FW-Rollback-${process.pid}`
339
+ const ps1 = join(tmpdir(), `fw-rollback-${process.pid}.ps1`)
340
+ const logFile = join(dshHome(), 'plugin-console', 'fw-upgrade.log')
341
+ const stateFile = join(dshHome(), 'plugin-console', 'fw-upgrade-state.txt')
342
+ const ps = (s) => JSON.stringify(s).replace(/\\\\/gu, '\\')
343
+ const lines = [
344
+ `$state = ${ps(stateFile)}`,
345
+ `$log = ${ps(logFile)}`,
346
+ "function Log($m) { try { Add-Content -Path $log -Value ((Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' ' + $m) -Encoding UTF8 } catch {} }",
347
+ "function SetState($s, $m) {",
348
+ " try { if ($s -eq 'failed') { Set-Content -Path $state -Value ($s + '|' + $m + '|stage=' + [string]$script:stage) -Encoding UTF8; return } } catch {}",
349
+ " try { if ($s -ne 'done' -and $s -ne 'idle') { $script:stage = $s }; Set-Content -Path $state -Value ($s + '|' + $m) -Encoding UTF8; Beat } catch {}",
350
+ "}",
351
+ relaunchPrelude({ nodePath, pluginDir: pluginRoot(), fwRoot: rec.fwRoot, target: rec.from ?? '', ps }),
352
+ "trap {",
353
+ " try { SetState 'failed' ('回滚脚本异常终止:' + $_.Exception.Message) } catch {}",
354
+ ` schtasks /delete /f /tn ${taskName} 2>$null`,
355
+ " exit 1",
356
+ "}",
357
+ "SetState 'rollback' '回滚到升级前版本…'",
358
+ "Log '一键回滚脚本启动'",
359
+ `try { $svc = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($svc) { $svc | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }; Start-Sleep -Seconds 3 } } catch {}`,
360
+ "Log '服务已停止(回滚生效)'",
361
+ `$cp = ${ps(rec.checkpointDir)}`,
362
+ `$restored = 0`,
363
+ `$entries = Get-ChildItem -Path (Join-Path $cp '.pnpm') -Directory -ErrorAction SilentlyContinue`,
364
+ `foreach ($e in $entries) {`,
365
+ ` $name = @($e.Name -split '\\+')[1].Split('@')[0]`,
366
+ ` $src = Join-Path $e.FullName ('node_modules\\@deepseek-ai\\' + $name)`,
367
+ ` $dst = Join-Path (Join-Path ${ps(rec.fwRoot)} ('.pnpm\\' + $e.Name)) ('node_modules\\@deepseek-ai\\' + $name)`,
368
+ ` if (Test-Path (Join-Path $src 'package.json')) { New-Item -ItemType Directory -Path (Split-Path $dst -Parent) -Force | Out-Null; robocopy $src $dst /E /NFL /NDL /NJH /NJS /R:1 /W:1 | Out-Null; $restored++ }`,
369
+ `}`,
370
+ `$topSrc = Join-Path $cp 'top-@deepseek-ai'`,
371
+ `if (Test-Path $topSrc) { Remove-Item ${ps(join(rec.fwRoot, '@deepseek-ai'))} -Recurse -Force -ErrorAction SilentlyContinue; robocopy $topSrc ${ps(join(rec.fwRoot, '@deepseek-ai'))} /E /NFL /NDL /NJH /NJS /R:1 /W:1 | Out-Null }`,
372
+ `try { Copy-Item (Join-Path $cp 'lock.yaml') ${ps(join(rec.fwRoot, '.pnpm', 'lock.yaml'))} -Force -ErrorAction SilentlyContinue } catch {}`,
373
+ `Log ('全树回滚完成:恢复 ' + $restored + ' 个版本包 + 顶层 scope,拉起验证中…')`,
374
+ `$ok = $false`,
375
+ `$started = $false`,
376
+ `for ($i = 0; $i -lt 20; $i++) {`,
377
+ ` try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($c.Count -gt 0) { $ok = $true; break } } catch {}`,
378
+ ` if (-not $ok -and -not $started) { if (Invoke-DshRelaunch '回滚后') { $started = $true } }; Beat`,
379
+ ` Start-Sleep -Seconds 5`,
380
+ `}`,
381
+ `if ($ok) { SetState 'done' ('已回滚到升级前版本 ${rec.from ?? '?'},服务正常') ; Log '回滚完成,服务已恢复' } else { SetState 'failed' '回滚后服务拉起失败:请手动运行 node ${resolveDshBin() ?? '<bin>'} web' ; Log '回滚后拉起失败' }`,
382
+ `schtasks /delete /f /tn ${taskName} 2>$null`,
383
+ ].filter((l) => l !== '').join('\r\n')
384
+ try { writeFileSync(stateFile, 'rollback|回滚脚本已启动…', 'utf8') } catch {}
385
+ setFwCheckCache(null)// 回滚后 [框架] 面板要立刻显示回滚到的版本
386
+ writeFile(ps1, `\uFEFF${lines}`, 'utf8').then(
387
+ () => {
388
+ const ps1Posix = ps1.replace(/\\/gu, '/')
389
+ const tr = / /.test(ps1Posix)
390
+ ? `"powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File \\"${ps1Posix}\\""`
391
+ : `C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File ${ps1Posix}`
392
+ execFile('schtasks.exe', ['/create', '/f', '/tn', taskName, '/tr', tr, '/sc', 'once', '/st', '00:00'], { windowsHide: true }, (error) => {
393
+ if (error) {
394
+ execFile('powershell.exe', ['-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', ps1], { windowsHide: true, detached: true, stdio: 'ignore' }, () => {})
395
+ return
396
+ }
397
+ setTimeout(() => {
398
+ execFile('schtasks.exe', ['/run', '/tn', taskName], { windowsHide: true }, (runError) => {
399
+ if (runError) {
400
+ execFile('powershell.exe', ['-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', ps1], { windowsHide: true, detached: true, stdio: 'ignore' }, () => {})
401
+ }
402
+ })
403
+ }, 800)
404
+ })
405
+ },
406
+ () => {},
407
+ )
408
+ sendJson(res, 200, { ok: true, from: rec.from ?? null, checkpointDir: rec.checkpointDir })
409
+ return
410
+ }
411
+
412
+ async function routeRestart(req, res, rc) {
413
+ const webPort = rc.deps.webPort
414
+ const ctx = rc.ctx
415
+ const url = rc.url
416
+ const pathname = rc.pathname
417
+ const method = rc.method
418
+ const body = rc.body
419
+ // 自带守护的自杀式重启:分离脚本杀掉本进程后,若端口无人监听则自动拉起服务。
420
+ // 不再依赖桌面端监督器(它并不总是会重启服务,曾导致用户需要重启电脑)。
421
+ // 安全护栏(事故教训):无法定位 dsh bin 或 bin.js 不存在时**拒绝重启**——
422
+ // 避免"kill 后拉不起"(框架缓存损坏时常见),提示先修复框架安装。
423
+ //
424
+ // v0.3.43 事故修复(2026-09-11 用户实测「重启后服务没自己拉起来,只能手动重启」):
425
+ // 现场证据=一堆 Ready 僵尸任务(DSH-Restart-13804 / -31688 / -3744 / V2 / V3),
426
+ // 说明脚本杀完服务后**自己也被结束了**(与升级/回滚脚本同一个毛病:0xC000013A),
427
+ // 于是"检查端口→拉起"那几行根本没跑到;而且原来只等 3 秒、只查一次端口。
428
+ // 现在改成三层保险:
429
+ // ① 主脚本:等到端口真正空出来(最多 20 秒轮询)→ 拉起 → 重试 3 次 → 写 console-restart.log
430
+ // ② **守护任务**(关键):主脚本动手**之前**就注册一个每分钟跑一次的独立计划任务,
431
+ // 服务被杀、主脚本被杀都不影响它;端口起来了它自删,起不来就继续拉(最多 5 次)
432
+ // ③ bin 解析用与升级/回滚同一套多级回退(node resolve → .pnpm → 顶层链接)
433
+ const port = webPort(ctx)
434
+ const binPath = resolveDshBin()
435
+ const nodePath = process.execPath
436
+ if (binPath === null || !existsSync(binPath)) {
437
+ sendError(res, 500, `无法定位 DSH 启动入口(bin.js${binPath !== null ? `:${binPath}` : ''}),已取消重启——框架安装可能已损坏,请先修复 @deepseek-ai/dsh 后再重启`)
438
+ return
439
+ }
440
+ if (process.platform === 'win32') {
441
+ const consoleDir = join(dshHome(), 'plugin-console')
442
+ try { mkdirSync(consoleDir, { recursive: true }) } catch {}
443
+ const restartLog = join(consoleDir, 'console-restart.log')
444
+ const fwRoot = resolveFrameworkRootNodeModules(dirname(dirname(binPath)))
445
+ let installedVersion = ''
446
+ try { installedVersion = JSON.parse(readFileSync(join(dirname(dirname(binPath)), 'package.json'), 'utf8')).version ?? '' } catch {}
447
+ const ps = (s) => {
448
+ const j = JSON.stringify(String(s)).replace(/\\\\/gu, '\\')
449
+ if (j === '""') return "''"
450
+ return j.replace(/`/gu, '``').replace(/\$/gu, '`$')
451
+ }
452
+ const prelude = relaunchPrelude({ nodePath, pluginDir: pluginRoot(), fwRoot: fwRoot ?? dirname(dirname(binPath)), target: installedVersion, ps })
453
+ const taskName = `DSH-Restart-${process.pid}`
454
+ const guardName = `DSH-RestartGuard-${process.pid}`
455
+ const guardCount = join(consoleDir, `restart-guard-${process.pid}.count`)
456
+ const killLine = `Stop-Process -Id ${process.pid} -Force -ErrorAction SilentlyContinue`
457
+ // ① 主脚本:等端口空 → 拉起(重试 3 次)
458
+ const mainLines = [
459
+ `$log = ${ps(restartLog)}`,
460
+ `$state = ${ps(join(consoleDir, 'fw-upgrade-state.txt'))}`,
461
+ `function Log($m) { try { Add-Content -Path $log -Value ((Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' ' + $m) -Encoding UTF8 } catch {} }`,
462
+ prelude,
463
+ `Log ('重启脚本启动:目标端口 ${port},bin=' + ${ps(binPath)})`,
464
+ killLine,
465
+ // 等到端口真正空出来(原实现只 sleep 3 秒、只查一次 —— 端口还占着就误判"已有人监听"而跳过拉起)
466
+ `$free = $false`,
467
+ `for ($i = 0; $i -lt 20; $i++) { Start-Sleep -Seconds 1; try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if (-not $c -or $c.Count -eq 0) { $free = $true; break } } catch { $free = $true; break } }`,
468
+ `Log ('端口 ' + ${ps(String(port))} + ' 状态:' + $(if ($free) { '已释放' } else { '仍被占用(可能被其它实例占着)' }))`,
469
+ `$up = $false`,
470
+ `for ($a = 1; $a -le 3; $a++) {`,
471
+ ` [void](Invoke-DshRelaunch ('重启第 ' + $a + ' 次'))`,
472
+ ` for ($w = 0; $w -lt 8; $w++) { Start-Sleep -Seconds 2; try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($c -and $c.Count -gt 0) { $up = $true; break } } catch {} }; if ($up) { break }`,
473
+ `}`,
474
+ `if ($up) { Log '服务已重新监听,重启完成' } else { Log '三次拉起后端口仍未监听:守护任务会继续尝试(输出见 fw-relaunch.log)' }`,
475
+ `schtasks /delete /f /tn ${taskName} 2>$null`,
476
+ ]
477
+ // ② 守护任务:独立于主脚本与服务进程,端口不起来就一直拉(最多 5 次)
478
+ const guardLines = [
479
+ `$log = ${ps(restartLog)}`,
480
+ `$state = ${ps(join(consoleDir, 'fw-upgrade-state.txt'))}`,
481
+ `function Log($m) { try { Add-Content -Path $log -Value ((Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' [guard] ' + $m) -Encoding UTF8 } catch {} }`,
482
+ prelude,
483
+ `$c = $null`,
484
+ `try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue } catch {}`,
485
+ `if ($c -and $c.Count -gt 0) { Log '服务已在监听,守护任务收工'; Remove-Item ${ps(guardCount)} -Force -ErrorAction SilentlyContinue; schtasks /delete /f /tn ${guardName} 2>$null; schtasks /delete /f /tn ${taskName} 2>$null; exit 0 }`,
486
+ `$n = 0`,
487
+ `try { $n = [int](Get-Content ${ps(guardCount)} -Raw -ErrorAction SilentlyContinue) } catch { $n = 0 }`,
488
+ `$n = $n + 1`,
489
+ `if ($n -gt 5) { Log ('已尝试 ' + ($n - 1) + ' 次仍拉不起来,放弃并自删(请手动启动,或看 fw-relaunch.log / console-restart.log)'); schtasks /delete /f /tn ${guardName} 2>$null; exit 0 }`,
490
+ `try { Set-Content -Path ${ps(guardCount)} -Value ([string]$n) -Encoding UTF8 } catch {}`,
491
+ `Log ('端口 ' + ${ps(String(port))} + ' 无监听,第 ' + $n + ' 次拉起')`,
492
+ `[void](Invoke-DshRelaunch ('守护第 ' + $n + ' 次'))`,
493
+ ]
494
+ const ps1 = join(tmpdir(), `console-restart-${process.pid}.ps1`)
495
+ const guardPs1 = join(tmpdir(), `console-restart-guard-${process.pid}.ps1`)
496
+ const scheduleFor = (file, name, scheduleArgs) => {
497
+ const posix = String(file).replace(/\\/gu, '/')
498
+ // 无引号 /tr(重要):Task Scheduler 对带引号命令的解析会把 Command 拆坏成
499
+ // `"powershell ... -File \"`(非有效可执行文件)——任务显示 Ready、/run 报 SUCCESS
500
+ // 但永不执行(曾导致升级/重启脚本反复"已启动"却不动作、服务不停止)。实测无引号
501
+ // 格式(exe 与脚本路径均无空格时)任务正常执行、脚本完整跑通。仅当脚本路径含空格
502
+ // 时才退回带引号格式(schtasks 引号解析在服务 execFile 上下文不可靠,此时宁可用它)。
503
+ const tr = / /.test(posix)
504
+ ? `"powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File \\"${posix}\\""`
505
+ : `C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File ${posix}`
506
+ return new Promise((resolve) => {
507
+ execFile('schtasks.exe', ['/create', '/f', '/tn', name, '/tr', tr, ...scheduleArgs], { windowsHide: true }, (error) => resolve(error ?? null))
508
+ })
509
+ }
510
+ Promise.all([
511
+ writeFile(ps1, `\uFEFF${mainLines.join('\r\n')}`, 'utf8'),
512
+ writeFile(guardPs1, `\uFEFF${guardLines.join('\r\n')}`, 'utf8'),
513
+ ]).then(
514
+ async () => {
515
+ // 守护任务先注册(每分钟一次,独立于服务进程树)——主脚本被杀也有它兜底
516
+ const guardError = await scheduleFor(guardPs1, guardName, ['/sc', 'minute', '/mo', '1'])
517
+ const mainError = await scheduleFor(ps1, taskName, ['/sc', 'once', '/st', '00:00'])
518
+ if (mainError !== null) {
519
+ // schtasks 不可用:退回 detached 直接执行主脚本(守护任务仍可能已建)
520
+ execFile('powershell.exe', ['-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', ps1], { windowsHide: true, detached: true, stdio: 'ignore' }, () => {})
521
+ return
522
+ }
523
+ execFile('schtasks.exe', ['/run', '/tn', taskName], { windowsHide: true }, () => {})
524
+ if (guardError !== null) {
525
+ try { writeFileSync(restartLog, `[warn] 守护任务注册失败(${guardError.message}),仅靠主脚本重启\n`, { flag: 'a' }) } catch {}
526
+ }
527
+ },
528
+ () => {},
529
+ )
530
+ } else {
531
+ const script = process.platform === 'win32'
532
+ ? `Start-Sleep -Seconds 2; Stop-Process -Id ${process.pid} -Force`
533
+ : `sleep 2; kill -9 ${process.pid}`
534
+ const cmd = process.platform === 'win32' ? 'powershell.exe' : 'sh'
535
+ const args = process.platform === 'win32'
536
+ ? ['-NoProfile', '-WindowStyle', 'Hidden', '-Command', script]
537
+ : ['-c', script]
538
+ execFile(cmd, args, { windowsHide: true }, () => {})
539
+ }
540
+ sendJson(res, 200, { ok: true, message: `正在重启 DSH 服务(自带守护,端口 ${port} 无监听会自动拉起),页面稍后自动恢复` })
541
+ return
542
+ }
543
+
544
+ export { routeFrameworkUpgradeStatusGet, routeFrameworkRelaunch, routeFrameworkCheck, routeCompatGate, routeCheckUpdate, routeFrameworkRollback, routeRestart }