@noob-stupid/dsh-plugin-console 0.5.43 → 0.5.45
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/README.md +4 -2
- package/README.zh.md +3 -1
- package/lib/client.js +14 -11
- package/lib/server/domain/restart.js +120 -0
- package/lib/server/routes/framework.js +84 -167
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -439,8 +439,10 @@ Verification matrix, raw logs and the one framework-side caveat (`0.1.2-rc.1` sh
|
|
|
439
439
|
|
|
440
440
|
## Known limitations
|
|
441
441
|
|
|
442
|
-
- Host code changes require a **service restart
|
|
443
|
-
|
|
442
|
+
- Host code changes require a **service restart**: the panel's restart button only points at the
|
|
443
|
+
**official path** (restart inside the desktop client; the console never kills or spawns a
|
|
444
|
+
process — a manual relaunch would fight the host for the port and produce
|
|
445
|
+
"The application could not start or stopped unexpectedly"); client changes just need a page refresh;
|
|
444
446
|
- Live GitHub search depends on GitHub reachability (browser-direct + server fallback;
|
|
445
447
|
during network-blackout windows retry later);
|
|
446
448
|
- Version detection works for npm-published packages; skill-type repos have no version concept;
|
package/README.zh.md
CHANGED
|
@@ -373,7 +373,9 @@ node scripts/safe-boot.mjs --mark-good # 把某份快照标记为"良
|
|
|
373
373
|
|
|
374
374
|
## 已知限制
|
|
375
375
|
|
|
376
|
-
-
|
|
376
|
+
- 宿主代码变更需要**重启服务**:面板的重启按钮只指向**官方路径**(在桌面端客户端里重启;
|
|
377
|
+
控制台绝不 kill / 拉起任何进程——手动拉起会与宿主争端口,并产生
|
|
378
|
+
「应用无法启动或已意外停止」);客户端变更只需刷新页面;
|
|
377
379
|
- 实时 GitHub 搜索依赖 GitHub 可达性(浏览器直连 + 服务端兜底;网络黑洞期请稍后重试);
|
|
378
380
|
- 版本检测只对已发布到 npm 的包生效;技能类仓库没有版本概念;
|
|
379
381
|
- 静态索引有上限(每次构建 500 插件 / 300 技能);新仓库靠 star 爬升或等下一轮 6 小时 CI 进入索引;
|
package/lib/client.js
CHANGED
|
@@ -388,8 +388,8 @@ window.__ModuleLoader__.load({
|
|
|
388
388
|
stageInstalling: "下载安装中(可离开本页,后台继续)",
|
|
389
389
|
stageConfiguring: "写入启用配置",
|
|
390
390
|
stageRepairing: "本地 AI 接管安装中…",
|
|
391
|
-
restartService: "
|
|
392
|
-
restarting: "
|
|
391
|
+
restartService: "重启服务(官方路径)",
|
|
392
|
+
restarting: "正在按官方路径处理重启请求…",
|
|
393
393
|
autoReload: "页面即将自动刷新…",
|
|
394
394
|
aiRepaired: "本地 AI 已接管并完成修复,请刷新页面查看",
|
|
395
395
|
deletePlugin: "删除插件(移除配置并卸载包)",
|
|
@@ -648,9 +648,9 @@ window.__ModuleLoader__.load({
|
|
|
648
648
|
frameworkUpToDate: "已是最新框架",
|
|
649
649
|
frameworkUpToDateTitle: "当前 DSH 框架已是最新版本(升级请走官方流程或等新版本发布)",
|
|
650
650
|
frameworkUseUpgrade: "deepseek-harness 是 DSH 框架本体,无需安装;升级请使用卡片上的「框架升级」流程(有新版本时出现)",
|
|
651
|
-
relaunchBtn: "
|
|
652
|
-
relaunchTitle: "
|
|
653
|
-
relaunchDone: "
|
|
651
|
+
relaunchBtn: "重启指引",
|
|
652
|
+
relaunchTitle: "服务断开时点此看官方重启方式(控制台不会自行 kill / 拉起进程)",
|
|
653
|
+
relaunchDone: "已按官方路径处理(控制台不会自行拉起进程)",
|
|
654
654
|
fwStepBackup: "备份现有配置",
|
|
655
655
|
fwStepStop: "停止服务(页面将断开)",
|
|
656
656
|
fwStepInstall: "升级框架本体",
|
|
@@ -948,8 +948,8 @@ window.__ModuleLoader__.load({
|
|
|
948
948
|
stageInstalling: "Downloading & installing (safe to leave this page)",
|
|
949
949
|
stageConfiguring: "Writing enable config",
|
|
950
950
|
stageRepairing: "Local AI is taking over the install…",
|
|
951
|
-
restartService: "Restart service",
|
|
952
|
-
restarting: "
|
|
951
|
+
restartService: "Restart service (official path)",
|
|
952
|
+
restarting: "Handling the restart request via the official path…",
|
|
953
953
|
autoReload: "Page will auto-reload…",
|
|
954
954
|
aiRepaired: "Local AI took over and finished the repair; refresh the page",
|
|
955
955
|
deletePlugin: "Delete plugin (remove config and uninstall package)",
|
|
@@ -1205,12 +1205,12 @@ window.__ModuleLoader__.load({
|
|
|
1205
1205
|
frameworkUpToDate: "Framework up to date",
|
|
1206
1206
|
frameworkUpToDateTitle: "Current DSH framework is already the latest (upgrade via the official flow or wait for a new release)",
|
|
1207
1207
|
frameworkUseUpgrade: "deepseek-harness is the DSH framework itself, not a plugin; upgrade via the card's Framework Upgrade flow (shown when a new version exists)",
|
|
1208
|
-
relaunchBtn: "
|
|
1208
|
+
relaunchBtn: "Restart guidance",
|
|
1209
1209
|
frameworkRollbackBtn: "Rollback to previous",
|
|
1210
1210
|
frameworkRollbackTitle: "One-click rollback to the previous framework version (full tree), service relaunches automatically",
|
|
1211
1211
|
frameworkRollbackDone: "Rollback started: {from} -> restoring, service will briefly disconnect",
|
|
1212
|
-
relaunchTitle: "
|
|
1213
|
-
relaunchDone: "
|
|
1212
|
+
relaunchTitle: "Service may be down — click for the official restart path (the console never kills or spawns processes)",
|
|
1213
|
+
relaunchDone: "Handled via the official path (the console never spawns a process)",
|
|
1214
1214
|
fwStepBackup: "Backup current config",
|
|
1215
1215
|
fwStepStop: "Stop service (page will disconnect)",
|
|
1216
1216
|
fwStepInstall: "Upgrade framework",
|
|
@@ -4830,9 +4830,12 @@ window.__ModuleLoader__.load({
|
|
|
4830
4830
|
title: t("relaunchTitle"),
|
|
4831
4831
|
"aria-label": t("relaunchBtn"),
|
|
4832
4832
|
onClick: () => {
|
|
4833
|
+
// 2026-10-10:服务端这条路由已改为**官方路径 / 如实告知**(零 spawn / 零 kill),
|
|
4834
|
+
// 永远回 409 + 面向用户的短句 ⇒ 原样显示那句话(不再套"安全开关失败:"前缀,
|
|
4835
|
+
// 否则用户看到的是"失败"而不是"该怎么做")。
|
|
4833
4836
|
call("/plugin-console/framework-relaunch", {}).then(
|
|
4834
4837
|
(data) => { if (data && data.ok === true) setMessage(t("relaunchDone")); },
|
|
4835
|
-
(error) => setMessage(
|
|
4838
|
+
(error) => { setMessage(error !== null && typeof error.message === "string" && error.message !== "" ? error.message : t("safetySwitchFailed")); },
|
|
4836
4839
|
);
|
|
4837
4840
|
},
|
|
4838
4841
|
}, t("relaunchBtn"))
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// L1 · domain —— restart.js(「重启 / 重载」路径的**唯一承担者**,2026-10-10 改错)
|
|
2
|
+
// 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言)——纯判据,不碰 ctx / 不碰 IO。
|
|
3
|
+
//
|
|
4
|
+
// ── 用户红线原话(2026-10-10)────────────────────────────────────────────────
|
|
5
|
+
// 「桌面端重启必须走官方的那种不报错重启,如果你走手动拉起之类的等等很可能会出现那种报错,
|
|
6
|
+
// 这是一定不能的」+「有时候控制台代理重启啥的会出现《应用无法启动或已意外停止》」。
|
|
7
|
+
//
|
|
8
|
+
// ── 元凶现场(<DSH_HOME>/plugin-console/,2026-10-10 15:27–15:33,实测原文)──────
|
|
9
|
+
// console-restart.log:
|
|
10
|
+
// 15:28:02 [guard] 端口 3080 无监听,第 1 次拉起
|
|
11
|
+
// 15:28:02 [guard] 已发起拉起服务(守护第 1 次,输出见 fw-relaunch.log)
|
|
12
|
+
// …(每分钟一次,直到第 5 次)
|
|
13
|
+
// 15:33:03 [guard] 已尝试 5 次仍拉不起来,放弃并自删(请手动启动,或看 fw-relaunch.log / console-restart.log)
|
|
14
|
+
// fw-relaunch.log:`拉起(守护第 N 次): <npx 缓存>\@deepseek-ai\dsh\lib\bin.js`
|
|
15
|
+
// ⇒ 旧实现:`POST /restart` 生成 PowerShell 自杀脚本 + 注册一个**每分钟跑一次**的
|
|
16
|
+
// `DSH-RestartGuard-<pid>` 计划任务;端口无监听就 `Start-Process node <bin.js> web`
|
|
17
|
+
// 手动拉起(最多 5 次),第 6 次 `schtasks /delete` **自删任务**(危险动作)。
|
|
18
|
+
// 触发面=控制台里的「重启服务」按钮 / 自更新成功后自动调 / 升级期间的悬浮「拉起」按钮;
|
|
19
|
+
// 守护任务一旦注册就**自己按分钟跑**(按钮触发 → 自动守护,两条都在)。
|
|
20
|
+
//
|
|
21
|
+
// ── 为什么"手动拉起"必然报错(两条路都通向同一句弹窗)─────────────────────────
|
|
22
|
+
// ① 桌面端实例:宿主是 Electron 外壳的**子进程**(Electron lib/main.js:3674 `spawn(node,
|
|
23
|
+
// [--expose-internals, …dsh-desktop-host/lib/index.js, …])`)。kill 它 ⇒ 外壳
|
|
24
|
+
// `child.once('close')`(lib/main.js:3722-3727)→ `fail()` → DesktopBackendController
|
|
25
|
+
// 发布 `phase:'error'` → `reportFatal(state.failure, 'host')`(lib/main.js:11389)
|
|
26
|
+
// ⇒ 弹「应用无法启动或已意外停止。」(lib/main.js:6709 `fatalSummary`)。
|
|
27
|
+
// ② 独立 `dsh web` 实例:再 spawn 一个 `dsh web` 必然与宿主**争同一个端口** ⇒
|
|
28
|
+
// `listen EADDRINUSE` ⇒ 外壳那一支的文案「有其他正在运行的 DSH(如其他 dsh web、桌面端),
|
|
29
|
+
// 无法同时启动」(lib/main.js:6543 / 6710)。
|
|
30
|
+
//
|
|
31
|
+
// ── 官方那条"不报错"的路(原文出处)──────────────────────────────────────────
|
|
32
|
+
// · 桌面端自带的官方重启 = Electron `app.relaunch(); quitWithoutConfirmation()`
|
|
33
|
+
// —— Electron lib/main.js:10983-10986(崩溃恢复对话框的「重启」按钮;标签 `restartApplication`
|
|
34
|
+
// 见 lib/main.js:6715,按钮表见 7551-7557,「地址被占用」分支在 7545-7551)、
|
|
35
|
+
// lib/main.js:11906-11911(应用菜单「重启应用与 Host」/ `restartAppHostMenu` lib/main.js:6751)。
|
|
36
|
+
// 它先走外壳自己的受管收尾(`child.send({ type:'shutdown' })` → 优雅等待 → SIGTERM/SIGKILL;
|
|
37
|
+
// dsh-desktop-host/lib/index.js:265 + lib/main.js:3790-3807),再由 Electron 自己拉起**整个应用**
|
|
38
|
+
// ——全程只有一个实例、不抢端口、宿主进程由外壳自己收尾 ⇒ 不报错。
|
|
39
|
+
// · **但插件拿不到它**(本模块存在的理由):桌面端 IPC 全表(lib/preload-app.cjs:6-30 ≡
|
|
40
|
+
// lib/main.js:6193-6218)没有 restart/relaunch 通道;外壳 ↔ 宿主控制通道只有
|
|
41
|
+
// `update-tasks` / `quit-inspection` / `shutdown`(dsh-desktop-host/lib/index.js:265-310)。
|
|
42
|
+
// ⇒ 插件无法调用官方重启,唯一诚实的做法是**请用户在客户端里重启**。
|
|
43
|
+
// · 官方对"改动何时生效"的语义 = `restart-required` → 「更改将在下次启动生效」
|
|
44
|
+
// (dsh-client-ui-plugin-manager/lib/client.js:91 文案 / :626 取值 / :1462 判据)。
|
|
45
|
+
//
|
|
46
|
+
// ── 本模块的判定(全仓唯一,调用点不许各写一份)──────────────────────────────
|
|
47
|
+
// hosted=true → channel 'desktop-client':官方路径 = 用户在桌面端客户端内重启
|
|
48
|
+
// hosted=false → channel 'unavailable' :没有受管重启器 ⇒ 如实告知手动重启
|
|
49
|
+
// **两种情况都是 spawns=false / kill=false —— 绝不 spawn / kill / 建计划任务 / 自删。**
|
|
50
|
+
|
|
51
|
+
/** 桌面端托管的实例:官方重启通道在客户端手里(插件无此通道)。 */
|
|
52
|
+
const DESKTOP_CLIENT_RESTART_NOTICE = '本实例由桌面端外壳托管 —— 请在桌面端客户端里重启:完全退出 DeepSeek Harness 再重新打开;'
|
|
53
|
+
+ '若已弹出「应用无法启动或已意外停止」,直接点弹窗里的「重启」。'
|
|
54
|
+
+ '控制台不会自行 kill / spawn 进程:手动拉起会与宿主争端口,那正是该报错的来源。'
|
|
55
|
+
|
|
56
|
+
/** 独立 `dsh web` 实例:DSH 没有给插件用的受管重启器,官方重启通道不可用。 */
|
|
57
|
+
const STANDALONE_RESTART_NOTICE = 'DSH 没有可由插件调用的官方重启通道:请手动重启这个 dsh web 进程'
|
|
58
|
+
+ '(在启动它的终端里 Ctrl+C,然后重新运行 dsh web)。'
|
|
59
|
+
+ '控制台不会自行 kill / spawn —— 手动拉起会与宿主争端口并产生「应用无法启动或已意外停止」这类报错。'
|
|
60
|
+
|
|
61
|
+
/** `/framework-relaunch`(原「手动拉起服务」悬浮按钮):同一判据,另加"这条路已下线"的说明。 */
|
|
62
|
+
const MANUAL_LAUNCH_REMOVED_NOTE = '「手动拉起服务」已按官方路径下线(控制台不再 spawn 新的 dsh 实例):'
|
|
63
|
+
|
|
64
|
+
/** 两条官方判定通道(稳定字符串,前端与测试按它判因)。 */
|
|
65
|
+
const RESTART_CHANNEL = { desktopClient: 'desktop-client', unavailable: 'unavailable' }
|
|
66
|
+
|
|
67
|
+
/** 面向用户的出路(结构化下发,前端可原样展示;桌面端与独立实例各自一条真出路)。 */
|
|
68
|
+
const GUIDES = {
|
|
69
|
+
'desktop-client': [
|
|
70
|
+
'桌面端:完全退出 DeepSeek Harness,再重新打开(官方 app.relaunch 那条路)。',
|
|
71
|
+
'已弹出「应用无法启动或已意外停止」时:直接点弹窗里的「重启」(官方恢复对话框)。',
|
|
72
|
+
'只是想让插件改动生效:按 DSH 官方语义"下次启动生效"(restart-required),刷新页面即可看到已生效的部分。',
|
|
73
|
+
],
|
|
74
|
+
unavailable: [
|
|
75
|
+
'独立 dsh web:在启动它的终端里 Ctrl+C,然后重新运行 dsh web。',
|
|
76
|
+
'只改了客户端插件:DSH 官方语义是"下次启动生效"(restart-required),直接刷新页面。',
|
|
77
|
+
'不要用任何"按端口 kill 再拉起"的做法:第二个实例会与宿主争端口(listen EADDRINUSE)。',
|
|
78
|
+
],
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 「重启 / 重载」路径的唯一判据。
|
|
83
|
+
*
|
|
84
|
+
* @param {object} p
|
|
85
|
+
* @param {boolean} p.hosted 是否由桌面端外壳托管(调用方用 domain/framework.js#detectHostShape 判定后传入;
|
|
86
|
+
* 本函数不重复判定,保证与 shellHostedRefusal 同一份事实)
|
|
87
|
+
* @param {'restart'|'relaunch'} [p.intent] 控制台里的哪颗按钮(只影响文案前缀,判定完全相同)
|
|
88
|
+
* @returns {{ ok: false, official: boolean, channel: string, spawns: false, kill: false,
|
|
89
|
+
* status: number, code: string, message: string, details: object }}
|
|
90
|
+
*/
|
|
91
|
+
function restartPathDecision({ hosted, intent = 'restart' } = {}) {
|
|
92
|
+
const shellHosted = hosted === true
|
|
93
|
+
const channel = shellHosted ? RESTART_CHANNEL.desktopClient : RESTART_CHANNEL.unavailable
|
|
94
|
+
const base = shellHosted ? DESKTOP_CLIENT_RESTART_NOTICE : STANDALONE_RESTART_NOTICE
|
|
95
|
+
const message = intent === 'relaunch' ? MANUAL_LAUNCH_REMOVED_NOTE + base : base
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
official: shellHosted,
|
|
99
|
+
channel,
|
|
100
|
+
spawns: false,
|
|
101
|
+
kill: false,
|
|
102
|
+
status: 409,
|
|
103
|
+
code: `official-restart-${channel}`,
|
|
104
|
+
message,
|
|
105
|
+
details: {
|
|
106
|
+
code: `official-restart-${channel}`,
|
|
107
|
+
official: shellHosted,
|
|
108
|
+
channel,
|
|
109
|
+
// 机器可读的红线判据:这两项永远是 false(测试的负控就钉在这里)
|
|
110
|
+
spawns: false,
|
|
111
|
+
kill: false,
|
|
112
|
+
scheduledTask: false,
|
|
113
|
+
selfDelete: false,
|
|
114
|
+
manualLaunch: intent === 'relaunch' ? 'removed' : 'never',
|
|
115
|
+
guide: GUIDES[channel],
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export { DESKTOP_CLIENT_RESTART_NOTICE, MANUAL_LAUNCH_REMOVED_NOTE, RESTART_CHANNEL, STANDALONE_RESTART_NOTICE, restartPathDecision }
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
// L2 · routes —— 框架(/framework-check · /check-update · /framework-upgrade · /framework-rollback · /framework-upgrade-status · /framework-relaunch · /compat-gate · /restart)
|
|
2
2
|
// 分层 Step 8b:从 lib/index.js 的 handle() 原样搬出(只搬移未改逻辑;缩进保持原样)
|
|
3
3
|
|
|
4
|
-
import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync } from 'node:fs'
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync, rmSync } from 'node:fs'
|
|
5
5
|
import { writeFile } from 'node:fs/promises'
|
|
6
6
|
import { execFile } from 'node:child_process'
|
|
7
|
-
import { dirname, join, resolve } from 'node:path'
|
|
7
|
+
import { dirname, join, resolve, basename } 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, platformUpgradeRefusal, relaunchPrelude, resolveDshBin,
|
|
11
|
+
import { cleanupStaleFwTasks, currentFrameworkVersion, detectHostShape, pickFrameworkTarget, platformUpgradeRefusal, relaunchPrelude, resolveDshBin, shellHostedRefusal } from '../domain/framework.js'
|
|
12
|
+
import { restartPathDecision } from '../domain/restart.js'
|
|
12
13
|
import { readGithubAuth } from '../domain/install.js'
|
|
13
14
|
import { webPort } from '../domain/runtime.js'
|
|
14
15
|
import { fetchJsonUrl } from '../infra/http.js'
|
|
@@ -33,6 +34,17 @@ function refuseWhenShellHosted(res) {
|
|
|
33
34
|
return true
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* 「本实例是否由桌面端外壳托管」——**零 spawn 路由**(/restart 与 /framework-relaunch)的唯一判定入口。
|
|
39
|
+
* 与 refuseWhenShellHosted 共用 domain/framework.js#detectHostShape 这一份事实,不另立判据。
|
|
40
|
+
* `rc.deps.hosted` 是单测的确定性注入口(正/负控见 tests/test-restart-official-path.mjs);
|
|
41
|
+
* 生产路径不传 ⇒ 现判(读真实进程全局)。缺省语义 =「判不出托管就不当作托管」——本路由在任何
|
|
42
|
+
* 分支都**不 spawn / 不 kill**,所以偏向哪边都不会造成"拉起"这个危险动作。
|
|
43
|
+
*/
|
|
44
|
+
function hostedHere(rc) {
|
|
45
|
+
return rc.deps?.hosted ?? detectHostShape().hosted === true
|
|
46
|
+
}
|
|
47
|
+
|
|
36
48
|
/**
|
|
37
49
|
* 平台守卫(2026-09-29 改错,与宿主守卫同形):一键升级/回滚脚本整体依赖 Windows 机制 ——
|
|
38
50
|
* `schtasks.exe` 计划任务 + PowerShell + `Get-NetTCPConnection`;非 Windows 上 execFile 必然 ENOENT,
|
|
@@ -124,38 +136,14 @@ async function routeFrameworkUpgradeStatusGet(req, res, rc) {
|
|
|
124
136
|
}
|
|
125
137
|
|
|
126
138
|
async function routeFrameworkRelaunch(req, res, rc) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
// 改为让用户在桌面端内重启。
|
|
136
|
-
if (refuseWhenShellHosted(res)) return
|
|
137
|
-
const port = webPort(ctx)
|
|
138
|
-
const binPath = resolveDshBin()
|
|
139
|
-
const nodePath = process.execPath
|
|
140
|
-
if (binPath === null || !existsSync(binPath)) {
|
|
141
|
-
sendError(res, 500, '无法定位 DSH 启动入口(bin.js 缺失)')
|
|
142
|
-
return
|
|
143
|
-
}
|
|
144
|
-
try {
|
|
145
|
-
const ps1 = join(tmpdir(), `console-relaunch-${Date.now()}.ps1`)
|
|
146
|
-
const lines = [
|
|
147
|
-
'$ok = $false',
|
|
148
|
-
`try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($c.Count -gt 0) { $ok = $true } } catch {}`,
|
|
149
|
-
`if (-not $ok) { Start-Process -FilePath ${JSON.stringify(nodePath)} -ArgumentList ${JSON.stringify(binPath)},'web' -WindowStyle Hidden }`,
|
|
150
|
-
]
|
|
151
|
-
writeFile(ps1, lines.join('\r\n'), 'utf8').then(
|
|
152
|
-
() => execFile('powershell.exe', ['-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', ps1], { windowsHide: true }, () => {}),
|
|
153
|
-
() => {},
|
|
154
|
-
)
|
|
155
|
-
sendJson(res, 200, { ok: true, message: '已发起手动拉起(端口无监听时自动启动服务)' })
|
|
156
|
-
} catch (error) {
|
|
157
|
-
sendError(res, 500, `拉起失败:${error instanceof Error ? error.message : String(error)}`)
|
|
158
|
-
}
|
|
139
|
+
// 原「手动拉起服务」(升级期间左侧悬浮按钮):`Start-Process node bin.js web`。
|
|
140
|
+
// 2026-10-10 用户红线 + 现场取证(console-restart.log「[guard] 端口 N 无监听,第 N 次拉起」/
|
|
141
|
+
// fw-relaunch.log「拉起(守护第 N 次): …\dsh\lib\bin.js」)⇒ 这条"手动拉起"整条下线:
|
|
142
|
+
// spawn 出来的第二个 `dsh web` 会与宿主争端口(listen EADDRINUSE),桌面端托管时更是
|
|
143
|
+
// 直接把宿主子进程顶掉 ⇒ 外壳弹「应用无法启动或已意外停止。」。
|
|
144
|
+
// 现在统一走 domain/restart.js 的**唯一判据**(官方路径 / 如实告知),本路由零 spawn / 零 kill。
|
|
145
|
+
const decision = restartPathDecision({ hosted: hostedHere(rc), intent: 'relaunch' })
|
|
146
|
+
sendError(res, decision.status, decision.message, decision.details)
|
|
159
147
|
return
|
|
160
148
|
}
|
|
161
149
|
|
|
@@ -451,148 +439,77 @@ async function routeFrameworkRollback(req, res, rc) {
|
|
|
451
439
|
}
|
|
452
440
|
|
|
453
441
|
async function routeRestart(req, res, rc) {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
const url = rc.url
|
|
457
|
-
const pathname = rc.pathname
|
|
458
|
-
const method = rc.method
|
|
459
|
-
const body = rc.body
|
|
460
|
-
// 自带守护的自杀式重启:分离脚本杀掉本进程后,若端口无人监听则自动拉起服务。
|
|
461
|
-
// 不再依赖桌面端监督器(它并不总是会重启服务,曾导致用户需要重启电脑)。
|
|
462
|
-
// 安全护栏(事故教训):无法定位 dsh bin 或 bin.js 不存在时**拒绝重启**——
|
|
463
|
-
// 避免"kill 后拉不起"(框架缓存损坏时常见),提示先修复框架安装。
|
|
442
|
+
// 「重启服务」按钮 / 自更新成功后自动调 / 升级期间悬浮按钮,全部收进这一条:
|
|
443
|
+
// domain/restart.js#restartPathDecision 是**唯一承担者**,本路由只负责把它翻成响应。
|
|
464
444
|
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
if (binPath === null || !existsSync(binPath)) {
|
|
480
|
-
sendError(res, 500, `无法定位 DSH 启动入口(bin.js${binPath !== null ? `:${binPath}` : ''}),已取消重启——框架安装可能已损坏,请先修复 @deepseek-ai/dsh 后再重启`)
|
|
481
|
-
return
|
|
482
|
-
}
|
|
483
|
-
if (process.platform === 'win32') {
|
|
484
|
-
const consoleDir = join(dshHome(), 'plugin-console')
|
|
485
|
-
try { mkdirSync(consoleDir, { recursive: true }) } catch {}
|
|
486
|
-
const restartLog = join(consoleDir, 'console-restart.log')
|
|
487
|
-
const fwRoot = resolveFrameworkRootNodeModules(dirname(dirname(binPath)))
|
|
488
|
-
let installedVersion = ''
|
|
489
|
-
try { installedVersion = JSON.parse(readFileSync(join(dirname(dirname(binPath)), 'package.json'), 'utf8')).version ?? '' } catch {}
|
|
490
|
-
const ps = (s) => {
|
|
491
|
-
const j = JSON.stringify(String(s)).replace(/\\\\/gu, '\\')
|
|
492
|
-
if (j === '""') return "''"
|
|
493
|
-
return j.replace(/`/gu, '``').replace(/\$/gu, '`$')
|
|
494
|
-
}
|
|
495
|
-
const prelude = relaunchPrelude({ nodePath, pluginDir: pluginRoot(), fwRoot: fwRoot ?? dirname(dirname(binPath)), target: installedVersion, ps })
|
|
496
|
-
const taskName = `DSH-Restart-${process.pid}`
|
|
497
|
-
const guardName = `DSH-RestartGuard-${process.pid}`
|
|
498
|
-
const guardCount = join(consoleDir, `restart-guard-${process.pid}.count`)
|
|
499
|
-
const killLine = `Stop-Process -Id ${process.pid} -Force -ErrorAction SilentlyContinue`
|
|
500
|
-
// ① 主脚本:等端口空 → 拉起(重试 3 次)
|
|
501
|
-
const mainLines = [
|
|
502
|
-
`$log = ${ps(restartLog)}`,
|
|
503
|
-
`$state = ${ps(join(consoleDir, 'fw-upgrade-state.txt'))}`,
|
|
504
|
-
`function Log($m) { try { Add-Content -Path $log -Value ((Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' ' + $m) -Encoding UTF8 } catch {} }`,
|
|
505
|
-
prelude,
|
|
506
|
-
`Log ('重启脚本启动:目标端口 ${port},bin=' + ${ps(binPath)})`,
|
|
507
|
-
killLine,
|
|
508
|
-
// 等到端口真正空出来(原实现只 sleep 3 秒、只查一次 —— 端口还占着就误判"已有人监听"而跳过拉起)
|
|
509
|
-
`$free = $false`,
|
|
510
|
-
`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 } }`,
|
|
511
|
-
`Log ('端口 ' + ${ps(String(port))} + ' 状态:' + $(if ($free) { '已释放' } else { '仍被占用(可能被其它实例占着)' }))`,
|
|
512
|
-
`$up = $false`,
|
|
513
|
-
`for ($a = 1; $a -le 3; $a++) {`,
|
|
514
|
-
` [void](Invoke-DshRelaunch ('重启第 ' + $a + ' 次'))`,
|
|
515
|
-
` 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 }`,
|
|
516
|
-
`}`,
|
|
517
|
-
`if ($up) { Log '服务已重新监听,重启完成' } else { Log '三次拉起后端口仍未监听:守护任务会继续尝试(输出见 fw-relaunch.log)' }`,
|
|
518
|
-
`schtasks /delete /f /tn ${taskName} 2>$null`,
|
|
519
|
-
]
|
|
520
|
-
// ② 守护任务:独立于主脚本与服务进程,端口不起来就一直拉(最多 5 次)
|
|
521
|
-
const guardLines = [
|
|
522
|
-
`$log = ${ps(restartLog)}`,
|
|
523
|
-
`$state = ${ps(join(consoleDir, 'fw-upgrade-state.txt'))}`,
|
|
524
|
-
`function Log($m) { try { Add-Content -Path $log -Value ((Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' [guard] ' + $m) -Encoding UTF8 } catch {} }`,
|
|
525
|
-
prelude,
|
|
526
|
-
`$c = $null`,
|
|
527
|
-
`try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue } catch {}`,
|
|
528
|
-
`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 }`,
|
|
529
|
-
`$n = 0`,
|
|
530
|
-
`try { $n = [int](Get-Content ${ps(guardCount)} -Raw -ErrorAction SilentlyContinue) } catch { $n = 0 }`,
|
|
531
|
-
`$n = $n + 1`,
|
|
532
|
-
`if ($n -gt 5) { Log ('已尝试 ' + ($n - 1) + ' 次仍拉不起来,放弃并自删(请手动启动,或看 fw-relaunch.log / console-restart.log)'); schtasks /delete /f /tn ${guardName} 2>$null; exit 0 }`,
|
|
533
|
-
`try { Set-Content -Path ${ps(guardCount)} -Value ([string]$n) -Encoding UTF8 } catch {}`,
|
|
534
|
-
`Log ('端口 ' + ${ps(String(port))} + ' 无监听,第 ' + $n + ' 次拉起')`,
|
|
535
|
-
`[void](Invoke-DshRelaunch ('守护第 ' + $n + ' 次'))`,
|
|
536
|
-
]
|
|
537
|
-
const ps1 = join(tmpdir(), `console-restart-${process.pid}.ps1`)
|
|
538
|
-
const guardPs1 = join(tmpdir(), `console-restart-guard-${process.pid}.ps1`)
|
|
539
|
-
const scheduleFor = (file, name, scheduleArgs) => {
|
|
540
|
-
const posix = String(file).replace(/\\/gu, '/')
|
|
541
|
-
// 无引号 /tr(重要):Task Scheduler 对带引号命令的解析会把 Command 拆坏成
|
|
542
|
-
// `"powershell ... -File \"`(非有效可执行文件)——任务显示 Ready、/run 报 SUCCESS
|
|
543
|
-
// 但永不执行(曾导致升级/重启脚本反复"已启动"却不动作、服务不停止)。实测无引号
|
|
544
|
-
// 格式(exe 与脚本路径均无空格时)任务正常执行、脚本完整跑通。仅当脚本路径含空格
|
|
545
|
-
// 时才退回带引号格式(schtasks 引号解析在服务 execFile 上下文不可靠,此时宁可用它)。
|
|
546
|
-
const tr = / /.test(posix)
|
|
547
|
-
? `"powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File \\"${posix}\\""`
|
|
548
|
-
: `C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File ${posix}`
|
|
549
|
-
return new Promise((resolve) => {
|
|
550
|
-
execFile('schtasks.exe', ['/create', '/f', '/tn', name, '/tr', tr, ...scheduleArgs], { windowsHide: true }, (error) => resolve(error ?? null))
|
|
551
|
-
})
|
|
552
|
-
}
|
|
553
|
-
Promise.all([
|
|
554
|
-
writeFile(ps1, `\uFEFF${mainLines.join('\r\n')}`, 'utf8'),
|
|
555
|
-
writeFile(guardPs1, `\uFEFF${guardLines.join('\r\n')}`, 'utf8'),
|
|
556
|
-
]).then(
|
|
557
|
-
async () => {
|
|
558
|
-
// 守护任务先注册(每分钟一次,独立于服务进程树)——主脚本被杀也有它兜底
|
|
559
|
-
const guardError = await scheduleFor(guardPs1, guardName, ['/sc', 'minute', '/mo', '1'])
|
|
560
|
-
const mainError = await scheduleFor(ps1, taskName, ['/sc', 'once', '/st', '00:00'])
|
|
561
|
-
if (mainError !== null) {
|
|
562
|
-
// schtasks 不可用:退回 detached 直接执行主脚本(守护任务仍可能已建)
|
|
563
|
-
execFile('powershell.exe', ['-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', ps1], { windowsHide: true, detached: true, stdio: 'ignore' }, () => {})
|
|
564
|
-
return
|
|
565
|
-
}
|
|
566
|
-
execFile('schtasks.exe', ['/run', '/tn', taskName], { windowsHide: true }, () => {})
|
|
567
|
-
if (guardError !== null) {
|
|
568
|
-
try { writeFileSync(restartLog, `[warn] 守护任务注册失败(${guardError.message}),仅靠主脚本重启\n`, { flag: 'a' }) } catch {}
|
|
569
|
-
}
|
|
570
|
-
},
|
|
571
|
-
() => {},
|
|
572
|
-
)
|
|
573
|
-
} else {
|
|
574
|
-
const script = process.platform === 'win32'
|
|
575
|
-
? `Start-Sleep -Seconds 2; Stop-Process -Id ${process.pid} -Force`
|
|
576
|
-
: `sleep 2; kill -9 ${process.pid}`
|
|
577
|
-
const cmd = process.platform === 'win32' ? 'powershell.exe' : 'sh'
|
|
578
|
-
const args = process.platform === 'win32'
|
|
579
|
-
? ['-NoProfile', '-WindowStyle', 'Hidden', '-Command', script]
|
|
580
|
-
: ['-c', script]
|
|
581
|
-
execFile(cmd, args, { windowsHide: true }, () => {})
|
|
582
|
-
}
|
|
583
|
-
sendJson(res, 200, { ok: true, message: `正在重启 DSH 服务(自带守护,端口 ${port} 无监听会自动拉起),页面稍后自动恢复` })
|
|
445
|
+
// 2026-10-10 改错(用户红线 + 现场取证)——旧实现在这里做了三件必须消失的事:
|
|
446
|
+
// ① `Stop-Process -Id <本进程>` 自杀脚本(桌面端托管时 = 杀掉 Electron 外壳的宿主子进程
|
|
447
|
+
// ⇒ 外壳 reportFatal(...,'host') ⇒ 弹「应用无法启动或已意外停止。」,Electron lib/main.js:6709);
|
|
448
|
+
// ② 注册**每分钟跑一次**的 `DSH-RestartGuard-<pid>` 计划任务 —— 端口无监听就
|
|
449
|
+
// `Start-Process node <bin.js> web` 手动拉起(现场原文见 console-restart.log
|
|
450
|
+
// `[guard] 端口 3080 无监听,第 1..5 次拉起` / fw-relaunch.log `拉起(守护第 N 次): …`);
|
|
451
|
+
// ③ 第 6 次 `schtasks /delete /f /tn DSH-RestartGuard-<pid>` **自删计划任务**(危险动作)。
|
|
452
|
+
// 现在:官方路径优先(桌面端托管 ⇒ 让用户在客户端里重启);官方通道不可用(独立 dsh web)
|
|
453
|
+
// ⇒ 如实告知手动重启。两种情况都**零 spawn / 零 kill / 零计划任务 / 零自删**。
|
|
454
|
+
//
|
|
455
|
+
// 注:清僵尸计划任务的既有能力**保留**(cleanupStaleFwTasks 见 lib/index.js 接线),
|
|
456
|
+
// 只是控制台自己再也不新建这类任务了。
|
|
457
|
+
const decision = restartPathDecision({ hosted: hostedHere(rc), intent: 'restart' })
|
|
458
|
+
sendError(res, decision.status, decision.message, decision.details)
|
|
584
459
|
return
|
|
585
460
|
}
|
|
586
461
|
|
|
587
462
|
/** 清掉「最近一次升级/回滚记录」(2026-09-25 用户要求):记录常驻是设计,但用户要能一键关掉。
|
|
588
|
-
* 只删状态文件与心跳(不动日志,日志仍可在 ~/.dsh/plugin-console 里查)。
|
|
463
|
+
* 只删状态文件与心跳(不动日志,日志仍可在 ~/.dsh/plugin-console 里查)。
|
|
464
|
+
* 2026-10-11 改错:删除失败**如实上报**(500 + 每个文件的原因 + removed/failed 明细),
|
|
465
|
+
* 不再像旧实现那样吞掉异常还回 ok:true —— 静默失败一律不许(见函数内注释与
|
|
466
|
+
* tests/test-framework-status-clear.mjs 的正控/负控)。 */
|
|
467
|
+
/** 删文件失败时给用户看的一句话原因(code + 系统原文,便于判因)。 */
|
|
468
|
+
function explainRemoveError(error) {
|
|
469
|
+
const code = typeof error?.code === 'string' && error.code !== '' ? error.code : 'UNKNOWN'
|
|
470
|
+
const text = (error instanceof Error ? error.message : String(error ?? '')).split('\n')[0].trim()
|
|
471
|
+
return text === '' ? code : `${code}: ${text}`
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** 删除时文件已不在 = 目标达成(不计失败);其余错误一律算失败。 */
|
|
475
|
+
function removeTargetGone(error) {
|
|
476
|
+
return error?.code === 'ENOENT'
|
|
477
|
+
}
|
|
478
|
+
|
|
589
479
|
async function routeFrameworkStatusClear(req, res, rc) {
|
|
590
480
|
const dir = join(dshHome(), 'plugin-console')
|
|
591
481
|
const state = join(dir, 'fw-upgrade-state.txt')
|
|
482
|
+
const io = rc.deps?.fs ?? { existsSync, rmSync } // 注入缝:负控要能造出真实的删除失败
|
|
592
483
|
const removed = []
|
|
484
|
+
const failed = []
|
|
593
485
|
for (const f of [state, `${state}.hb`]) {
|
|
594
|
-
|
|
486
|
+
let present = false
|
|
487
|
+
try { present = io.existsSync(f) === true } catch (error) { failed.push({ file: basename(f), reason: explainRemoveError(error) }); continue }
|
|
488
|
+
if (!present) continue
|
|
489
|
+
try {
|
|
490
|
+
io.rmSync(f, { force: true })
|
|
491
|
+
removed.push(basename(f))
|
|
492
|
+
} catch (error) {
|
|
493
|
+
// 2026-10-11 改错:这里原来是 `catch {}` + 无条件 ok:true —— 静默失败。
|
|
494
|
+
// 先前的真实现状更糟:rmSync/basename 没 import ⇒ 每轮都抛 ReferenceError、被空 catch 吞掉,
|
|
495
|
+
// 路由照样回 200 {"ok":true,"removed":[]},标记文件一个都没删(用户以为清掉了)。
|
|
496
|
+
// 判据(用户红线):宁可红,也不报假的绿 —— 删除失败必须如实上报原因。
|
|
497
|
+
if (removeTargetGone(error)) { removed.push(basename(f)); continue }
|
|
498
|
+
failed.push({ file: basename(f), reason: explainRemoveError(error) })
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (failed.length > 0) {
|
|
502
|
+
// 如实上报:500 + 每个文件的原因 + 与成功路径同形的 removed/status 字段(客户端不必猜)。
|
|
503
|
+
sendJson(res, 500, {
|
|
504
|
+
ok: false,
|
|
505
|
+
error: `清除升级记录失败:${failed.map((x) => `${x.file}(${x.reason})`).join('、')}`,
|
|
506
|
+
removed,
|
|
507
|
+
failed,
|
|
508
|
+
status: 'clear-failed',
|
|
509
|
+
message: null,
|
|
510
|
+
})
|
|
511
|
+
return
|
|
595
512
|
}
|
|
596
|
-
sendJson(res, 200, { ok: true, removed, status: 'idle', message: null })
|
|
513
|
+
sendJson(res, 200, { ok: true, removed, failed, status: 'idle', message: null })
|
|
597
514
|
}
|
|
598
515
|
export { refuseWhenShellHosted, refuseWhenPlatformUnsupported, routeFrameworkStatusClear, routeFrameworkUpgradeStatusGet, routeFrameworkRelaunch, routeFrameworkCheck, routeCompatGate, routeCheckUpdate, routeFrameworkRollback, routeRestart }
|
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.45",
|
|
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",
|