dsh-long-plugins 1.3.1

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.
@@ -0,0 +1,349 @@
1
+ ---
2
+ name: dsh-web-start-panel-install
3
+ description: 在一台已装好 DSH 的机器上生成并启动 web 启动面板(纯 Node、无依赖),网页点「启动/重启 dsh web」即可拉起或重启 DSH。自动探测 DSH_HOME/Node 与目标平台(Windows/NAS)、写 server.mjs / start.sh(或 start-dsh.cmd)/ start-panel.sh(或 start-panel.cmd)、启动并验证;重启由用户手动点按钮。用户说「生成启动面板」「装启动面板」「重装后建面板」「web 启动面板」时调用。
4
+ whenToUse: 用户要求生成/安装 DSH web 启动面板,或重装后重建该面板时调用。
5
+ ---
6
+
7
+ # dsh-web-start-panel-install
8
+
9
+ # DSH 启动面板(web start panel)安装/生成
10
+
11
+ 作为部署助手,在一台**已装好 DSH** 的机器上生成并启动一个极简 **web 启动面板**:纯 Node、无依赖,一个网页上点「启动/重启 dsh web」即可拉起重启 DSH。**支持 Windows 与 NAS/Linux**(本 skill 以 NAS/Linux 为默认完整示例,Windows 差异在「关键差异」表与「Windows 关键」处标注)。
12
+
13
+ > 本 skill 是**操作指引**,由你(agent)在目标机器上执行。真实 shell/文件权限来自该机器;凡**远程 push / 打 tag / 发 Release** 一律停下,等用户明确确认(硬性安全边界)。
14
+
15
+ ## 前置检查(先探测,再动手)
16
+
17
+ 执行前先探测环境,**不要假设本机路径**。逐项确认,缺了就停下让用户填:
18
+
19
+ ```
20
+ dsh --version # 是否装了 DSH;没有则提示先按官方文档装 DSH
21
+ echo "$DSH_HOME" # 未设则查 /volume1/dsh、$HOME/.dsh 常见位置
22
+ ls -d <候选>/profiles/web # 定位 profile 目录(一般 web)
23
+ node --version # 需 >=22(面板用原生 fetch / --expose-internals 依赖)
24
+ where node ; ls <node套件>/bin/node # 找 node 真实路径;找不到让用户给
25
+ ```
26
+
27
+ - **node 路径**:Windows 常为 `C:\Program Files\nodejs\node.exe`;NAS 常为 `/volume1/@appstore/Node.js_vXX/usr/local/bin/node`。以探测值为准,不要写死。
28
+ - **DSH_HOME**:默认 `$HOME/.dsh`(Windows/`D:\.dsh`)或 `/volume1/dsh`(NAS),以用户实际值为准。
29
+ - **profile**:默认 `web`;用 `dsh --profile <p> --dump-config` 核对。
30
+ - **dsh web 端口**:默认 `3080`(面板做健康检查与 PID 检测用)。
31
+
32
+ ## 文件构成(部署目录 `$DSH_HOME/web-start/`)
33
+
34
+ | 文件 | 作用 | 平台差异 |
35
+ |---|---|---|
36
+ | `server.mjs` | 面板服务(纯 Node,无依赖),默认监听 `0.0.0.0:3456`(局域网可访问;设 `DSH_START_HOST=127.0.0.1` 仅本机) | **核心差异点**:`/api/start` 的 spawn、`findDshPid`、`START_SCRIPT` 路径 |
37
+ | `start-dsh.cmd` | 「启动/重启 dsh web」按钮调用的脚本:停旧 dsh → 带正确环境启动 | Windows 用 `.cmd`;NAS 用 `start.sh` |
38
+ | `start-panel.cmd` | 面板自身 start/stop/status | Windows 用 `.cmd`;NAS 用 `start-panel.sh` |
39
+
40
+ ## 目录约定(先确认,再动手)
41
+
42
+ - 面板部署目录 = **`$DSH_HOME/web-start/`**(缺失则创建,不要落到用户工作目录/当前目录)。
43
+ - 面板只做「启动 + 状态」,不提供停止;点「启动」只会执行启动脚本(NAS 为 `start.sh`、Windows 为 `start-dsh.cmd`,脚本内部会**先停旧再启新**),不会执行任意命令。
44
+ - 用户工作目录与上传目录与本面板无关;启动脚本(`start.sh`/`start-dsh.cmd`)里会带上 `DSH_UPLOAD_DIR`,以符合 dsh-long-plugins 的目录约定(若已配置)。
45
+
46
+ ### 「打开 dsh 会话界面」链接(可选;本机面板未内置)
47
+ 本机运行的面板 = 「启动按钮 + 状态灯」,**没有**单独的「打开 dsh 会话界面」链接——页脚只显示局域网/本机地址,供人记着地址去开。若想面板醒目地直达 dsh 会话界面(多设备/反代访问时方便),按下述**可选**增强实现:
48
+
49
+ - 在 `server.mjs` 加一个 `DSH_OPEN_URL`(独立于健康检查地址):`const DSH_OPEN_URL = process.env.DSH_OPEN_URL ?? "http://你的域名或局域网IP:3080";`
50
+ - 在 PAGE 的 `.card` 里加:`<div class="open" style="margin-top:16px"><a id="open" href="${DSH_OPEN_URL}" target="_blank" rel="noopener" style="color:#93c5fd;text-decoration:none;font-size:14px;opacity:.35;pointer-events:none">打开 dsh 会话界面</a></div>`
51
+ - 在 JS 里取 `open` 元素并加一个 `setOpen(on)` 辅助函数,在 `poll()` 中随 `running` 切换 `open.style.opacity`/`pointerEvents`(运行中才可点)。
52
+ - 生成前可用 `ask_user_question` 让用户填**实际能访问到的地址**(反代域名如 `https://你的域名` / 局域网 IP 如 `http://192.168.x.x:3080` / 仅本机 `http://127.0.0.1:3080`),或直接把该值写进 `DSH_OPEN_URL`。
53
+
54
+ **注意**:健康检查地址 `DSH_WEB_URL` 保持 `http://127.0.0.1:3080/` 不变(后端本机探测必须用它),与 `DSH_OPEN_URL` **解耦**——这样从反代域名访问面板时,「打开 dsh 会话界面」也能跳到正确地址,而不是跳到访问者自己的 127.0.0.1。
55
+
56
+ ## 关键:Windows 与 NAS/Linux 的差异
57
+
58
+ 下面 `server.mjs` 是 NAS/Linux 写法,**Windows 必须按下表改这三处**,否则面板按钮无效/状态假:
59
+
60
+ | 项 | NAS/Linux | Windows |
61
+ |---|---|---|
62
+ | `/api/start` 启动 | `spawn("sh", [START_SCRIPT], {detached:true, stdio:"ignore"})` | 让 `server.mjs` 直接 `spawn(NODE, [...], {detached:true, stdio:"ignore", windowsHide:true})`,先 `killDsh()` → `waitPortFree(3080)` → 再启动;**勿**用 `spawn(.cmd,{shell:true})`(见「Windows 关键」) |
63
+ | `findDshPid` | `spawn("sh", ["-c", "ps aux \\| grep -F 'bin.js web' \\| grep -v grep …"])` | `spawn("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", ["-NoProfile","-Command","Get-CimInstance …"])` 查 node 进程 |
64
+ | `START_SCRIPT` 默认 | `/volume1/dsh/start.sh` | `D:\.dsh\web-start\start-dsh.cmd` |
65
+ | 面板管理脚本 | `start-panel.sh`(chmod +x,`start|stop|status`,用 `ps`/`kill`) | `start-panel.cmd`(`start|stop|status`,用 `taskkill`/PowerShell) |
66
+
67
+ > 可用环境变量覆盖(面板已支持):`DSH_START_PORT`(默认 3456)、`DSH_START_HOST`(NAS 示例默认 `0.0.0.0`;只留本机设 `127.0.0.1`)、`DSH_START_SCRIPT`。
68
+
69
+ > ⚠️ **Windows 关键(实测过的坑)**:`/api/start` 用 `spawn(START_SCRIPT, {shell:true})` 去跑 `.cmd`,或手动 `start /b 跑 .cmd` 拉起 dsh,**会踩两个坑**——① cmd 控制台一关就把子进程带走;② 不等 3080 端口释放就绑端口 → `EADDRINUSE`,新 dsh 起来即崩、面板一直「正在启动」。**Windows 建议让 `server.mjs` 自己直接 `spawn(NODE, ["--expose-internals","--max-old-space-size=8192", DSH_BIN, "web", "--port","3080"], {detached:true, stdio:"ignore", windowsHide:true, env:{...process.env, DSH_HOME, DSH_WORKSPACE, DSH_UPLOAD_DIR, NODE_OPTIONS:"", PATH: DSH_PYBIN+";"+...}})`**,且 `/api/start` 内部**先 `killDsh()` → 轮询 `waitPortFree(3080)` → 再启动**,`start-dsh.cmd` 仅作手动备用。`DSH_UPLOAD_DIR`/`DSH_WORKSPACE` 等由启动脚本显式传入 env,保证输出文件目录正确。NAS 版用 `start.sh`(内建停旧+健康检查)已足够可靠,可沿用 `spawn("sh",[START_SCRIPT],…)`。
70
+
71
+ ## 安装步骤
72
+
73
+ ### 1. 确保部署目录存在
74
+ ```sh
75
+ # Windows / NAS 通用:$DSH_HOME/web-start/ 缺失则创建
76
+ mkdir -p "$DSH_HOME/web-start"
77
+ ```
78
+
79
+ ### 2. 生成 `server.mjs`(NAS/Linux 版,完整内容)
80
+ 在 `$DSH_HOME/web-start/server.mjs` 写入以下内容。**改默认 `START_SCRIPT`/端口/健康检查地址以匹配本机**(下文中已标 `← 改我` 的地方):
81
+
82
+ ```js
83
+ #!/usr/bin/env node
84
+ /**
85
+ * dsh 启动面板 — 一个极简网页,点击按钮即可启动 dsh web(类似 WOL 面板)。
86
+ *
87
+ * 端点:
88
+ * GET / 启动面板页面
89
+ * GET /api/status {"running": true|false, "pid": <node pid>|null, "checkedAt": ...}
90
+ * POST /api/start 触发 <START_SCRIPT>(立即返回;页面轮询状态直到 running)
91
+ *
92
+ * 监听:默认 0.0.0.0:3456(局域网可访问;页脚地址由 lanIp() 自动探测,可用 DSH_START_LAN 覆盖)。
93
+ * 可用环境变量覆盖:DSH_START_HOST / DSH_START_PORT / DSH_START_SCRIPT / DSH_START_LAN。
94
+ * 安全提示:局域网内任何人都能点"启动"按钮;如需限制请挂反代+认证,
95
+ * 或把 DSH_START_HOST 设回 127.0.0.1。
96
+ */
97
+ import { createServer } from "node:http";
98
+ import { spawn } from "node:child_process";
99
+ import { networkInterfaces } from "node:os";
100
+
101
+ const PORT = Number(process.env.DSH_START_PORT ?? 3456);
102
+ const HOST = process.env.DSH_START_HOST ?? "0.0.0.0";
103
+ // ← 改我:本机已有的 dsh 启动脚本(面板只执行它,不执行任意命令),NAS 复用现有 start.sh
104
+ const START_SCRIPT = process.env.DSH_START_SCRIPT ?? "/volume1/dsh/start.sh";
105
+ const DSH_WEB_URL = "http://127.0.0.1:3080/";
106
+ // ← 改我:检测 dsh 是否运行的进程特征串
107
+ const WEB_PID_PATTERN = "bin.js web";
108
+
109
+ function dshRunning() {
110
+ return new Promise((resolve) => {
111
+ const req = fetch(DSH_WEB_URL, { signal: AbortSignal.timeout(2000) });
112
+ req.then(() => resolve(true), () => resolve(false));
113
+ });
114
+ }
115
+
116
+ function findDshPid() {
117
+ return new Promise((resolve) => {
118
+ spawn("sh", ["-c", `ps aux | grep -F '${WEB_PID_PATTERN}' | grep -v grep | awk '{print $2}' | head -1`])
119
+ .stdout.on("data", (d) => resolve(String(d).trim() || null))
120
+ .on("error", () => resolve(null));
121
+ });
122
+ }
123
+
124
+ // 自动探测局域网 IP(跳过 docker/容器/虚拟网卡,取第一个剩下的非回环 IPv4;任何 NAS 都适用,不写死)。
125
+ // 仍可能取到不想要的网卡,可用 DSH_START_LAN 覆盖成真正对外的局域网 IP。
126
+ function lanIp() {
127
+ const skip = /^(docker|br-|veth|virbr|tailscale|zt|tun)/;
128
+ let lan = null;
129
+ for (const [name, list] of Object.entries(networkInterfaces())) {
130
+ if (skip.test(name)) continue;
131
+ for (const it of list ?? []) {
132
+ if (it.family === "IPv4" && !it.internal) { lan = it.address; break; }
133
+ }
134
+ if (lan) break;
135
+ }
136
+ return lan;
137
+ }
138
+ // 面板页脚展示的局域网地址(可覆盖,避免误选 docker/tailscale 网卡)
139
+ const LAN = process.env.DSH_START_LAN ?? lanIp();
140
+
141
+ const PAGE = `<!DOCTYPE html>
142
+ <html lang="zh-CN">
143
+ <head>
144
+ <meta charset="utf-8">
145
+ <meta name="viewport" content="width=device-width, initial-scale=1">
146
+ <title>DSH 启动面板</title>
147
+ <style>
148
+ body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; background:#0f1720; color:#e5e7eb; display:flex; align-items:center; justify-content:center; min-height:100vh; margin:0; }
149
+ .card { background:#1a2530; border:1px solid #2c3a47; border-radius:14px; padding:36px 44px; text-align:center; box-shadow:0 8px 30px rgba(0,0,0,.4); }
150
+ h1 { font-size:20px; margin:0 0 8px; font-weight:600; }
151
+ .status { font-size:14px; color:#9ca3af; margin-bottom:26px; }
152
+ .dot { display:inline-block; width:10px; height:10px; border-radius:50%; margin-right:6px; vertical-align:middle; }
153
+ .dot.on { background:#22c55e; box-shadow:0 0 8px #22c55e; }
154
+ .dot.off { background:#ef4444; box-shadow:0 0 8px #ef4444; }
155
+ .dot.busy { background:#f59e0b; box-shadow:0 0 8px #f59e0b; animation:blink 1s infinite; }
156
+ @keyframes blink { 50% { opacity:.3; } }
157
+ button { background:#2563eb; color:#fff; border:none; border-radius:10px; font-size:16px; padding:14px 44px; cursor:pointer; transition:background .15s; }
158
+ button:hover { background:#1d4ed8; }
159
+ button:disabled { background:#374151; cursor:not-allowed; }
160
+ .msg { margin-top:18px; font-size:13px; color:#f59e0b; min-height:18px; }
161
+ .ok { color:#22c55e !important; }
162
+ .err { color:#ef4444 !important; }
163
+ .hint { margin-top:22px; font-size:12px; color:#6b7280; }
164
+ </style>
165
+ </head>
166
+ <body>
167
+ <div class="card">
168
+ <h1>DSH 启动面板</h1>
169
+ <div class="status"><span class="dot" id="dot"></span><span id="text">检测中…</span></div>
170
+ <button id="btn" disabled>启动 dsh web</button>
171
+ <div class="msg" id="msg"></div>
172
+ <div class="hint">局域网:${LAN ? `http://${LAN}:${PORT} · ` : ""}本机:http://127.0.0.1:${PORT}</div>
173
+ </div>
174
+ <script>
175
+ const dot=document.getElementById('dot'),text=document.getElementById('text'),
176
+ btn=document.getElementById('btn'),msg=document.getElementById('msg');
177
+ let last='unknown';
178
+ async function poll(){
179
+ try{
180
+ const r=await fetch('/api/status'); const s=await r.json();
181
+ if(s.running){ dot.className='dot on'; text.textContent='dsh web 运行中'+(s.pid?'(PID '+s.pid+')':''); btn.disabled=false; btn.textContent='重启 dsh web'; }
182
+ else { dot.className='dot off'; text.textContent='dsh web 未运行'; btn.disabled=false; btn.textContent='启动 dsh web'; }
183
+ if(last==='starting'&&s.running){ msg.className='msg ok'; msg.textContent='✅ dsh web 已就绪'; last='running'; }
184
+ if(last==='starting'&&!s.running){ msg.className='msg'; msg.textContent='⏳ 正在重启/启动中…'; }
185
+ }catch(e){ dot.className='dot off'; text.textContent='无法连接面板服务'; }
186
+ }
187
+ btn.onclick=async()=>{
188
+ const isRun=btn.textContent.indexOf('重启')===0;
189
+ if(isRun&&!confirm('确认重启 dsh web?正在进行的对话会被中断,重启约 20 秒。'))return;
190
+ btn.disabled=true; msg.className='msg'; msg.textContent=isRun?'⏳ 正在重启…':'⏳ 正在启动…'; last='starting';
191
+ try{ await fetch('/api/start',{method:'POST'}); }
192
+ catch(e){ msg.className='msg err'; msg.textContent='❌ 触发失败:'+e; btn.disabled=false; last='unknown'; }
193
+ };
194
+ poll(); setInterval(poll,2000);
195
+ </script>
196
+ </body>
197
+ </html>`;
198
+
199
+ const server = createServer((req, res) => {
200
+ const url = new URL(req.url ?? "/", `http://${HOST}:${PORT}`);
201
+ if (req.method === "GET" && url.pathname === "/") {
202
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
203
+ res.end(PAGE);
204
+ return;
205
+ }
206
+ if (req.method === "GET" && url.pathname === "/api/status") {
207
+ dshRunning().then(async (running) => {
208
+ const pid = running ? await findDshPid() : null;
209
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
210
+ res.end(JSON.stringify({ running, pid, checkedAt: Date.now() }));
211
+ });
212
+ return;
213
+ }
214
+ if (req.method === "POST" && url.pathname === "/api/start") {
215
+ console.log(new Date().toISOString(), "start triggered");
216
+ const child = spawn("sh", [START_SCRIPT], { detached: true, stdio: "ignore" });
217
+ child.unref();
218
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
219
+ res.end(JSON.stringify({ ok: true, script: START_SCRIPT }));
220
+ return;
221
+ }
222
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
223
+ res.end("not found");
224
+ });
225
+
226
+ server.listen(PORT, HOST, () => {
227
+ console.log(`dsh start panel: http://${HOST}:${PORT}`);
228
+ });
229
+ ```
230
+
231
+ > 本机(NAS/Linux)基准版要改的地方就是代码里 `改我` 标记处,其余照抄即可:
232
+ > - `START_SCRIPT`:指向本机已有的 `start.sh`(如 `/volume1/dsh/start.sh`);目标机没有才按第 3 步 NAS 版生成。
233
+ > - `WEB_PID_PATTERN`:dsh 进程命令行特征串(默认 `bin.js web`,与 `start.sh` 里的 `grep` 一致)。
234
+ > - 页脚局域网地址已由 `lanIp()` 自动探测、不再写死(任何 NAS 都适用);仅在多网卡/反代/取错网卡时用环境变量 `DSH_START_LAN=` 覆盖成真正对外的地址。
235
+ > - node / dsh 入口路径不在本文件里,在第 3、4 步的 `start.sh` / `start-panel.sh` 中。
236
+
237
+ > **Windows 版差异**:`findDshPid` 用 PowerShell(`Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object { $_.CommandLine -match 'bin\\.js' -and $_.CommandLine -match '\\bweb\\b' }` 查进程);`/api/start` **不要**用 `spawn` 去跑 `start-dsh.cmd`(即 `spawn(file,{shell:true})` 那种写法,见「Windows 关键」),而是让 `server.mjs` 直接 `spawn(NODE, [...], {detached:true, stdio:"ignore", windowsHide:true})`,内部先 `killDsh()` → 轮询 `waitPortFree(3080)` → 再启动;`START_SCRIPT` 默认改为 `$DSH_HOME\web-start\start-dsh.cmd`,`start-dsh.cmd` 仅作手动备用。
238
+
239
+ ### 3. 生成 `start-dsh.cmd`(面板要执行的 dsh 启动脚本,含正确环境)
240
+ 在 `$DSH_HOME/web-start/start-dsh.cmd` 写入(**改 DSH_HOME / DSH_UPLOAD_DIR / Python PATH 以匹配本机**):
241
+
242
+ ```bat
243
+ @echo off
244
+ REM dsh web 启动脚本 (面板调用): 停旧 dsh -> 带正确环境启动
245
+ set DSH_HOME=D:\.dsh
246
+ set DSH_UPLOAD_DIR=D:\dsh\workspace\upload
247
+ set PATH=C:\Users\Jackypc\AppData\Local\Programs\Python\Python312;%PATH%
248
+ set NODE_OPTIONS=
249
+ REM 停旧 dsh web (仅杀匹配 bin.js + web 的 node)
250
+ powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Where-Object { $_.CommandLine -match 'bin\.js' -and $_.CommandLine -match '\bweb\b' } | ForEach-Object { Write-Host (' 停止 PID ' + $_.ProcessId); Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"
251
+ cd /d D:\dsh\workspace
252
+ start "" /b "C:\Program Files\nodejs\node.exe" --expose-internals --max-old-space-size=8192 "C:\Users\Jackypc\AppData\Roaming\npm\node_modules\@deepseek-ai\dsh\lib\bin.js" web
253
+ ```
254
+
255
+ > **NAS/Linux 版**:**优先复用目标机已有的 `start.sh`**(把 `server.mjs` 的 `START_SCRIPT` 指向它,如 `/volume1/dsh/start.sh`),不要重新生成极简版——可靠的 `start.sh` 应含:精确 kill 旧进程、`setsid + nohup` 双层脱离 + `</dev/null`、`--host 127.0.0.1`、`--trusted-host ...`、`DSH_UPLOAD_*` 上限、health check 等待 HTTP 200。`--expose-internals` 必须有,否则插件路由 404。仅当目标机没有 `start.sh` 时才生成,起步例如:`setsid nohup <node> --expose-internals --max-old-space-size=8192 <dsh 入口> web --port 3080 &`(并补上 kill 旧进程 + health check)。**换机必改**:`--host`/`--trusted-host`、`DSH_UPLOAD_DIR`、node 与 dsh 入口路径都要按目标机设,别照抄本机的 IP/域名/路径。
256
+
257
+ ### 4. 生成 `start-panel.cmd`(面板自身 start/stop/status)
258
+ 在 `$DSH_HOME/web-start/start-panel.cmd` 写入:
259
+
260
+ ```bat
261
+ @echo off
262
+ REM dsh 启动面板 (Windows) 管理: start-panel.cmd [start|stop|status]
263
+ set NODE=C:\Program Files\nodejs\node.exe
264
+ set SERVER=D:\.dsh\web-start\server.mjs
265
+ set LOGFILE=D:\.dsh\web-start\server.log
266
+ set PIDFILE=D:\.dsh\web-start\server.pid
267
+ set PORT=%DSH_START_PORT%
268
+ if "%PORT%"=="" set PORT=3456
269
+
270
+ set ACTION=%1
271
+ if "%ACTION%"=="" set ACTION=status
272
+ if /i "%ACTION%"=="start" goto :start
273
+ if /i "%ACTION%"=="stop" goto :stop
274
+ if /i "%ACTION%"=="status" goto :status
275
+ echo 用法: start-panel.cmd [start^|stop^|status]
276
+ exit /b 1
277
+
278
+ :start
279
+ echo == 启动 dsh 启动面板 (port %PORT%) ==
280
+ if exist "%PIDFILE%" ( set /p OLDPID=<"%PIDFILE%" & taskkill /PID %OLDPID% /F /T >nul 2>&1 )
281
+ start "" /b "%NODE%" "%SERVER%" > "%LOGFILE%" 2>&1
282
+ echo started. 面板: http://127.0.0.1:%PORT%
283
+ exit /b 0
284
+
285
+ :stop
286
+ echo == 停止 dsh 启动面板 ==
287
+ if exist "%PIDFILE%" ( set /p OLDPID=<"%PIDFILE%" & taskkill /PID %OLDPID% /F /T >nul 2>&1 & del "%PIDFILE%" >nul 2>&1 )
288
+ powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Where-Object { $_.CommandLine -match 'web-start\\server\.mjs' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"
289
+ echo stopped.
290
+ exit /b 0
291
+
292
+ :status
293
+ echo == dsh 启动面板 状态 ==
294
+ powershell -NoProfile -Command "$p = Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Where-Object { $_.CommandLine -match 'web-start\\server\.mjs' } | Select-Object -First 1; if($p){ '运行中 PID ' + $p.ProcessId } else { '未运行' }"
295
+ exit /b 0
296
+ ```
297
+
298
+ > **NAS/Linux 版**:换 `start-panel.sh`(`chmod +x`;用 `start|stop|status`,`SERVER`/`LOG`/`PIDFILE` 用 `$DSH_HOME/web-start/`,`NODE` 用探测到的 node 绝对路径)。**别在脚本里写死局域网 IP**:启动/状态输出的局域网地址用动态获取(如 `ip route get 1.1.1.1 | awk '{print $7}'` 或 `hostname -I | awk '{print $1}'`),取不到就只打 `http://127.0.0.1:$PORT`;也可读 `DSH_START_LAN`。
299
+
300
+ ### 5. 启动面板并验证
301
+ ```sh
302
+ # Windows:在独立终端
303
+ D:\.dsh\web-start\start-panel.cmd start
304
+ # 或直接后台起:
305
+ "C:\Program Files\nodejs\node.exe" D:\.dsh\web-start\server.mjs > D:\.dsh\web-start\server.log 2>&1
306
+
307
+ # NAS/Linux:
308
+ /volume1/dsh/web-start/start-panel.sh start
309
+ ```
310
+ 验证(逐项):
311
+ 1. `GET http://127.0.0.1:3456/` → HTTP 200,页面正常。
312
+ 2. `GET http://127.0.0.1:3456/api/status` → `{"running":true|false,"pid":…}`,能正确探测 dsh 运行状态。
313
+ 3. 页面上按钮随状态切换(未运行显示「启动 dsh web」,运行中显示「重启 dsh web」)。
314
+ 4. (由用户在浏览器点)「启动/重启 dsh web」→ 触发 `start.sh`(Windows 为 `start-dsh.cmd`)→ 停旧 + 起新,面板约 20 秒后变绿「✅ dsh web 已就绪」。
315
+
316
+ > **注意**:点「重启」会中断当前 agent 会话(agent 不自重启)。由用户在独立浏览器/终端操作,勿让 agent 触发 `/api/start`。
317
+
318
+ ## 验证清单(逐项)
319
+ 1. 三个文件都在 `$DSH_HOME/web-start/`。
320
+ 2. `node --version` ≥22。
321
+ 3. `GET /` 返回 200。
322
+ 4. `GET /api/status` 返回正确的 `running`/`pid`(用 `dsh --profile <p> --dump-config` 或看 3080 是否响应核对)。
323
+ 5. 面板与 dsh 服务分别常驻(面板 3456,dsh 3080)。
324
+ 6. 若目标 NAS/Linux,确认 `start-panel.sh` 已 `chmod +x`、`NODE` 路径正确。
325
+
326
+ ## 配置/可选
327
+ - **局域网/本机**:默认绑 `0.0.0.0`(局域网可访问,页面自动显示本机局域网 IP);只留本机则设 `DSH_START_HOST=127.0.0.1`。**注意「放权限」是两步**:① 绑 `0.0.0.0`(已默认)② 在 DSM「控制面板 → 安全性 → 防火墙」放行 **3456** 端口(建议只对局域网网段放行),局域网设备才连得上;只留本机则绑 `127.0.0.1` 即可,无需放行、也不暴露。安全提示:绑 `0.0.0.0` 后内网任何设备都能点「启动」。
328
+ - **端口改**:`DSH_START_PORT=xxxx`。
329
+ - **启动脚本改**:`DSH_START_SCRIPT=<path>`(NAS 默认 `/volume1/dsh/start.sh`;Windows 默认 `$DSH_HOME\web-start\start-dsh.cmd`)。
330
+ - **页脚局域网地址改**:默认由 `lanIp()` 自动探测(跳过 docker/虚拟网卡,任何 NAS 都适用);多网卡/反代场景取错时设 `DSH_START_LAN=<IP>` 覆盖成真正对外的地址。
331
+ - **开机自启**:把 `start-panel.cmd start`(Windows)/`start-panel.sh start`(NAS)加进 DSH 启动脚本或计划任务,先起面板再起 dsh(可选)。
332
+
333
+ ## 常见坑速查
334
+ | 症状 | 原因 | 解决 |
335
+ |---|---|---|
336
+ | 点按钮没反应/接口 500 | `START_SCRIPT` 路径不对,或 `/api/start` 用了 Unix 的 `spawn("sh",…)` | 改 `server.mjs` 的 `START_SCRIPT`;Windows 建议让 `server.mjs` 直接 spawn node(见上文「Windows 关键」),勿用 `spawn(file,{shell:true})` 跑 `.cmd` |
337
+ | 点「启动/重启」dsh 起来即崩、面板一直「正在启动」 | Windows 用 `start /b 跑 .cmd` 或 `spawn(.cmd,{shell:true})`:不等 3080 释放就绑端口 → `EADDRINUSE`,且 cmd 控制台关闭带走子进程 | 让 `server.mjs` 直接 spawn node + 内部先 `killDsh()` → 轮询 `waitPortFree(3080)` → 再启动(见上文「Windows 关键」);先 `netstat -ano | findstr :3080` 确认端口空闲 |
338
+ | 页面一直显示「未运行」 | `WEB_PID_PATTERN` 匹配不到 dsh 进程,或 `DSH_WEB_URL` 健康检查地址不对 | 用 PowerShell `Get-CimInstance`(Windows)或 `ps aux | grep dsh`(NAS)看实际命令行,改匹配串/端口 |
339
+ | 状态灯红/假 | 健康检查端口/地址不对 | 改 `server.mjs` 的 `DSH_WEB_URL`(默认 3080) |
340
+ | 面板启动失败 | `NODE`/node 路径不对 | `where node`(Windows)或 `which node`(NAS)确认真实路径改 `start-panel.cmd`/`start-panel.sh` |
341
+ | dsh 重启后插件路由 404 | 启动缺 `--expose-internals` | `start-dsh.cmd`/`start.sh` 启动命令加该 flag |
342
+ | `findDshPid` 在 Windows 报错 | 用了 `ps`/`sh` | 换成 PowerShell `Get-CimInstance` 版本 |
343
+ | 设置了 `DSH_UPLOAD_DIR` 但输出文件目录还是旧值 | dsh 进程是旧环境启动 | 用面板「重启 dsh web」(或带环境重启),新进程才生效 |
344
+
345
+ ## 硬性安全边界(必须遵守)
346
+ - **不主动 `git push` / 打 tag / 发 Release**;需要发版本时停下用 `ask_user_question` 等用户确认。
347
+ - 所有写操作(写文件 / 起停服务)先向用户说明将改哪个文件、做什么,再执行。
348
+ - 探测到 `DSH_HOME`/`node`/profile 目录缺失时**停下提示**,让用户补充,不硬猜。
349
+ - **不要触发 `/api/start`**(会重启 dsh、中断当前会话);由用户点按钮。
@@ -0,0 +1,243 @@
1
+ ---
2
+ name: dsh-web-win-service-install
3
+ description: 在一台已装好 DSH 的 Windows 机器上,把 dsh web 注册成 Windows 服务(NSSM),使关闭任何 cmd 窗口都不影响服务;自动安装 nssm、生成启动器/安装/卸载脚本、自提权注册、开机自启、崩溃自动重启并验证。用户说"把 dsh 做成 windows 服务"、"关 cmd 不能被服务"、"新电脑装 dsh 服务"时调用。
4
+ whenToUse: 用户要求在 Windows 上把 DeepSeek Harness(dsh web)注册为 Windows 服务(含 nssm 安装),或在一台新电脑上用服务方式运行 dsh 时调用。
5
+ ---
6
+
7
+ # DSH web 注册为 Windows 服务(含 NSSM 安装)
8
+
9
+ 作为部署助手,在一台**已装好 DSH 的 Windows 机器**上,把 `dsh web` 注册成 **NSSM Windows 服务** `DSHWeb`,让它:
10
+ - 后台常驻、**关闭任意 cmd 窗口都不受影响**(服务挂在 services.exe 下);
11
+ - **开机自启**(SERVICE_AUTO_START)、**崩溃自动重启**(AppExit=Restart);
12
+ - 带 `--expose-internals`(插件路由必需)+ 插件所需环境变量(DSH_UPLOAD_DIR / PYTHONHOME / PATH)。
13
+
14
+ > 本 skill 是**操作指引**,由你(agent)在目标机器上执行。NSSM 安装与服务注册都需要**真正提权**(用户点 UAC),你 **不可自称已提权**——把"提权"交给脚本里的自提权(RunAs)或用户手动跑。凡**远程 push / 打 tag / 发 Release** 一律停下等用户确认(硬性安全边界)。
15
+
16
+ ## 你会在目标机器上生成 4 个脚本(都在 `<DSH_HOME>\bin`)
17
+ | 脚本 | 作用 |
18
+ |---|---|
19
+ | `start-dsh-service.cmd` | 服务底层启动器:**绝对路径**起 node + `--expose-internals` + 插件 env(LocalSystem 安全) |
20
+ | `install-dsh-service.cmd` | **自提权**:装/配置/启动 `DSHWeb` 服务(幂等,可重复跑) |
21
+ | `restart-dsh.bat` | **自提权**:可靠的"停 → 等 STOPPED → 启"重启服务,带确认提示 + 自动开浏览器(**日常重启用这个**) |
22
+ | `remove-dsh-service.cmd` | 自提权卸载服务 |
23
+
24
+ **NSSM** 放到用户 PATH 目录(如 `%APPDATA%\npm\nssm.exe`,无需管理员)。
25
+
26
+ ## 前置探测(不要假设路径)
27
+ ```
28
+ dsh --version # 没装 DSH 则先按官方文档装(需 node>=22)
29
+ where node # 找 node 真实路径(如 C:\Program Files\nodejs\node.exe)
30
+ where dsh.cmd # 找 DSH CLI 入口;其所在目录即 npm 全局 bin
31
+ echo $env:APPDATA # npm 全局 bin 通常在 %APPDATA%\npm
32
+ where python # 真实 python(md2docx 用;Windows 常被 Store stub 劫持)
33
+ ```
34
+ 据此记下并**写入启动器**的绝对路径:
35
+ - `NODE_EXE` = `where node` 的第一个真路径
36
+ - `DSH_HOME` = `C:\Users\<用户>\.dsh`(或用户实际值)
37
+ - `DSH_BIN` = `<where dsh.cmd 所在目录>\node_modules\@deepseek-ai\dsh\lib\bin.js`(或 `%APPDATA%\npm\node_modules\@deepseek-ai\dsh\lib\bin.js`)
38
+ - `UPLOAD_DIR` = 用户工作区下的 upload(如 `C:\dsh\workspace\upload`;没有就默认 `%DSH_HOME%\uploads`)
39
+ - `PYTHONHOME` = `where python` 的父目录(如 `C:\Python314`)
40
+
41
+ > ⚠️ **占位符提醒**:步骤 2/3/4/5 的脚本模板里,`C:\Users\YOURUSER\...`、`C:\dsh\workspace\upload`、`C:\Python314` 等都是**占位符**——写文件前**一律替换**成上面探测到的真实路径(DSH_HOME / NODE_EXE / DSH_BIN / UPLOAD_DIR / PYTHONHOME)。漏换会导致脚本指向不存在的路径。
42
+
43
+ ## 步骤 1:安装 NSSM
44
+ ```powershell
45
+ # 下载并解压 nssm-2.24.zip
46
+ Invoke-WebRequest https://nssm.cc/release/nssm-2.24.zip -OutFile "$env:TEMP\nssm.zip"
47
+ Expand-Archive "$env:TEMP\nssm.zip" -DestinationPath "$env:TEMP\nssmx" -Force
48
+ # 取 64 位 nssm.exe 放到用户 PATH 目录(npm 全局 bin,免管理员)
49
+ Copy-Item "$env:TEMP\nssmx\nssm-2.24\win64\nssm.exe" "$env:APPDATA\npm\nssm.exe" -Force
50
+ nssm version # 应输出 Version 2.24 64-bit
51
+ ```
52
+ > 备选:`choco install nssm -y`。验证 `where nssm` 能找到。
53
+
54
+ ## 步骤 2:生成启动器 `start-dsh-service.cmd`
55
+ 写文件(**纯 ASCII、CRLF**;把占位符替换成上面探测到的绝对路径):
56
+ ```cmd
57
+ @echo off
58
+ rem DSH-Web launcher (paths baked in; service runs as LocalSystem so it
59
+ rem must NOT rely on the logged-in user's %USERPROFILE%/PATH).
60
+ setlocal EnableDelayedExpansion
61
+ if not defined DSH_HOME set "DSH_HOME=C:\Users\YOURUSER\.dsh"
62
+ if not defined NODE_EXE set "NODE_EXE=C:\Program Files\nodejs\node.exe"
63
+ if not defined DSH_BIN set "DSH_BIN=C:\Users\YOURUSER\AppData\Roaming\npm\node_modules\@deepseek-ai\dsh\lib\bin.js"
64
+ if not defined UPLOAD_DIR set "UPLOAD_DIR=C:\dsh\workspace\upload"
65
+ if not defined PYTHONHOME set "PYTHONHOME=C:\Python314"
66
+ if not defined DSH_PORT set "DSH_PORT=3080"
67
+ if not exist "%NODE_EXE%" ( echo [ERR] node not found: %NODE_EXE% & goto :fail )
68
+ if not exist "%DSH_BIN%" ( echo [ERR] bin.js not found: %DSH_BIN% & goto :fail )
69
+ set "DSH_UPLOAD_DIR=%UPLOAD_DIR%"
70
+ set "PYTHONHOME=%PYTHONHOME%"
71
+ set "PATH=%DSH_HOME%\bin;%PATH%"
72
+ echo [OK] node=%NODE_EXE%
73
+ echo [OK] dsh=%DSH_BIN%
74
+ "%NODE_EXE%" --expose-internals --max-old-space-size=8192 "%DSH_BIN%" web --port %DSH_PORT%
75
+ goto :eof
76
+ :fail
77
+ exit /b 1
78
+ ```
79
+ > **要点**:路径**写死**(不要靠 `where`/`%APPDATA%` 运行时探测)——服务以 **LocalSystem** 跑,运行时探测会指向系统账户而失败。想移植其它机器,改开头几行即可。
80
+ > `cmd` 批处理**必须 CRLF**。写完后统一转一次:
81
+ > ```powershell
82
+ > $f="C:\Users\YOURUSER\.dsh\bin\start-dsh-service.cmd"; $c=[IO.File]::ReadAllText($f); $c=$c -replace "`r`n","`n" -replace "`n","`r`n"; [IO.File]::WriteAllText($f,$c,[Text.Encoding]::ASCII)
83
+ > ```
84
+
85
+ ## 步骤 3:生成安装脚本 `install-dsh-service.cmd`(自提权)
86
+ **纯 ASCII + CRLF**。**关键坑**:`if defined BUSY ( ... )` 块内的 `echo` 文本**不要含 `(`/`)`**(会被 cmd 当作嵌套子块而报"此时不应有 。")。自提权用 `whoami /groups` 判断 `S-1-16-12288`(High),不要用 `net session`(在该环境会误报 0):
87
+ ```cmd
88
+ @echo off
89
+ setlocal EnableDelayedExpansion
90
+ whoami /groups | find /i "S-1-16-12288" >nul 2>&1
91
+ if errorlevel 1 (
92
+ echo [*] Needs Administrator. Relaunching elevated...
93
+ powershell -NoProfile -Command "Start-Process -FilePath '%~f0' -Verb RunAs"
94
+ exit /b
95
+ )
96
+ set "SVC=DSHWeb"
97
+ set "LAUNCHER=C:\Users\YOURUSER\.dsh\bin\start-dsh-service.cmd"
98
+ set "UPLOAD=C:\dsh\workspace\upload"
99
+ set "PY=C:\Python314"
100
+ set "LOG=C:\Users\YOURUSER\.dsh\bin\dsh-service.log"
101
+ set "ERRLOG=C:\Users\YOURUSER\.dsh\bin\dsh-service.err.log"
102
+ set "PORT=3080"
103
+ where nssm >nul 2>&1 || ( echo [X] nssm not found & exit /b 1 )
104
+ if not exist "%LAUNCHER%" ( echo [X] launcher missing: %LAUNCHER% & exit /b 1 )
105
+ sc query "%SVC%" >nul 2>&1 && (
106
+ echo [*] Service exists, removing old one...
107
+ nssm stop "%SVC%" >nul 2>&1
108
+ nssm remove "%SVC%" confirm >nul 2>&1
109
+ )
110
+ set "BUSY="
111
+ for /f "tokens=5" %%p in ('netstat -ano ^| findstr ":%PORT%" ^| findstr "LISTENING"') do set "BUSY=%%p"
112
+ if defined BUSY (
113
+ echo [W] Port %PORT% is in use by PID !BUSY! likely the running DSH instance.
114
+ echo Stop it so the new service can bind? This will end that web session. [Y/N]
115
+ set /p ANS=
116
+ if /i "!ANS!"=="Y" taskkill /PID !BUSY! /F >nul 2>&1
117
+ )
118
+ nssm install "%SVC%" cmd.exe /c "\"%LAUNCHER%\"" >nul
119
+ if errorlevel 1 ( echo [X] nssm install failed & exit /b 1 )
120
+ nssm set "%SVC%" AppDirectory "C:\Users\YOURUSER\.dsh\bin" >nul
121
+ nssm set "%SVC%" DisplayName "DSH web service" >nul
122
+ nssm set "%SVC%" Description "DeepSeek Harness web node expose-internals" >nul
123
+ nssm set "%SVC%" Start SERVICE_AUTO_START >nul
124
+ nssm set "%SVC%" AppExit Default Restart >nul
125
+ nssm set "%SVC%" AppEnvironmentExtra DSH_UPLOAD_DIR=%UPLOAD% PYTHONHOME=%PY% >nul
126
+ nssm set "%SVC%" AppStdout "%LOG%" >nul
127
+ nssm set "%SVC%" AppStderr "%ERRLOG%" >nul
128
+ nssm start "%SVC%" >nul
129
+ if errorlevel 1 ( echo [X] nssm start failed, see %ERRLOG% & exit /b 1 )
130
+ echo [OK] Service installed and started.
131
+ echo status: nssm status %SVC%
132
+ echo browser: http://127.0.0.1:%PORT%
133
+ echo logs: %LOG% / %ERRLOG%
134
+ exit /b 0
135
+ ```
136
+
137
+ ## 步骤 4:生成卸载脚本 `remove-dsh-service.cmd`(自提权)
138
+ ```cmd
139
+ @echo off
140
+ setlocal
141
+ set "SVC=DSHWeb"
142
+ whoami /groups | find /i "S-1-16-12288" >nul 2>&1
143
+ if errorlevel 1 (
144
+ echo [*] Needs Administrator. Relaunching elevated...
145
+ powershell -NoProfile -Command "Start-Process -FilePath '%~f0' -Verb RunAs"
146
+ exit /b
147
+ )
148
+ where nssm >nul 2>&1 || ( echo [X] nssm not found & exit /b 1 )
149
+ sc query "%SVC%" >nul 2>&1 || ( echo [*] Service %SVC% not present & exit /b 0 )
150
+ nssm stop "%SVC%" >nul 2>&1
151
+ nssm remove "%SVC%" confirm
152
+ echo [OK] Removed.
153
+ exit /b 0
154
+ ```
155
+ > 生成的四个脚本(`.cmd`/`.bat`)都要做 **ASCII + CRLF** 归一(每次写完都跑一遍步骤 2 末尾的 PowerShell 片段,把文件名换掉)。
156
+
157
+ ## 步骤 5:生成重启脚本 `restart-dsh.bat`(自提权,可靠的日常重启)
158
+ **纯 ASCII + CRLF**。解决 `nssm restart` 因 stop 异步失败(第一次报 `Unexpected status SERVICE_STOP_PENDING`)的问题——用**显式停 → 轮询等到 `STOPPED` → 再启**:
159
+ ```cmd
160
+ @echo off
161
+ setlocal EnableDelayedExpansion
162
+ whoami /groups | find /i "S-1-16-12288" >nul 2>&1
163
+ if errorlevel 1 (
164
+ echo [*] Needs Administrator. Relaunching elevated...
165
+ powershell -NoProfile -Command "Start-Process -FilePath '%~f0' -Verb RunAs"
166
+ exit /b
167
+ )
168
+ set "SVC=DSHWeb"
169
+ set "PORT=3080"
170
+ echo.
171
+ echo This restarts DSH web and will interrupt the current session.
172
+ set /p ANS= Continue? [Y/N]
173
+ if /i not "!ANS!"=="Y" ( echo [*] Cancelled. Nothing was restarted. & exit /b 0 )
174
+ echo.
175
+ sc query "%SVC%" >nul 2>&1
176
+ if not %errorlevel% equ 0 (
177
+ echo [*] No service %SVC%. Starting a manual foreground instance...
178
+ for /f "tokens=5" %%p in ('netstat -ano ^| findstr ":%PORT%" ^| findstr "LISTENING"') do taskkill /PID %%p /F >nul 2>&1
179
+ call "C:\Users\YOURUSER\.dsh\bin\start-dsh-service.cmd"
180
+ exit /b 0
181
+ )
182
+ echo [*] Stopping service %SVC% ...
183
+ nssm stop "%SVC%" >nul 2>&1
184
+ set /a tries=0
185
+ :waitstop
186
+ sc query "%SVC%" | findstr /c:"STOPPED" >nul 2>&1
187
+ if not errorlevel 1 goto :stopped
188
+ set /a tries+=1
189
+ if !tries! geq 30 (
190
+ echo [W] Did not reach STOPPED within 30s; continuing to start anyway.
191
+ goto :stopped
192
+ )
193
+ timeout /t 1 /nobreak >nul
194
+ goto :waitstop
195
+ :stopped
196
+ echo [*] Starting service %SVC% ...
197
+ nssm start "%SVC%" >nul 2>&1
198
+ if errorlevel 1 ( echo [X] nssm start failed, check dsh-service.err.log & exit /b 1 )
199
+ echo [OK] Service restarted. Waiting for the server to come back up...
200
+ timeout /t 5 /nobreak >nul
201
+ start "" "http://127.0.0.1:%PORT%"
202
+ exit /b 0
203
+ ```
204
+ > 这是**用户日常重启**要用的脚本(装插件/升级后跑它即生效):自提权 + 确认提示 + 重启后自动开浏览器;无服务时回退为手动前台启动。
205
+
206
+ ## 步骤 6:执行安装
207
+ 让**用户**(或用户在资源管理器双击 `install-dsh-service.cmd`)运行;脚本会自动 **UAC 提权**,然后:
208
+ 1. 若 3080 被现有 DSH 手动实例占用,会问 `[Y/N]`——**用户输 `Y`**(停掉当前实例,会中断其当前会话)。
209
+ 2. `nssm install` + `nssm start` → 打印 `[OK] Service installed and started.`
210
+
211
+ ## 步骤 7:验证清单
212
+ 1. `nssm status DSHWeb` = `SERVICE_RUNNING`;`sc query DSHWeb` 显示 `AUTO_START`。
213
+ 2. `dsh-service.log` 显示 `[OK] node=... dsh=...`,无 `bin.js not found`;`dsh-service.err.log`为空。
214
+ 3. `netstat -ano | findstr :3080` → 服务进程(node + `--expose-internals`)。
215
+ 4. 刷新 `http://127.0.0.1:3080`;各插件入口/上传/预览正常。
216
+ 5. `nssm get DSHWeb Start` = `SERVICE_AUTO_START`,`nssm get DSHWeb AppExit Default` = `Restart`。
217
+
218
+ ## 交付:把这些文件位置明确告诉用户
219
+ 生成并验证后,把下面每个脚本的**完整路径**和用途逐项列给用户:
220
+ - `start-dsh-service.cmd` —— 服务底层启动器(一般不用手动动它)
221
+ - `install-dsh-service.cmd` —— 装/升级 `DSHWeb` 服务(双击→UAC)
222
+ - `restart-dsh.bat` —— **日常重启**(装插件/升级后跑这个;双击→UAC→输 `Y`→自动重启并开浏览器)
223
+ - `remove-dsh-service.cmd` —— 卸载服务(双击→UAC)
224
+ 并告诉用户:平时只需打开 `http://127.0.0.1:<port>`;需要重启就运行 `restart-dsh.bat`。
225
+
226
+ ## 关键坑速查
227
+ | 症状 | 原因 | 解决 |
228
+ |---|---|---|
229
+ | 批次报"此时不应有 。/ . was unexpected" | 文件是 LF(cmd 要 CRLF)或 `if (...)` 块内 `echo` 文本含 `(``)` | CRLF 归一;echo 文本去掉括号 |
230
+ | 服务装上但启动 `SERVICE_PAUSED`,日志 `bin.js not found` | 启动器运行时探测路径失败(LocalSystem 下 `where`/`%APPDATA%` 不对) | 启动器**写死绝对路径** |
231
+ | `nssm install` 报"需管理员" | 进程非 High integrity | 自提权(RunAs)或用"以管理员身份运行" |
232
+ | `net session` 返回 0 却仍无权限建服务 | 该环境下 `net session` 误报 | 用 `whoami /groups` 查 `S-1-16-12288` 判断是否真提权 |
233
+ | 服务启动后 3080 起不来 | 旧手动实例仍占 3080 | 先在脚本提示处输 `Y` 停掉,或 `taskkill /PID <pid> /F` |
234
+ | `nssm` 找不到 | 未装/不在 PATH | 步骤 1 安装并 `where nssm` 验证 |
235
+
236
+ ## 通用性(换机器/换目录)
237
+ 只需要改 **`start-dsh-service.cmd` 开头 5 行**(DSH_HOME / NODE_EXE / DSH_BIN / UPLOAD_DIR / PYTHONHOME)和 **`install-dsh-service.cmd` 顶部 5 个 SET**(LAUNCHER / UPLOAD / PY / LOG / ERRLOG),其余完全复用。若 DSH 装在别的用户/盘,把对应绝对路径换成该机器实际值即可。
238
+
239
+ ## 硬性安全边界
240
+ - **不主动 `git push` / 打 tag / 发 Release**。
241
+ - 所有写操作(生成脚本、改配置、启动/停止服务)先向用户说明做什么再执行。
242
+ - 提权必须靠 UAC/用户"以管理员身份运行",**不要谎称已提权**。
243
+ - 端口占用提示处要不要停旧实例,先让用户确认,避免误杀其会话。