zen-gitsync 2.14.2 → 2.15.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.
Files changed (67) hide show
  1. package/package.json +1 -1
  2. package/src/ui/public/assets/{AppVersionBadge-M9ydXpd9.js → AppVersionBadge-BoAhI0Kt.js} +2 -2
  3. package/src/ui/public/assets/BranchSelector--8Qfb75A.js +1 -0
  4. package/src/ui/public/assets/CommitForm-CobjwXah.js +6 -0
  5. package/src/ui/public/assets/ConsoleView-B2WZRKsP.css +1 -0
  6. package/src/ui/public/assets/ConsoleView-HcfiYLnM.js +17 -0
  7. package/src/ui/public/assets/CustomCommandManager-DQG4wRU7.js +1 -0
  8. package/src/ui/public/assets/{EditorView-rGLnccH3.js → EditorView-CAjuz7yY.js} +0 -0
  9. package/src/ui/public/assets/{FlowExecutionViewer-B4BADP8e.js → FlowExecutionViewer-C8H9uoZL.js} +1 -1
  10. package/src/ui/public/assets/FlowOrchestrationWorkspace-C2RMFB-p.js +21 -0
  11. package/src/ui/public/assets/{LogList-7agk5Obq.js → LogList-8qSNrgT7.js} +4 -4
  12. package/src/ui/public/assets/{MindmapView-mOFG9TmR.js → MindmapView-C5pLBOs_.js} +1 -1
  13. package/src/ui/public/assets/{MonitorView-Dlpgy8Bb.js → MonitorView-Bijvi8pi.js} +1 -1
  14. package/src/ui/public/assets/ProjectStartupButton-Cb-RAZqA.js +1 -0
  15. package/src/ui/public/assets/{RemoteRepoCard-CFKl6CB9.js → RemoteRepoCard-DjfI188H.js} +1 -1
  16. package/src/ui/public/assets/{SourceMapView-Dsc_a3t7.js → SourceMapView-DbdbojUR.js} +2 -2
  17. package/src/ui/public/assets/{SvgIcon-Cw2mL_zx.js → SvgIcon-BVBpj6nK.js} +1 -1
  18. package/src/ui/public/assets/TemplateManager-C0rm8xfC.css +1 -0
  19. package/src/ui/public/assets/TemplateManager-CSI1wQZY.js +1 -0
  20. package/src/ui/public/assets/{UserInputNode-yBT2005h.js → UserInputNode-8ocSzj-6.js} +1 -1
  21. package/src/ui/public/assets/WorkbenchView-CvHPPs8Q.css +1 -0
  22. package/src/ui/public/assets/{WorkbenchView-CiG-qMl7.js → WorkbenchView-DAXAzJTK.js} +4 -4
  23. package/src/ui/public/assets/_plugin-vue_export-helper-Uwc4Esmp.js +15 -0
  24. package/src/ui/public/assets/commandParser-wwLxoJ92.js +1 -0
  25. package/src/ui/public/assets/{configStore-Cq6Qwn35.js → configStore-BHFJlgWp.js} +1 -1
  26. package/src/ui/public/assets/element-plus-R1nMzM9j.css +1 -0
  27. package/src/ui/public/assets/{flow-mindmap-CAkGY2ir.js → flow-mindmap-C0IUPqjN.js} +1 -1
  28. package/src/ui/public/assets/index-BZk-ldXr.css +1 -0
  29. package/src/ui/public/assets/index-Bl8fyL3X.js +23 -0
  30. package/src/ui/public/assets/{vendor-BuiZXcjj.js → vendor-0hkrGFDb.js} +1 -1
  31. package/src/ui/public/assets/{vue-flow-C6RXt-U3.js → vue-flow-CkwNeuGO.js} +1 -1
  32. package/src/ui/public/index.html +9 -11
  33. package/src/ui/server/index.js +632 -625
  34. package/src/ui/server/middleware/errorHandler.js +84 -0
  35. package/src/ui/server/middleware/errorHandler.test.js +114 -0
  36. package/src/ui/server/routes/branchStatus.test.js +37 -0
  37. package/src/ui/server/routes/exec.js +0 -21
  38. package/src/ui/server/routes/gitOps.js +62 -0
  39. package/src/ui/server/routes/monitor.js +42 -26
  40. package/src/ui/server/routes/npm.js +1387 -1327
  41. package/src/ui/server/routes/terminal.js +318 -320
  42. package/src/ui/server/routes/workbench/attachmentUtils.js +76 -0
  43. package/src/ui/server/routes/workbench/index.js +1680 -0
  44. package/src/ui/server/routes/workbench/instructionStore.js +175 -0
  45. package/src/ui/server/routes/workbench/jobStore.js +225 -0
  46. package/src/ui/server/routes/workbench/jsonParse.js +219 -0
  47. package/src/ui/server/routes/workbench/llmClient.js +214 -0
  48. package/src/ui/server/routes/workbench/projectScan.js +191 -0
  49. package/src/ui/server/routes/workbench/sessionStore.js +126 -0
  50. package/src/ui/server/routes/workbench/shared.js +115 -0
  51. package/src/ui/server/routes/workbench/taskRunner.js +493 -0
  52. package/src/ui/server/routes/workbench.js +16 -3523
  53. package/src/utils/index.js +10 -3
  54. package/src/ui/public/assets/BranchSelector-scIk6OeM.js +0 -1
  55. package/src/ui/public/assets/CommandConsole-BikQuarw.js +0 -17
  56. package/src/ui/public/assets/CommandConsole-Da3j09oc.css +0 -1
  57. package/src/ui/public/assets/CommitForm-DuvNxUdP.js +0 -6
  58. package/src/ui/public/assets/CommonDialog-Cyh3xu0p.css +0 -1
  59. package/src/ui/public/assets/CommonDialog-G17HYMln.js +0 -1
  60. package/src/ui/public/assets/FlowOrchestrationWorkspace-C3l3Jco7.js +0 -21
  61. package/src/ui/public/assets/ProjectStartupButton-CgS14qyg.js +0 -1
  62. package/src/ui/public/assets/WorkbenchView-OmJ1eM5g.css +0 -1
  63. package/src/ui/public/assets/_plugin-vue_export-helper-BMXWW0fG.js +0 -15
  64. package/src/ui/public/assets/element-plus-D06ZgZjR.css +0 -1
  65. package/src/ui/public/assets/index-CPqKvZBK.js +0 -23
  66. package/src/ui/public/assets/index-gbRjS0sL.css +0 -1
  67. /package/src/ui/public/assets/{element-plus-B6Owr5jY.js → element-plus-tbZnDtyd.js} +0 -0
@@ -1,320 +1,318 @@
1
- // Copyright 2026 xz333221
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
- //
15
- import { spawn, exec } from 'child_process';
16
- import logger from '../utils/logger.js'
17
- import { psEscape, appleEscape, shQuote } from '../utils/shellQuote.js';
18
- import { asyncRoute, HttpError } from '../utils/asyncRoute.js';
19
-
20
- export function registerTerminalRoutes({
21
- app,
22
- getCurrentProjectPath,
23
- nextTerminalSessionId,
24
- terminalSessions
25
- }) {
26
- async function startTerminalProcess({ command, workingDirectory }) {
27
- const targetDir = workingDirectory || getCurrentProjectPath();
28
-
29
- if (process.platform === 'win32') {
30
- const cmdToRun = String(command || '').trim();
31
-
32
- const splitArgs = (input) => {
33
- const s = String(input || '');
34
- const out = [];
35
- let cur = '';
36
- let inQuotes = false;
37
- for (let i = 0; i < s.length; i++) {
38
- const ch = s[i];
39
- if (ch === '"') {
40
- if (inQuotes && s[i + 1] === '"') {
41
- cur += '"';
42
- i++;
43
- continue;
44
- }
45
- inQuotes = !inQuotes;
46
- continue;
47
- }
48
- if (!inQuotes && /\s/.test(ch)) {
49
- if (cur) out.push(cur);
50
- cur = '';
51
- continue;
52
- }
53
- cur += ch;
54
- }
55
- if (cur) out.push(cur);
56
- return out;
57
- };
58
-
59
- const isUrl = (v) => /^https?:\/\//i.test(String(v || '').trim());
60
-
61
- const tokens = splitArgs(cmdToRun);
62
- const isStartCommand = tokens.length > 0 && String(tokens[0]).toLowerCase() === 'start';
63
-
64
- // PowerShell 转义全部走 shellQuote.psEscape ($ ` " 都会被处理)
65
- let psScript;
66
- if (isStartCommand) {
67
- const args = tokens.slice(1);
68
- const first = args[0];
69
- const second = args[1];
70
- const candidateExe = first === '' ? second : first;
71
- const candidateUrl = isUrl(first) ? first : isUrl(second) ? second : null;
72
- const exe = candidateExe && !isUrl(candidateExe) ? String(candidateExe) : null;
73
-
74
- if (!exe && candidateUrl) {
75
- psScript = `$p = Start-Process -FilePath "${psEscape(candidateUrl)}" -WorkingDirectory "${psEscape(targetDir)}" -PassThru; Write-Output $p.Id`;
76
- } else if (exe && candidateUrl) {
77
- psScript = `$p = Start-Process -FilePath "${psEscape(exe)}" -ArgumentList "${psEscape(candidateUrl)}" -WorkingDirectory "${psEscape(targetDir)}" -PassThru; Write-Output $p.Id`;
78
- } else {
79
- psScript = `$p = Start-Process -FilePath "cmd.exe" -ArgumentList "/C", "${psEscape(cmdToRun)}" -WorkingDirectory "${psEscape(targetDir)}" -WindowStyle Hidden -PassThru; Write-Output $p.Id`;
80
- }
81
- } else {
82
- psScript = `$p = Start-Process -FilePath "cmd.exe" -ArgumentList "/K", "${psEscape(cmdToRun)}" -WorkingDirectory "${psEscape(targetDir)}" -PassThru; Write-Output $p.Id`;
83
- }
84
-
85
- return await new Promise((resolve, reject) => {
86
- const child = spawn('powershell.exe', ['-NoProfile', '-Command', psScript], {
87
- windowsHide: true
88
- });
89
-
90
- let out = '';
91
- let err = '';
92
-
93
- child.stdout?.on('data', (d) => {
94
- out += d.toString('utf8');
95
- });
96
-
97
- child.stderr?.on('data', (d) => {
98
- err += d.toString('utf8');
99
- });
100
-
101
- child.on('error', (e) => reject(e));
102
-
103
- child.on('close', (code) => {
104
- if (code !== 0) {
105
- return reject(new Error(err || `Start-Process 失败,退出码: ${code}`));
106
- }
107
-
108
- const pid = parseInt(String(out).trim(), 10);
109
- if (!Number.isFinite(pid)) {
110
- return reject(new Error(`无法获取终端 PID: ${out || err || 'unknown'}`));
111
- }
112
-
113
- resolve({ pid });
114
- });
115
- });
116
- }
117
-
118
- if (process.platform === 'darwin') {
119
- // AppleScript 转义统一走 shellQuote.appleEscape (\ " 都会被处理)
120
- const script = `tell application "Terminal" to do script "cd ${appleEscape(targetDir)} && ${appleEscape(command.trim())}"`;
121
- // 外层用单引号包,把脚本里可能出现的单引号转义为 '\''
122
- exec(`osascript -e ${shQuote(script)}`, (error) => {
123
- if (error) {
124
- logger.error('打开终端失败:', error);
125
- }
126
- });
127
- return { pid: null };
128
- }
129
-
130
- // Linux: 用 sh 单引号包命令行,单引号内不解释 $ ` " \ ,只把单引号自身 '\''
131
- const terminalCommand = `gnome-terminal -- bash -c ${shQuote(`cd ${targetDir} && ${command.trim()}; exec bash`)} || xterm -e ${shQuote(`cd ${targetDir} && ${command.trim()}; bash`)}`;
132
- exec(terminalCommand, (error) => {
133
- if (error) {
134
- logger.error('打开终端失败:', error);
135
- }
136
- });
137
- return { pid: null };
138
- }
139
-
140
- // 在新终端中执行自定义命令
141
- app.post('/api/exec-in-terminal', asyncRoute(async (req, res) => {
142
- try {
143
- const { command, workingDirectory } = req.body || {};
144
- if (!command || typeof command !== 'string' || !command.trim()) {
145
- throw new HttpError(400, 'command 不能为空');
146
- }
147
-
148
- const targetDir = workingDirectory || getCurrentProjectPath();
149
- logger.info(`在终端中执行命令: ${command}`);
150
- logger.info(`工作目录: ${targetDir}`);
151
-
152
- const terminalSessionId = nextTerminalSessionId();
153
- const now = Date.now();
154
- terminalSessions.set(terminalSessionId, {
155
- id: terminalSessionId,
156
- command: command.trim(),
157
- workingDirectory: targetDir,
158
- pid: null,
159
- createdAt: now,
160
- lastStartedAt: now
161
- });
162
-
163
- const { pid } = await startTerminalProcess({ command, workingDirectory: targetDir });
164
- const session = terminalSessions.get(terminalSessionId);
165
- if (session) {
166
- session.pid = pid;
167
- session.lastStartedAt = Date.now();
168
- terminalSessions.set(terminalSessionId, session);
169
- }
170
-
171
- res.json({
172
- success: true,
173
- message: `已在新终端中执行命令`,
174
- session: terminalSessions.get(terminalSessionId)
175
- });
176
- } catch (error) {
177
- logger.error('在终端中执行命令失败:', error);
178
- res.status(500).json({
179
- success: false,
180
- error: `在终端中执行命令失败: ${error.message}`
181
- });
182
- }
183
- }));
184
-
185
- app.get('/api/terminal-sessions', asyncRoute(async (req, res) => {
186
- try {
187
- const sessions = Array.from(terminalSessions.values()).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
188
- res.json({ success: true, sessions });
189
- } catch (error) {
190
- res.status(500).json({ success: false, error: error.message });
191
- }
192
- }));
193
-
194
- function killTerminalPid(pid) {
195
- if (!pid) return;
196
-
197
- if (process.platform === 'win32') {
198
- exec(`taskkill /pid ${pid} /T /F`, (error) => {
199
- if (error) {
200
- logger.warn(`[终端会话] taskkill 失败(可忽略): ${error?.message || error}`);
201
- }
202
- });
203
- return;
204
- }
205
-
206
- try {
207
- process.kill(pid, 'SIGTERM');
208
- } catch (e) {
209
- // ignore
210
- }
211
- }
212
-
213
- async function isPidAlive(pid) {
214
- if (!pid) return false;
215
-
216
- if (process.platform === 'win32') {
217
- const script = `Get-Process -Id ${pid} -ErrorAction SilentlyContinue | Out-Null; if ($?) { exit 0 } else { exit 1 }`;
218
- return await new Promise((resolve) => {
219
- const child = spawn('powershell.exe', ['-NoProfile', '-Command', script], { windowsHide: true });
220
- child.on('error', () => resolve(false));
221
- child.on('close', (code) => resolve(code === 0));
222
- });
223
- }
224
-
225
- try {
226
- process.kill(pid, 0);
227
- return true;
228
- } catch {
229
- return false;
230
- }
231
- }
232
-
233
- app.post('/api/terminal-sessions/:id/restart', asyncRoute(async (req, res) => {
234
- try {
235
- const id = parseInt(req.params.id, 10);
236
- if (!Number.isFinite(id)) {
237
- throw new HttpError(400, 'id 非法');
238
- }
239
-
240
- const session = terminalSessions.get(id);
241
- if (!session) {
242
- throw new HttpError(404, `终端会话 #${id} 不存在`);
243
- }
244
-
245
- const oldPid = session.pid;
246
- if (oldPid) {
247
- killTerminalPid(oldPid);
248
- }
249
-
250
- const { pid } = await startTerminalProcess({ command: session.command, workingDirectory: session.workingDirectory });
251
- session.pid = pid;
252
- session.lastStartedAt = Date.now();
253
- terminalSessions.set(id, session);
254
-
255
- res.json({ success: true, session });
256
- } catch (error) {
257
- res.status(500).json({ success: false, error: error.message });
258
- }
259
- }));
260
-
261
- app.get('/api/terminal-sessions/status', asyncRoute(async (req, res) => {
262
- try {
263
- const cleanup = String(req.query.cleanup || 'false') === 'true';
264
- const sessions = Array.from(terminalSessions.values());
265
- const results = await Promise.all(sessions.map(async (s) => {
266
- const alive = s?.pid ? await isPidAlive(s.pid) : false;
267
- return { ...s, alive };
268
- }));
269
-
270
- if (cleanup) {
271
- const PROTECT_DURATION_MS = 10000; // 刚启动的进程保护期 10 秒
272
- const now = Date.now();
273
- for (const s of results) {
274
- if (s.pid && !s.alive) {
275
- // 如果会话刚刚启动(10秒内),即使检测不到进程也不删除,给进程启动留时间
276
- const lastStarted = s.lastStartedAt || s.createdAt || 0;
277
- if (now - lastStarted < PROTECT_DURATION_MS) {
278
- continue;
279
- }
280
- terminalSessions.delete(s.id);
281
- }
282
- }
283
- }
284
-
285
- const output = cleanup
286
- ? Array.from(terminalSessions.values()).map((s) => {
287
- const found = results.find(r => r.id === s.id);
288
- return { ...s, alive: found?.alive ?? false };
289
- }).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))
290
- : results.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
291
-
292
- res.json({ success: true, sessions: output });
293
- } catch (error) {
294
- res.status(500).json({ success: false, error: error.message });
295
- }
296
- }));
297
-
298
- app.delete('/api/terminal-sessions/:id', asyncRoute(async (req, res) => {
299
- try {
300
- const id = parseInt(req.params.id, 10);
301
- if (!Number.isFinite(id)) {
302
- throw new HttpError(400, 'id 非法');
303
- }
304
-
305
- const session = terminalSessions.get(id);
306
- if (!session) {
307
- throw new HttpError(404, `终端会话 #${id} 不存在`);
308
- }
309
-
310
- if (session.pid) {
311
- killTerminalPid(session.pid);
312
- }
313
-
314
- terminalSessions.delete(id);
315
- res.json({ success: true, removedId: id });
316
- } catch (error) {
317
- res.status(500).json({ success: false, error: error.message });
318
- }
319
- }));
320
- }
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+ import { spawn, exec } from 'child_process';
16
+ import logger from '../utils/logger.js'
17
+ import { psEscape, appleEscape, shQuote } from '../utils/shellQuote.js';
18
+ import { asyncRoute, HttpError } from '../utils/asyncRoute.js';
19
+
20
+ export function registerTerminalRoutes({
21
+ app,
22
+ getCurrentProjectPath,
23
+ nextTerminalSessionId,
24
+ terminalSessions
25
+ }) {
26
+ async function startTerminalProcess({ command, workingDirectory }) {
27
+ const targetDir = workingDirectory || getCurrentProjectPath();
28
+
29
+ if (process.platform === 'win32') {
30
+ const cmdToRun = String(command || '').trim();
31
+
32
+ const splitArgs = (input) => {
33
+ const s = String(input || '');
34
+ const out = [];
35
+ let cur = '';
36
+ let inQuotes = false;
37
+ for (let i = 0; i < s.length; i++) {
38
+ const ch = s[i];
39
+ if (ch === '"') {
40
+ if (inQuotes && s[i + 1] === '"') {
41
+ cur += '"';
42
+ i++;
43
+ continue;
44
+ }
45
+ inQuotes = !inQuotes;
46
+ continue;
47
+ }
48
+ if (!inQuotes && /\s/.test(ch)) {
49
+ if (cur) out.push(cur);
50
+ cur = '';
51
+ continue;
52
+ }
53
+ cur += ch;
54
+ }
55
+ if (cur) out.push(cur);
56
+ return out;
57
+ };
58
+
59
+ const isUrl = (v) => /^https?:\/\//i.test(String(v || '').trim());
60
+
61
+ const tokens = splitArgs(cmdToRun);
62
+ const isStartCommand = tokens.length > 0 && String(tokens[0]).toLowerCase() === 'start';
63
+
64
+ // PowerShell 转义全部走 shellQuote.psEscape ($ ` " 都会被处理)
65
+ let psScript;
66
+ if (isStartCommand) {
67
+ const args = tokens.slice(1);
68
+ const first = args[0];
69
+ const second = args[1];
70
+ const candidateExe = first === '' ? second : first;
71
+ const candidateUrl = isUrl(first) ? first : isUrl(second) ? second : null;
72
+ const exe = candidateExe && !isUrl(candidateExe) ? String(candidateExe) : null;
73
+
74
+ if (!exe && candidateUrl) {
75
+ psScript = `$p = Start-Process -FilePath "${psEscape(candidateUrl)}" -WorkingDirectory "${psEscape(targetDir)}" -PassThru; Write-Output $p.Id`;
76
+ } else if (exe && candidateUrl) {
77
+ psScript = `$p = Start-Process -FilePath "${psEscape(exe)}" -ArgumentList "${psEscape(candidateUrl)}" -WorkingDirectory "${psEscape(targetDir)}" -PassThru; Write-Output $p.Id`;
78
+ } else {
79
+ psScript = `$p = Start-Process -FilePath "cmd.exe" -ArgumentList "/C", "${psEscape(cmdToRun)}" -WorkingDirectory "${psEscape(targetDir)}" -WindowStyle Hidden -PassThru; Write-Output $p.Id`;
80
+ }
81
+ } else {
82
+ psScript = `$p = Start-Process -FilePath "cmd.exe" -ArgumentList "/K", "${psEscape(cmdToRun)}" -WorkingDirectory "${psEscape(targetDir)}" -PassThru; Write-Output $p.Id`;
83
+ }
84
+
85
+ return await new Promise((resolve, reject) => {
86
+ const child = spawn('powershell.exe', ['-NoProfile', '-Command', psScript], {
87
+ windowsHide: true
88
+ });
89
+
90
+ let out = '';
91
+ let err = '';
92
+
93
+ child.stdout?.on('data', (d) => {
94
+ out += d.toString('utf8');
95
+ });
96
+
97
+ child.stderr?.on('data', (d) => {
98
+ err += d.toString('utf8');
99
+ });
100
+
101
+ child.on('error', (e) => reject(e));
102
+
103
+ child.on('close', (code) => {
104
+ if (code !== 0) {
105
+ return reject(new Error(err || `Start-Process 失败,退出码: ${code}`));
106
+ }
107
+
108
+ const pid = parseInt(String(out).trim(), 10);
109
+ if (!Number.isFinite(pid)) {
110
+ return reject(new Error(`无法获取终端 PID: ${out || err || 'unknown'}`));
111
+ }
112
+
113
+ resolve({ pid });
114
+ });
115
+ });
116
+ }
117
+
118
+ if (process.platform === 'darwin') {
119
+ // AppleScript 转义统一走 shellQuote.appleEscape (\ " 都会被处理)
120
+ const script = `tell application "Terminal" to do script "cd ${appleEscape(targetDir)} && ${appleEscape(command.trim())}"`;
121
+ // 外层用单引号包,把脚本里可能出现的单引号转义为 '\''
122
+ exec(`osascript -e ${shQuote(script)}`, (error) => {
123
+ if (error) {
124
+ logger.error('打开终端失败:', error);
125
+ }
126
+ });
127
+ return { pid: null };
128
+ }
129
+
130
+ // Linux: 用 sh 单引号包命令行,单引号内不解释 $ ` " \ ,只把单引号自身 '\''
131
+ const terminalCommand = `gnome-terminal -- bash -c ${shQuote(`cd ${targetDir} && ${command.trim()}; exec bash`)} || xterm -e ${shQuote(`cd ${targetDir} && ${command.trim()}; bash`)}`;
132
+ exec(terminalCommand, (error) => {
133
+ if (error) {
134
+ logger.error('打开终端失败:', error);
135
+ }
136
+ });
137
+ return { pid: null };
138
+ }
139
+
140
+ // 在新终端中执行自定义命令
141
+ app.post('/api/exec-in-terminal', asyncRoute(async (req, res) => {
142
+ try {
143
+ const { command, workingDirectory } = req.body || {};
144
+ if (!command || typeof command !== 'string' || !command.trim()) {
145
+ throw new HttpError(400, 'command 不能为空');
146
+ }
147
+
148
+ const targetDir = workingDirectory || getCurrentProjectPath();
149
+ logger.info(`在终端中执行命令: ${command}`);
150
+ logger.info(`工作目录: ${targetDir}`);
151
+
152
+ const terminalSessionId = nextTerminalSessionId();
153
+ const now = Date.now();
154
+ terminalSessions.set(terminalSessionId, {
155
+ id: terminalSessionId,
156
+ command: command.trim(),
157
+ workingDirectory: targetDir,
158
+ pid: null,
159
+ createdAt: now,
160
+ lastStartedAt: now
161
+ });
162
+
163
+ const { pid } = await startTerminalProcess({ command, workingDirectory: targetDir });
164
+ const session = terminalSessions.get(terminalSessionId);
165
+ if (session) {
166
+ session.pid = pid;
167
+ session.lastStartedAt = Date.now();
168
+ terminalSessions.set(terminalSessionId, session);
169
+ }
170
+
171
+ res.json({
172
+ success: true,
173
+ message: `已在新终端中执行命令`,
174
+ session: terminalSessions.get(terminalSessionId)
175
+ });
176
+ } catch (error) {
177
+ logger.error('在终端中执行命令失败:', error);
178
+ res.status(500).json({
179
+ success: false,
180
+ error: `在终端中执行命令失败: ${error.message}`
181
+ });
182
+ }
183
+ }));
184
+
185
+ app.get('/api/terminal-sessions', asyncRoute(async (req, res) => {
186
+ try {
187
+ const sessions = Array.from(terminalSessions.values()).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
188
+ res.json({ success: true, sessions });
189
+ } catch (error) {
190
+ res.status(500).json({ success: false, error: error.message });
191
+ }
192
+ }));
193
+
194
+ function killTerminalPid(pid) {
195
+ if (!pid) return;
196
+
197
+ if (process.platform === 'win32') {
198
+ exec(`taskkill /pid ${pid} /T /F`, (error) => {
199
+ if (error) {
200
+ logger.warn(`[终端会话] taskkill 失败(可忽略): ${error?.message || error}`);
201
+ }
202
+ });
203
+ return;
204
+ }
205
+
206
+ try {
207
+ process.kill(pid, 'SIGTERM');
208
+ } catch (e) {
209
+ // ignore
210
+ }
211
+ }
212
+
213
+ // process.kill(pid, 0) 跨平台探测进程存活(信号 0 = 只探测不发信号)。
214
+ // 原实现 Windows 分支 spawn powershell.exe 冷启动 1-2s/次,N 个 session 累积
215
+ // 让 /api/terminal-sessions/status 耗时 15-29s;改用 process.kill 后 <1ms/次。
216
+ // Node Windows 上用 OpenProcess 模拟信号 0,语义与 POSIX 一致:
217
+ // - 进程存在 无错返回 true
218
+ // - 进程不存在 ESRCH → false
219
+ // - 无权限 EPERM true(进程存在但属于其他用户)
220
+ async function isPidAlive(pid) {
221
+ if (!pid) return false;
222
+ try {
223
+ process.kill(pid, 0);
224
+ return true;
225
+ } catch (e) {
226
+ if (e.code === 'EPERM') return true;
227
+ return false;
228
+ }
229
+ }
230
+
231
+ app.post('/api/terminal-sessions/:id/restart', asyncRoute(async (req, res) => {
232
+ try {
233
+ const id = parseInt(req.params.id, 10);
234
+ if (!Number.isFinite(id)) {
235
+ throw new HttpError(400, 'id 非法');
236
+ }
237
+
238
+ const session = terminalSessions.get(id);
239
+ if (!session) {
240
+ throw new HttpError(404, `终端会话 #${id} 不存在`);
241
+ }
242
+
243
+ const oldPid = session.pid;
244
+ if (oldPid) {
245
+ killTerminalPid(oldPid);
246
+ }
247
+
248
+ const { pid } = await startTerminalProcess({ command: session.command, workingDirectory: session.workingDirectory });
249
+ session.pid = pid;
250
+ session.lastStartedAt = Date.now();
251
+ terminalSessions.set(id, session);
252
+
253
+ res.json({ success: true, session });
254
+ } catch (error) {
255
+ res.status(500).json({ success: false, error: error.message });
256
+ }
257
+ }));
258
+
259
+ app.get('/api/terminal-sessions/status', asyncRoute(async (req, res) => {
260
+ try {
261
+ const cleanup = String(req.query.cleanup || 'false') === 'true';
262
+ const sessions = Array.from(terminalSessions.values());
263
+ const results = await Promise.all(sessions.map(async (s) => {
264
+ const alive = s?.pid ? await isPidAlive(s.pid) : false;
265
+ return { ...s, alive };
266
+ }));
267
+
268
+ if (cleanup) {
269
+ const PROTECT_DURATION_MS = 10000; // 刚启动的进程保护期 10 秒
270
+ const now = Date.now();
271
+ for (const s of results) {
272
+ if (s.pid && !s.alive) {
273
+ // 如果会话刚刚启动(10秒内),即使检测不到进程也不删除,给进程启动留时间
274
+ const lastStarted = s.lastStartedAt || s.createdAt || 0;
275
+ if (now - lastStarted < PROTECT_DURATION_MS) {
276
+ continue;
277
+ }
278
+ terminalSessions.delete(s.id);
279
+ }
280
+ }
281
+ }
282
+
283
+ const output = cleanup
284
+ ? Array.from(terminalSessions.values()).map((s) => {
285
+ const found = results.find(r => r.id === s.id);
286
+ return { ...s, alive: found?.alive ?? false };
287
+ }).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))
288
+ : results.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
289
+
290
+ res.json({ success: true, sessions: output });
291
+ } catch (error) {
292
+ res.status(500).json({ success: false, error: error.message });
293
+ }
294
+ }));
295
+
296
+ app.delete('/api/terminal-sessions/:id', asyncRoute(async (req, res) => {
297
+ try {
298
+ const id = parseInt(req.params.id, 10);
299
+ if (!Number.isFinite(id)) {
300
+ throw new HttpError(400, 'id 非法');
301
+ }
302
+
303
+ const session = terminalSessions.get(id);
304
+ if (!session) {
305
+ throw new HttpError(404, `终端会话 #${id} 不存在`);
306
+ }
307
+
308
+ if (session.pid) {
309
+ killTerminalPid(session.pid);
310
+ }
311
+
312
+ terminalSessions.delete(id);
313
+ res.json({ success: true, removedId: id });
314
+ } catch (error) {
315
+ res.status(500).json({ success: false, error: error.message });
316
+ }
317
+ }));
318
+ }