zen-gitsync 2.15.10 → 2.15.12
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/package.json +4 -3
- package/src/gitCommit.js +475 -475
- package/src/ui/public/assets/AppVersionBadge-BaXEI9Aq.js +2 -0
- package/src/ui/public/assets/BranchSelector-DJhEF0y9.js +1 -0
- package/src/ui/public/assets/CommitForm-BfpcK-bz.js +6 -0
- package/src/ui/public/assets/ConsoleView-D838O0O6.js +17 -0
- package/src/ui/public/assets/CustomCommandManager-BcCK46df.js +1 -0
- package/src/ui/public/assets/EditorView-DnnqYNGF.js +0 -0
- package/src/ui/public/assets/{FlowExecutionViewer-Dvv2-pUp.js → FlowExecutionViewer-CSlMmcDS.js} +1 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-UgHpZ3FR.js +21 -0
- package/src/ui/public/assets/LogList-uUgskwwp.js +7 -0
- package/src/ui/public/assets/MindmapView-CCmFMiHR.js +1 -0
- package/src/ui/public/assets/{MindmapView-Bt2VdyoX.css → MindmapView-KFcJx-i-.css} +1 -1
- package/src/ui/public/assets/MonitorView-BxoqeX70.js +1 -0
- package/src/ui/public/assets/ProjectStartupButton-C3AwivIB.js +1 -0
- package/src/ui/public/assets/{RemoteRepoCard-BbKpAzPj.js → RemoteRepoCard-BbxeVpje.js} +1 -1
- package/src/ui/public/assets/SourceMapView-DKv-Y6t_.js +3 -0
- package/src/ui/public/assets/{SvgIcon-BoP8CWdO.js → SvgIcon-sMM8XFgm.js} +1 -1
- package/src/ui/public/assets/TemplateManager-Dzj8rXVw.js +1 -0
- package/src/ui/public/assets/UserInputNode-BU7Trzq-.js +1 -0
- package/src/ui/public/assets/WorkbenchView-DMoulJCG.css +1 -0
- package/src/ui/public/assets/WorkbenchView-DTgRoOkv.js +12 -0
- package/src/ui/public/assets/{_plugin-vue_export-helper-D9aIxbUC.js → _plugin-vue_export-helper-8ryIYVvs.js} +1 -1
- package/src/ui/public/assets/{configStore-CiAM1jWr.js → configStore-D77uNk8X.js} +1 -1
- package/src/ui/public/assets/element-plus-D8SQiesr.js +17 -0
- package/src/ui/public/assets/element-plus-DB_sOyH_.css +1 -0
- package/src/ui/public/assets/flow-mindmap-BLa-kSA7.js +90 -0
- package/src/ui/public/assets/flow-mindmap-C_aN-n3H.css +1 -0
- package/src/ui/public/assets/index-CpFreGqX.js +23 -0
- package/src/ui/public/assets/index-CytKCnOp.css +1 -0
- package/src/ui/public/assets/{socket-io-z4n873W1.js → socket-io-B1foIbJS.js} +1 -1
- package/src/ui/public/assets/{vendor-SOsdHF7M.js → vendor-B9orL8iU.js} +37 -37
- package/src/ui/public/assets/vendor-Dm_yd-By.css +1 -0
- package/src/ui/public/assets/{vue-flow-D5lw7RxP.js → vue-flow-BY7LODit.js} +1 -1
- package/src/ui/public/favicon.svg +75 -75
- package/src/ui/public/index.html +28 -28
- package/src/ui/public/logo.svg +74 -74
- package/src/ui/server/middleware/requestLogger.js +53 -53
- package/src/ui/server/routes/branchStatus.js +105 -105
- package/src/ui/server/routes/fileOpen.js +309 -309
- package/src/ui/server/routes/fs.js +791 -791
- package/src/ui/server/routes/git/diff.js +426 -426
- package/src/ui/server/routes/git/diffUtils.js +149 -149
- package/src/ui/server/routes/git/stash.js +550 -550
- package/src/ui/server/routes/git/tags.js +176 -176
- package/src/ui/server/routes/git.js +181 -181
- package/src/ui/server/routes/gitOps.js +1211 -1211
- package/src/ui/server/routes/process.js +86 -86
- package/src/ui/server/routes/status.js +67 -67
- package/src/ui/server/utils/createSavePortToFile.js +48 -48
- package/src/ui/server/utils/instanceRegistry.js +282 -282
- package/src/ui/server/utils/startServerOnAvailablePort.js +116 -116
- package/src/utils/index.js +1168 -1168
- package/src/ui/public/assets/AppVersionBadge-ZginY_T5.js +0 -2
- package/src/ui/public/assets/BranchSelector-BD8y_UMH.js +0 -1
- package/src/ui/public/assets/CommitForm-B1DJxyib.js +0 -6
- package/src/ui/public/assets/ConsoleView-EJa9vjMa.js +0 -17
- package/src/ui/public/assets/CustomCommandManager-BRxKf9jl.js +0 -1
- package/src/ui/public/assets/EditorView-DR2O8qKP.js +0 -0
- package/src/ui/public/assets/FlowOrchestrationWorkspace-DzSgBTmq.js +0 -21
- package/src/ui/public/assets/LogList-BEnd3kcT.js +0 -7
- package/src/ui/public/assets/MindmapView-Ds2Qd2ZL.js +0 -1
- package/src/ui/public/assets/MonitorView-B9t-8uJB.js +0 -1
- package/src/ui/public/assets/ProjectStartupButton-CHh6z5Pn.js +0 -1
- package/src/ui/public/assets/SourceMapView-Deai1zNG.js +0 -3
- package/src/ui/public/assets/TemplateManager-Eu4YhYCk.js +0 -1
- package/src/ui/public/assets/UserInputNode-C0GJHtef.js +0 -1
- package/src/ui/public/assets/WorkbenchView-C2xSxJfg.css +0 -1
- package/src/ui/public/assets/WorkbenchView-DUS0hiHf.js +0 -12
- package/src/ui/public/assets/element-plus-DB-NeCl9.js +0 -24
- package/src/ui/public/assets/element-plus-R1nMzM9j.css +0 -1
- package/src/ui/public/assets/flow-mindmap-CI2OSS19.css +0 -1
- package/src/ui/public/assets/flow-mindmap-DrcjupFb.js +0 -90
- package/src/ui/public/assets/index-Dleiy2zi.js +0 -23
- package/src/ui/public/assets/index-DltbTqIJ.css +0 -1
- package/src/ui/public/assets/vendor-CT8e9rs3.css +0 -1
- package/src/ui/server/.claude/codediff.txt +0 -8
|
@@ -1,791 +1,791 @@
|
|
|
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 express from 'express';
|
|
16
|
-
import logger from '../utils/logger.js'
|
|
17
|
-
import chalk from 'chalk';
|
|
18
|
-
import fs from 'fs/promises';
|
|
19
|
-
import path from 'path';
|
|
20
|
-
import open from 'open';
|
|
21
|
-
import os from 'os';
|
|
22
|
-
import { spawn, exec } from 'child_process';
|
|
23
|
-
import { ensureWithinCwd } from '../utils/pathGuard.js';
|
|
24
|
-
import { asyncRoute, HttpError } from '../utils/asyncRoute.js';
|
|
25
|
-
import { invalidateCurrentProjectKey, invalidateRawConfigCache } from '../../../config.js';
|
|
26
|
-
import { invalidateCwdCache } from '../../../utils/index.js';
|
|
27
|
-
|
|
28
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
29
|
-
// /api/change_directory 路径白名单(SEC-PATH-2)
|
|
30
|
-
//
|
|
31
|
-
// 防止 chdir 到任意绝对路径(尤其 / 或 C:\\)后,下游 fs / git 操作都
|
|
32
|
-
// 跟着跨出项目。
|
|
33
|
-
// 规则:
|
|
34
|
-
// 1. 必须是字符串
|
|
35
|
-
// 2. 必须是绝对路径或相对 process.cwd() 能解析的
|
|
36
|
-
// 3. 解析后必须是已存在的目录
|
|
37
|
-
// 4. 不能包含 NUL / 控制字符 / 路径穿越 token("..")越过祖先之外
|
|
38
|
-
//
|
|
39
|
-
// 注意:即便合法,也最好在 chdir 后立即 invalidateCurrentProjectKey 与
|
|
40
|
-
// config 缓存,详见 fs.js 后续代码。
|
|
41
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
42
|
-
const FORBIDDEN_PATH_PATTERNS = /[\x00-\x1f]/
|
|
43
|
-
|
|
44
|
-
function validateChangeDirectoryPath(input) {
|
|
45
|
-
if (!input || typeof input !== 'string') {
|
|
46
|
-
return { ok: false, error: '目录路径必须是非空字符串' };
|
|
47
|
-
}
|
|
48
|
-
const trimmed = input.trim();
|
|
49
|
-
if (!trimmed) {
|
|
50
|
-
return { ok: false, error: '目录路径不能为空' };
|
|
51
|
-
}
|
|
52
|
-
if (FORBIDDEN_PATH_PATTERNS.test(trimmed)) {
|
|
53
|
-
return { ok: false, error: '目录路径包含非法控制字符' };
|
|
54
|
-
}
|
|
55
|
-
// 不允许长度过长的路径(防止 DoS / 解析爆炸)
|
|
56
|
-
if (trimmed.length > 4096) {
|
|
57
|
-
return { ok: false, error: '目录路径过长' };
|
|
58
|
-
}
|
|
59
|
-
const resolved = path.resolve(trimmed);
|
|
60
|
-
// 阻止 chdir 到根目录之外但仍是合理目录的边界场景:
|
|
61
|
-
// 这里采用宽松策略 — 只要 resolved 是已存在的目录即可(无法 100% 阻止
|
|
62
|
-
// chdir 到任意位置,这是产品要求:GUI 内的"切换项目"功能需要允许切到
|
|
63
|
-
// 任意用户目录)。安全护栏放在下游 fs/git 操作上(safePathInProject)。
|
|
64
|
-
return { ok: true, resolved };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// 把 absolute path 准备好传给 spawn/exec,内部不再做字符串拼接,
|
|
68
|
-
// Windows 走 cmd /c start "" ... argv 模式;Linux 走 sh -c 但 argument 已被
|
|
69
|
-
// shQuote.js 转义
|
|
70
|
-
import { shQuote, psEscape } from '../utils/shellQuote.js';
|
|
71
|
-
|
|
72
|
-
export function registerFsRoutes({
|
|
73
|
-
app,
|
|
74
|
-
execGitCommand,
|
|
75
|
-
configManager,
|
|
76
|
-
io,
|
|
77
|
-
getCurrentProjectPath,
|
|
78
|
-
setCurrentProjectPath,
|
|
79
|
-
getProjectRoomId,
|
|
80
|
-
setProjectRoomId,
|
|
81
|
-
setIsGitRepo
|
|
82
|
-
}) {
|
|
83
|
-
// ── 解析并校验 user 输入路径在当前项目 cwd 内(防 ../ 父目录逃逸、startsWith 假阳性、Windows 大小写)──
|
|
84
|
-
const safePathInProject = async (userPath) => {
|
|
85
|
-
const cwd = getCurrentProjectPath() || process.cwd()
|
|
86
|
-
return ensureWithinCwd(userPath, cwd)
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// 新增获取当前工作目录接口
|
|
90
|
-
app.get('/api/current_directory', asyncRoute(async (req, res) => {
|
|
91
|
-
try {
|
|
92
|
-
const directory = process.cwd();
|
|
93
|
-
|
|
94
|
-
// 检查当前目录是否是Git仓库
|
|
95
|
-
try {
|
|
96
|
-
await execGitCommand(['rev-parse', '--is-inside-work-tree']);
|
|
97
|
-
} catch (error) {
|
|
98
|
-
return res.status(400).json({
|
|
99
|
-
error: '当前目录不是一个Git仓库',
|
|
100
|
-
directory,
|
|
101
|
-
isGitRepo: false
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
res.json({
|
|
106
|
-
directory,
|
|
107
|
-
isGitRepo: true
|
|
108
|
-
});
|
|
109
|
-
} catch (error) {
|
|
110
|
-
res.status(500).json({ error: error.message });
|
|
111
|
-
}
|
|
112
|
-
}));
|
|
113
|
-
|
|
114
|
-
// 新增切换工作目录接口
|
|
115
|
-
app.post('/api/change_directory', asyncRoute(async (req, res) => {
|
|
116
|
-
try {
|
|
117
|
-
const { path: reqPath } = req.body;
|
|
118
|
-
|
|
119
|
-
if (!reqPath) {
|
|
120
|
-
throw new HttpError(400, '目录路径不能为空');
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// SEC-PATH-2 防御:路径白名单 + 长度限制 + 控制字符拦截
|
|
124
|
-
const validation = validateChangeDirectoryPath(reqPath);
|
|
125
|
-
if (!validation.ok) {
|
|
126
|
-
throw new HttpError(400, validation.error);
|
|
127
|
-
}
|
|
128
|
-
const safeReqPath = validation.resolved;
|
|
129
|
-
|
|
130
|
-
try {
|
|
131
|
-
// 解析后再次确认是已存在的目录,防止 race 期间目录被删
|
|
132
|
-
try {
|
|
133
|
-
const stat = await fs.stat(safeReqPath);
|
|
134
|
-
if (!stat.isDirectory()) {
|
|
135
|
-
throw new HttpError(400, '目标路径不是目录');
|
|
136
|
-
}
|
|
137
|
-
} catch (e) {
|
|
138
|
-
if (e instanceof HttpError) throw e;
|
|
139
|
-
throw new HttpError(400, `目标目录不存在: ${safeReqPath}`);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
process.chdir(safeReqPath);
|
|
143
|
-
// chdir 后立即失效项目键 + 原始 config 缓存,防止下一次 loadConfig /
|
|
144
|
-
// saveConfig 还命中旧 cwd 容器写到错误项目下。
|
|
145
|
-
// 同时清掉 utils/index.js 里 getCwd() 的 cwd 缓存,避免后续 execGitCommand
|
|
146
|
-
// 仍按 process.argv / 启动期 cwd 跑子命令,导致切到非 git 目录后
|
|
147
|
-
// /api/current_directory 等接口的 rev-parse 误判为 git 仓库。
|
|
148
|
-
invalidateCurrentProjectKey();
|
|
149
|
-
invalidateRawConfigCache();
|
|
150
|
-
invalidateCwdCache();
|
|
151
|
-
const newDirectory = process.cwd();
|
|
152
|
-
|
|
153
|
-
// 更新当前项目路径和房间ID
|
|
154
|
-
const oldProjectPath = getCurrentProjectPath();
|
|
155
|
-
const newProjectPath = newDirectory;
|
|
156
|
-
const newProjectRoomId = `project:${newProjectPath.replace(/[\\/:*?"<>|\s]/g, '_')}`;
|
|
157
|
-
|
|
158
|
-
console.log(chalk.yellow(`项目路径切换: ${oldProjectPath} -> ${newProjectPath}`));
|
|
159
|
-
console.log(chalk.yellow(`房间ID更新: ${getProjectRoomId()} -> ${newProjectRoomId}`));
|
|
160
|
-
|
|
161
|
-
// 检查新目录是否是Git仓库
|
|
162
|
-
try {
|
|
163
|
-
await execGitCommand(['rev-parse', '--is-inside-work-tree']);
|
|
164
|
-
|
|
165
|
-
// 更新全局变量
|
|
166
|
-
setCurrentProjectPath(newProjectPath);
|
|
167
|
-
setProjectRoomId(newProjectRoomId);
|
|
168
|
-
setIsGitRepo(true);
|
|
169
|
-
|
|
170
|
-
// 确保切换后立即初始化该项目的配置条目
|
|
171
|
-
try {
|
|
172
|
-
const currentCfg = await configManager.loadConfig();
|
|
173
|
-
await configManager.saveConfig(currentCfg);
|
|
174
|
-
// 将新目录加入最近目录
|
|
175
|
-
await configManager.saveRecentDirectory(newDirectory);
|
|
176
|
-
} catch (e) {
|
|
177
|
-
logger.warn('初始化项目配置失败:', e?.message || e);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// 通知所有旧房间的客户端项目已切换
|
|
181
|
-
io.to(getProjectRoomId()).emit('project_changed', {
|
|
182
|
-
oldProjectPath: getCurrentProjectPath(),
|
|
183
|
-
newProjectPath: newProjectPath,
|
|
184
|
-
newProjectRoomId: newProjectRoomId
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
res.json({
|
|
188
|
-
success: true,
|
|
189
|
-
directory: newDirectory,
|
|
190
|
-
isGitRepo: true,
|
|
191
|
-
projectRoomId: newProjectRoomId
|
|
192
|
-
});
|
|
193
|
-
} catch (error) {
|
|
194
|
-
// 不是Git仓库,停止监控
|
|
195
|
-
|
|
196
|
-
// 更新全局变量
|
|
197
|
-
setCurrentProjectPath(newProjectPath);
|
|
198
|
-
setProjectRoomId(newProjectRoomId);
|
|
199
|
-
setIsGitRepo(false);
|
|
200
|
-
|
|
201
|
-
// 通知所有旧房间的客户端项目已切换
|
|
202
|
-
io.to(getProjectRoomId()).emit('project_changed', {
|
|
203
|
-
oldProjectPath: getCurrentProjectPath(),
|
|
204
|
-
newProjectPath: newProjectPath,
|
|
205
|
-
newProjectRoomId: newProjectRoomId
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
// 即使不是Git仓库也初始化当前目录配置(使用CWD作为项目键)
|
|
209
|
-
try {
|
|
210
|
-
const currentCfg = await configManager.loadConfig();
|
|
211
|
-
await configManager.saveConfig(currentCfg);
|
|
212
|
-
// 将新目录加入最近目录
|
|
213
|
-
await configManager.saveRecentDirectory(newDirectory);
|
|
214
|
-
} catch (e) {
|
|
215
|
-
logger.warn('非Git目录初始化项目配置失败:', e?.message || e);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
res.json({
|
|
219
|
-
success: true,
|
|
220
|
-
directory: newDirectory,
|
|
221
|
-
isGitRepo: false,
|
|
222
|
-
warning: '新目录不是一个Git仓库',
|
|
223
|
-
projectRoomId: newProjectRoomId
|
|
224
|
-
});
|
|
225
|
-
}
|
|
226
|
-
} catch (error) {
|
|
227
|
-
if (error instanceof HttpError) {
|
|
228
|
-
return res.status(error.status).json({ success: false, error: error.message });
|
|
229
|
-
}
|
|
230
|
-
res.status(400).json({
|
|
231
|
-
success: false,
|
|
232
|
-
error: `切换到目录 "${safeReqPath}" 失败: ${error.message}`
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
} catch (error) {
|
|
236
|
-
res.status(500).json({ error: error.message });
|
|
237
|
-
}
|
|
238
|
-
}));
|
|
239
|
-
|
|
240
|
-
// 获取目录内容(用于浏览目录)
|
|
241
|
-
app.get('/api/browse_directory', asyncRoute(async (req, res) => {
|
|
242
|
-
try {
|
|
243
|
-
// 获取要浏览的目录路径,如果没有提供,则使用当前目录
|
|
244
|
-
const directoryPath = req.query.path || process.cwd();
|
|
245
|
-
|
|
246
|
-
try {
|
|
247
|
-
// 读取目录内容
|
|
248
|
-
const items = await fs.readdir(directoryPath, { withFileTypes: true });
|
|
249
|
-
|
|
250
|
-
// 分离文件夹和文件
|
|
251
|
-
const directories = [];
|
|
252
|
-
const files = [];
|
|
253
|
-
|
|
254
|
-
for (const item of items) {
|
|
255
|
-
const fullPath = path.join(directoryPath, item.name);
|
|
256
|
-
if (item.isDirectory()) {
|
|
257
|
-
directories.push({
|
|
258
|
-
name: item.name,
|
|
259
|
-
path: fullPath,
|
|
260
|
-
type: 'directory'
|
|
261
|
-
});
|
|
262
|
-
} else if (item.isFile()) {
|
|
263
|
-
files.push({
|
|
264
|
-
name: item.name,
|
|
265
|
-
path: fullPath,
|
|
266
|
-
type: 'file'
|
|
267
|
-
});
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// 优先显示目录,然后是文件,都按字母排序
|
|
272
|
-
directories.sort((a, b) => a.name.localeCompare(b.name));
|
|
273
|
-
files.sort((a, b) => a.name.localeCompare(b.name));
|
|
274
|
-
|
|
275
|
-
// 获取父目录路径
|
|
276
|
-
const parentPath = path.dirname(directoryPath);
|
|
277
|
-
|
|
278
|
-
res.json({
|
|
279
|
-
success: true,
|
|
280
|
-
currentPath: directoryPath,
|
|
281
|
-
parentPath: parentPath !== directoryPath ? parentPath : null,
|
|
282
|
-
items: [...directories, ...files]
|
|
283
|
-
});
|
|
284
|
-
} catch (error) {
|
|
285
|
-
res.status(400).json({
|
|
286
|
-
success: false,
|
|
287
|
-
error: `无法读取目录 "${directoryPath}": ${error.message}`
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
} catch (error) {
|
|
291
|
-
res.status(500).json({ error: error.message });
|
|
292
|
-
}
|
|
293
|
-
}));
|
|
294
|
-
|
|
295
|
-
// POST接口版本的浏览目录功能
|
|
296
|
-
app.post('/api/browse_directory', asyncRoute(async (req, res) => {
|
|
297
|
-
try {
|
|
298
|
-
// 获取要浏览的目录路径,如果没有提供,则使用当前目录
|
|
299
|
-
const directoryPath = req.body.currentPath || process.cwd();
|
|
300
|
-
|
|
301
|
-
try {
|
|
302
|
-
// 读取目录内容
|
|
303
|
-
const items = await fs.readdir(directoryPath, { withFileTypes: true });
|
|
304
|
-
|
|
305
|
-
// 分离文件夹和文件
|
|
306
|
-
const directories = [];
|
|
307
|
-
|
|
308
|
-
for (const item of items) {
|
|
309
|
-
const fullPath = path.join(directoryPath, item.name);
|
|
310
|
-
if (item.isDirectory()) {
|
|
311
|
-
directories.push({
|
|
312
|
-
name: item.name,
|
|
313
|
-
path: fullPath,
|
|
314
|
-
type: 'directory'
|
|
315
|
-
});
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// 只返回目录,不返回文件
|
|
320
|
-
directories.sort((a, b) => a.name.localeCompare(b.name));
|
|
321
|
-
|
|
322
|
-
// 获取父目录路径
|
|
323
|
-
const parentPath = path.dirname(directoryPath);
|
|
324
|
-
|
|
325
|
-
// 返回选择的目录路径
|
|
326
|
-
res.json({
|
|
327
|
-
success: true,
|
|
328
|
-
path: directoryPath,
|
|
329
|
-
parentPath: parentPath !== directoryPath ? parentPath : null,
|
|
330
|
-
items: directories
|
|
331
|
-
});
|
|
332
|
-
} catch (error) {
|
|
333
|
-
res.status(400).json({
|
|
334
|
-
success: false,
|
|
335
|
-
error: `无法读取目录 "${directoryPath}": ${error.message}`
|
|
336
|
-
});
|
|
337
|
-
}
|
|
338
|
-
} catch (error) {
|
|
339
|
-
res.status(500).json({
|
|
340
|
-
success: false,
|
|
341
|
-
error: error.message
|
|
342
|
-
});
|
|
343
|
-
}
|
|
344
|
-
}));
|
|
345
|
-
|
|
346
|
-
// 获取最近访问的目录列表
|
|
347
|
-
app.get('/api/recent_directories', asyncRoute(async (req, res) => {
|
|
348
|
-
try {
|
|
349
|
-
// 尝试从配置中获取最近的目录
|
|
350
|
-
const recentDirs = await configManager.getRecentDirectories();
|
|
351
|
-
// 并行检查每个目录是否存在
|
|
352
|
-
const checked = await Promise.all(
|
|
353
|
-
(recentDirs || []).map(async (dir) => {
|
|
354
|
-
let exists = false;
|
|
355
|
-
try {
|
|
356
|
-
await fs.access(dir);
|
|
357
|
-
exists = true;
|
|
358
|
-
} catch {
|
|
359
|
-
exists = false;
|
|
360
|
-
}
|
|
361
|
-
return { path: dir, exists };
|
|
362
|
-
})
|
|
363
|
-
);
|
|
364
|
-
res.json({
|
|
365
|
-
success: true,
|
|
366
|
-
directories: checked
|
|
367
|
-
});
|
|
368
|
-
} catch (error) {
|
|
369
|
-
res.status(500).json({
|
|
370
|
-
success: false,
|
|
371
|
-
error: error.message
|
|
372
|
-
});
|
|
373
|
-
}
|
|
374
|
-
}));
|
|
375
|
-
|
|
376
|
-
// 在资源管理器/访达中打开当前目录
|
|
377
|
-
app.post('/api/open_directory', asyncRoute(async (req, res) => {
|
|
378
|
-
try {
|
|
379
|
-
// 获取要打开的目录路径,如果没有提供,则使用当前目录
|
|
380
|
-
const directoryPath = req.body.path || process.cwd();
|
|
381
|
-
|
|
382
|
-
try {
|
|
383
|
-
// 检查目录是否存在
|
|
384
|
-
await fs.access(directoryPath);
|
|
385
|
-
|
|
386
|
-
// 使用open模块打开目录,自动处理不同操作系统
|
|
387
|
-
await open(directoryPath, { wait: false });
|
|
388
|
-
|
|
389
|
-
res.json({
|
|
390
|
-
success: true,
|
|
391
|
-
message: '已在文件管理器中打开目录'
|
|
392
|
-
});
|
|
393
|
-
} catch (error) {
|
|
394
|
-
res.status(400).json({
|
|
395
|
-
success: false,
|
|
396
|
-
error: `无法打开目录 "${directoryPath}": ${error.message}`
|
|
397
|
-
});
|
|
398
|
-
}
|
|
399
|
-
} catch (error) {
|
|
400
|
-
res.status(500).json({
|
|
401
|
-
success: false,
|
|
402
|
-
error: error.message
|
|
403
|
-
});
|
|
404
|
-
}
|
|
405
|
-
}));
|
|
406
|
-
|
|
407
|
-
// 在终端中打开当前目录
|
|
408
|
-
app.post('/api/open_terminal', asyncRoute(async (req, res) => {
|
|
409
|
-
try {
|
|
410
|
-
// SEC-INJ-4 防御:先 path.resolve 规范化,后续所有 spawn 走 argv 数组
|
|
411
|
-
const directoryPath = path.resolve(String(req.body.path || process.cwd()));
|
|
412
|
-
|
|
413
|
-
try {
|
|
414
|
-
// 检查目录是否存在
|
|
415
|
-
await fs.access(directoryPath);
|
|
416
|
-
|
|
417
|
-
// 根据不同操作系统打开终端
|
|
418
|
-
const platform = os.platform();
|
|
419
|
-
let command;
|
|
420
|
-
let args;
|
|
421
|
-
|
|
422
|
-
switch (platform) {
|
|
423
|
-
case 'win32':
|
|
424
|
-
// Windows: cmd /c start "" /D path cmd,所有参数走数组传入,
|
|
425
|
-
// 不拼字符串 - 避免 directoryPath 含双引号 / 反斜杠被二次解释
|
|
426
|
-
command = 'cmd';
|
|
427
|
-
args = ['/c', 'start', '', '/D', directoryPath, 'cmd'];
|
|
428
|
-
break;
|
|
429
|
-
case 'darwin':
|
|
430
|
-
// macOS: 使用 Terminal.app
|
|
431
|
-
command = 'open';
|
|
432
|
-
args = ['-a', 'Terminal', directoryPath];
|
|
433
|
-
break;
|
|
434
|
-
case 'linux': {
|
|
435
|
-
// Linux: 尝试使用常见的终端模拟器 - argv 数组模式
|
|
436
|
-
// xterm 路径用 bash -c 跑 cd(且 cd path 已经 JSON.stringify 转义)
|
|
437
|
-
const terminals = [
|
|
438
|
-
{ cmd: 'gnome-terminal', args: ['--working-directory', directoryPath] },
|
|
439
|
-
{ cmd: 'konsole', args: ['--workdir', directoryPath] },
|
|
440
|
-
{ cmd: 'xterm', args: ['-e', 'bash', '-c', `cd ${JSON.stringify(directoryPath)} && exec $SHELL`] },
|
|
441
|
-
];
|
|
442
|
-
|
|
443
|
-
// 用 spawn 探测命令存在(不走 shell)
|
|
444
|
-
let terminalFound = null;
|
|
445
|
-
for (const terminal of terminals) {
|
|
446
|
-
try {
|
|
447
|
-
const probe = await new Promise((resolve) => {
|
|
448
|
-
const p = spawn('sh', ['-c', `command -v ${terminal.cmd}`], { stdio: 'ignore' });
|
|
449
|
-
p.on('exit', (code) => resolve(code === 0));
|
|
450
|
-
p.on('error', () => resolve(false));
|
|
451
|
-
});
|
|
452
|
-
if (probe) {
|
|
453
|
-
terminalFound = terminal;
|
|
454
|
-
break;
|
|
455
|
-
}
|
|
456
|
-
} catch (e) {
|
|
457
|
-
continue;
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
if (!terminalFound) {
|
|
462
|
-
return res.status(400).json({
|
|
463
|
-
success: false,
|
|
464
|
-
error: '未找到可用的终端模拟器'
|
|
465
|
-
});
|
|
466
|
-
}
|
|
467
|
-
command = terminalFound.cmd;
|
|
468
|
-
args = terminalFound.args;
|
|
469
|
-
break;
|
|
470
|
-
}
|
|
471
|
-
default:
|
|
472
|
-
return res.status(400).json({
|
|
473
|
-
success: false,
|
|
474
|
-
error: `不支持的操作系统: ${platform}`
|
|
475
|
-
});
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
// 执行命令打开终端
|
|
479
|
-
spawn(command, args, {
|
|
480
|
-
detached: true,
|
|
481
|
-
stdio: 'ignore'
|
|
482
|
-
}).unref();
|
|
483
|
-
|
|
484
|
-
res.json({
|
|
485
|
-
success: true,
|
|
486
|
-
message: '已在终端中打开目录'
|
|
487
|
-
});
|
|
488
|
-
} catch (error) {
|
|
489
|
-
res.status(400).json({
|
|
490
|
-
success: false,
|
|
491
|
-
error: `无法打开终端 "${directoryPath}": ${error.message}`
|
|
492
|
-
});
|
|
493
|
-
}
|
|
494
|
-
} catch (error) {
|
|
495
|
-
res.status(500).json({
|
|
496
|
-
success: false,
|
|
497
|
-
error: error.message
|
|
498
|
-
});
|
|
499
|
-
}
|
|
500
|
-
}));
|
|
501
|
-
|
|
502
|
-
// 新开 cmd 标签并在目标路径执行 g ui(SEC-INJ-4 修复)
|
|
503
|
-
app.post('/api/open-new-tab-gui', asyncRoute(async (req, res) => {
|
|
504
|
-
try {
|
|
505
|
-
const directoryPath = path.resolve(String(req.body.path || process.cwd()));
|
|
506
|
-
|
|
507
|
-
try {
|
|
508
|
-
await fs.access(directoryPath);
|
|
509
|
-
|
|
510
|
-
const platform = os.platform();
|
|
511
|
-
|
|
512
|
-
if (platform === 'win32') {
|
|
513
|
-
// Windows: 用 argv 数组启动 start "" /D path cmd /k g ui,
|
|
514
|
-
// directoryPath 通过数组传入 - Node 自动按 Windows CreateProcess
|
|
515
|
-
// 要求转义,无需手写 replace
|
|
516
|
-
spawn('cmd', ['/c', 'start', '', '/D', directoryPath, 'cmd', '/k', 'g', 'ui'], {
|
|
517
|
-
detached: true,
|
|
518
|
-
stdio: 'ignore',
|
|
519
|
-
windowsHide: false,
|
|
520
|
-
}).unref();
|
|
521
|
-
} else if (platform === 'darwin') {
|
|
522
|
-
spawn('open', ['-a', 'Terminal', directoryPath], {
|
|
523
|
-
detached: true,
|
|
524
|
-
stdio: 'ignore'
|
|
525
|
-
}).unref();
|
|
526
|
-
} else {
|
|
527
|
-
// Linux fallback: gnome-terminal 直接接 --working-directory,
|
|
528
|
-
// 内部命令(g ui; exec bash)作为 bash 的独立 argv 元素
|
|
529
|
-
spawn('gnome-terminal', [
|
|
530
|
-
'--working-directory', directoryPath,
|
|
531
|
-
'--', 'bash', '-c', 'g ui; exec bash',
|
|
532
|
-
], {
|
|
533
|
-
detached: true,
|
|
534
|
-
stdio: 'ignore'
|
|
535
|
-
}).unref();
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
res.json({ success: true });
|
|
539
|
-
} catch (error) {
|
|
540
|
-
res.status(400).json({ success: false, error: `无法打开: ${'$'}{error.message}` });
|
|
541
|
-
}
|
|
542
|
-
} catch (error) {
|
|
543
|
-
res.status(500).json({ success: false, error: error.message });
|
|
544
|
-
}
|
|
545
|
-
}));
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
// ========== 文件锁定相关 API ==========
|
|
549
|
-
|
|
550
|
-
// 获取锁定文件列表
|
|
551
|
-
app.get('/api/locked-files', asyncRoute(async (req, res) => {
|
|
552
|
-
try {
|
|
553
|
-
const lockedFiles = await configManager.getLockedFiles();
|
|
554
|
-
res.json({ success: true, lockedFiles });
|
|
555
|
-
} catch (error) {
|
|
556
|
-
res.status(500).json({ success: false, error: error.message });
|
|
557
|
-
}
|
|
558
|
-
}));
|
|
559
|
-
|
|
560
|
-
// 锁定文件
|
|
561
|
-
app.post('/api/lock-file', asyncRoute(async (req, res) => {
|
|
562
|
-
try {
|
|
563
|
-
const { filePath } = req.body;
|
|
564
|
-
if (!filePath) {
|
|
565
|
-
throw new HttpError(400, '缺少文件路径参数');
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
const result = await configManager.lockFile(filePath);
|
|
569
|
-
res.json({ success: true, locked: result });
|
|
570
|
-
} catch (error) {
|
|
571
|
-
res.status(500).json({ success: false, error: error.message });
|
|
572
|
-
}
|
|
573
|
-
}));
|
|
574
|
-
|
|
575
|
-
// 解锁文件
|
|
576
|
-
app.post('/api/unlock-file', asyncRoute(async (req, res) => {
|
|
577
|
-
try {
|
|
578
|
-
const { filePath } = req.body;
|
|
579
|
-
if (!filePath) {
|
|
580
|
-
throw new HttpError(400, '缺少文件路径参数');
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
const result = await configManager.unlockFile(filePath);
|
|
584
|
-
res.json({ success: true, unlocked: result });
|
|
585
|
-
} catch (error) {
|
|
586
|
-
res.status(500).json({ success: false, error: error.message });
|
|
587
|
-
}
|
|
588
|
-
}));
|
|
589
|
-
|
|
590
|
-
// 检查文件是否锁定
|
|
591
|
-
app.post('/api/check-file-lock', asyncRoute(async (req, res) => {
|
|
592
|
-
try {
|
|
593
|
-
const { filePath } = req.body;
|
|
594
|
-
if (!filePath) {
|
|
595
|
-
throw new HttpError(400, '缺少文件路径参数');
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
const isLocked = await configManager.isFileLocked(filePath);
|
|
599
|
-
res.json({ success: true, isLocked });
|
|
600
|
-
} catch (error) {
|
|
601
|
-
res.status(500).json({ success: false, error: error.message });
|
|
602
|
-
}
|
|
603
|
-
}));
|
|
604
|
-
|
|
605
|
-
// ── 编辑器:读取文件内容 ────────────────────────────────────────
|
|
606
|
-
app.get('/api/editor/file', asyncRoute(async (req, res) => {
|
|
607
|
-
try {
|
|
608
|
-
const filePath = req.query.path;
|
|
609
|
-
if (!filePath) throw new HttpError(400, '缺少 path 参数');
|
|
610
|
-
const safe = await safePathInProject(filePath);
|
|
611
|
-
if (!safe) throw new HttpError(403, '禁止访问工作目录以外的文件');
|
|
612
|
-
const resolved = safe.safePath;
|
|
613
|
-
const stat = await fs.stat(resolved);
|
|
614
|
-
if (!stat.isFile()) throw new HttpError(400, '目标不是文件');
|
|
615
|
-
// 超过 2MB 不读取
|
|
616
|
-
if (stat.size > 2 * 1024 * 1024) {
|
|
617
|
-
return res.json({ success: false, error: '文件过大(> 2 MB),暂不支持在线编辑' });
|
|
618
|
-
}
|
|
619
|
-
const content = await fs.readFile(resolved, 'utf-8');
|
|
620
|
-
res.json({ success: true, content });
|
|
621
|
-
} catch (error) {
|
|
622
|
-
res.status(500).json({ success: false, error: error.message });
|
|
623
|
-
}
|
|
624
|
-
}));
|
|
625
|
-
|
|
626
|
-
// ── 编辑器:读取原始文件(用于图片等二进制文件预览)────────────────────
|
|
627
|
-
app.get('/api/editor/raw', asyncRoute(async (req, res) => {
|
|
628
|
-
try {
|
|
629
|
-
const filePath = req.query.path;
|
|
630
|
-
if (!filePath) return res.status(400).end();
|
|
631
|
-
const safe = await safePathInProject(filePath);
|
|
632
|
-
if (!safe) return res.status(403).end();
|
|
633
|
-
const resolved = safe.safePath;
|
|
634
|
-
const stat = await fs.stat(resolved);
|
|
635
|
-
if (!stat.isFile()) return res.status(400).end();
|
|
636
|
-
if (stat.size > 20 * 1024 * 1024) return res.status(413).end();
|
|
637
|
-
const ext = path.extname(resolved).toLowerCase().slice(1);
|
|
638
|
-
const mimeMap = {
|
|
639
|
-
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
|
|
640
|
-
gif: 'image/gif', webp: 'image/webp', ico: 'image/x-icon',
|
|
641
|
-
svg: 'image/svg+xml', bmp: 'image/bmp', tiff: 'image/tiff',
|
|
642
|
-
};
|
|
643
|
-
const mime = mimeMap[ext] || 'application/octet-stream';
|
|
644
|
-
const content = await fs.readFile(resolved);
|
|
645
|
-
res.setHeader('Content-Type', mime);
|
|
646
|
-
res.setHeader('Cache-Control', 'no-store');
|
|
647
|
-
res.send(content);
|
|
648
|
-
} catch (error) {
|
|
649
|
-
res.status(500).end();
|
|
650
|
-
}
|
|
651
|
-
}));
|
|
652
|
-
|
|
653
|
-
// ── 编辑器:写入文件内容 ────────────────────────────────────────
|
|
654
|
-
app.put('/api/editor/file', express.json(), async (req, res) => {
|
|
655
|
-
try {
|
|
656
|
-
const { path: filePath, content } = req.body;
|
|
657
|
-
if (!filePath || content === undefined) {
|
|
658
|
-
throw new HttpError(400, '缺少 path 或 content 参数');
|
|
659
|
-
}
|
|
660
|
-
const safe = await safePathInProject(filePath);
|
|
661
|
-
if (!safe) throw new HttpError(403, '禁止写入工作目录以外的文件');
|
|
662
|
-
await fs.writeFile(safe.safePath, content, 'utf-8');
|
|
663
|
-
res.json({ success: true });
|
|
664
|
-
} catch (error) {
|
|
665
|
-
res.status(500).json({ success: false, error: error.message });
|
|
666
|
-
}
|
|
667
|
-
});
|
|
668
|
-
|
|
669
|
-
// ── 编辑器:新建文件 ───────────────────────────────────────────
|
|
670
|
-
app.post('/api/editor/file', express.json(), async (req, res) => {
|
|
671
|
-
try {
|
|
672
|
-
const { path: filePath } = req.body;
|
|
673
|
-
if (!filePath) throw new HttpError(400, '缺少 path 参数');
|
|
674
|
-
const safe = await safePathInProject(filePath);
|
|
675
|
-
if (!safe) throw new HttpError(403, '禁止在工作目录以外创建文件');
|
|
676
|
-
const resolved = safe.safePath;
|
|
677
|
-
// 如果已存在则拒绝
|
|
678
|
-
try {
|
|
679
|
-
await fs.access(resolved);
|
|
680
|
-
throw new HttpError(409, '文件已存在');
|
|
681
|
-
} catch { /* 不存在,继续 */ }
|
|
682
|
-
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
683
|
-
await fs.writeFile(resolved, '', 'utf-8');
|
|
684
|
-
res.json({ success: true });
|
|
685
|
-
} catch (error) {
|
|
686
|
-
res.status(500).json({ success: false, error: error.message });
|
|
687
|
-
}
|
|
688
|
-
});
|
|
689
|
-
|
|
690
|
-
// ── 编辑器:新建文件夹 ─────────────────────────────────────────
|
|
691
|
-
app.post('/api/editor/directory', express.json(), async (req, res) => {
|
|
692
|
-
try {
|
|
693
|
-
const { path: dirPath } = req.body;
|
|
694
|
-
if (!dirPath) throw new HttpError(400, '缺少 path 参数');
|
|
695
|
-
const safe = await safePathInProject(dirPath);
|
|
696
|
-
if (!safe) throw new HttpError(403, '禁止在工作目录以外创建目录');
|
|
697
|
-
const resolved = safe.safePath;
|
|
698
|
-
try {
|
|
699
|
-
await fs.access(resolved);
|
|
700
|
-
throw new HttpError(409, '目录已存在');
|
|
701
|
-
} catch { /* 不存在,继续 */ }
|
|
702
|
-
await fs.mkdir(resolved, { recursive: true });
|
|
703
|
-
res.json({ success: true });
|
|
704
|
-
} catch (error) {
|
|
705
|
-
res.status(500).json({ success: false, error: error.message });
|
|
706
|
-
}
|
|
707
|
-
});
|
|
708
|
-
|
|
709
|
-
// ── 编辑器:删除文件或文件夹 ───────────────────────────────────
|
|
710
|
-
app.delete('/api/editor/entry', express.json(), async (req, res) => {
|
|
711
|
-
try {
|
|
712
|
-
const filePath = req.query.path;
|
|
713
|
-
if (!filePath) throw new HttpError(400, '缺少 path 参数');
|
|
714
|
-
const safe = await safePathInProject(filePath);
|
|
715
|
-
if (!safe) throw new HttpError(403, '禁止删除工作目录以外的内容');
|
|
716
|
-
const resolved = safe.safePath;
|
|
717
|
-
const stat = await fs.stat(resolved);
|
|
718
|
-
if (stat.isDirectory()) {
|
|
719
|
-
await fs.rm(resolved, { recursive: true, force: true });
|
|
720
|
-
} else {
|
|
721
|
-
await fs.unlink(resolved);
|
|
722
|
-
}
|
|
723
|
-
res.json({ success: true });
|
|
724
|
-
} catch (error) {
|
|
725
|
-
res.status(500).json({ success: false, error: error.message });
|
|
726
|
-
}
|
|
727
|
-
});
|
|
728
|
-
|
|
729
|
-
// ── 编辑器:重命名文件或文件夹 ────────────────────────────────
|
|
730
|
-
app.put('/api/editor/rename', express.json(), async (req, res) => {
|
|
731
|
-
try {
|
|
732
|
-
const { oldPath, newPath } = req.body;
|
|
733
|
-
if (!oldPath || !newPath) throw new HttpError(400, '缺少参数');
|
|
734
|
-
const safeOld = await safePathInProject(oldPath)
|
|
735
|
-
const safeNew = await safePathInProject(newPath)
|
|
736
|
-
if (!safeOld || !safeNew) throw new HttpError(403, '禁止操作工作目录以外的内容');
|
|
737
|
-
try {
|
|
738
|
-
await fs.access(safeNew.safePath);
|
|
739
|
-
throw new HttpError(409, '目标名称已存在');
|
|
740
|
-
} catch { /* 不存在,继续 */ }
|
|
741
|
-
await fs.rename(safeOld.safePath, safeNew.safePath);
|
|
742
|
-
res.json({ success: true });
|
|
743
|
-
} catch (error) {
|
|
744
|
-
res.status(500).json({ success: false, error: error.message });
|
|
745
|
-
}
|
|
746
|
-
});
|
|
747
|
-
|
|
748
|
-
// ── 编辑器:在系统文件管理器中打开 / 选中 ───────────────────
|
|
749
|
-
// 行为:
|
|
750
|
-
// - 目录:直接用系统文件管理器打开
|
|
751
|
-
// - 文件:Windows / macOS 上在文件管理器中选中该文件;Linux 无标准接口,退化为打开所在目录
|
|
752
|
-
app.post('/api/editor/reveal', express.json(), async (req, res) => {
|
|
753
|
-
try {
|
|
754
|
-
const targetPath = req.body?.path;
|
|
755
|
-
if (!targetPath) throw new HttpError(400, '缺少 path 参数');
|
|
756
|
-
|
|
757
|
-
const safe = await safePathInProject(targetPath);
|
|
758
|
-
if (!safe) throw new HttpError(403, '禁止访问工作目录以外的内容');
|
|
759
|
-
const resolved = safe.safePath;
|
|
760
|
-
|
|
761
|
-
try {
|
|
762
|
-
await fs.access(resolved);
|
|
763
|
-
} catch {
|
|
764
|
-
throw new HttpError(404, '目标不存在');
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
const stat = await fs.stat(resolved);
|
|
768
|
-
const isDirectory = stat.isDirectory();
|
|
769
|
-
|
|
770
|
-
const platform = process.platform;
|
|
771
|
-
if (isDirectory) {
|
|
772
|
-
await open(resolved, { wait: false });
|
|
773
|
-
} else if (platform === 'win32') {
|
|
774
|
-
// Windows: explorer.exe /select,<path>(逗号分隔,无双引号)
|
|
775
|
-
// resolved 已通过 safePathInProject 校验,argv 数组传入防二次解释
|
|
776
|
-
spawn('explorer.exe', ['/select,', resolved], { detached: true, stdio: 'ignore' }).unref();
|
|
777
|
-
} else if (platform === 'darwin') {
|
|
778
|
-
// macOS: open -R "<path>" 在 Finder 中选中
|
|
779
|
-
spawn('open', ['-R', resolved], { detached: true, stdio: 'ignore' }).unref();
|
|
780
|
-
} else {
|
|
781
|
-
// Linux / 其它: 没有统一标准,打开所在目录
|
|
782
|
-
await open(path.dirname(resolved), { wait: false });
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
res.json({ success: true });
|
|
786
|
-
} catch (error) {
|
|
787
|
-
res.status(500).json({ success: false, error: error.message });
|
|
788
|
-
}
|
|
789
|
-
});
|
|
790
|
-
}
|
|
791
|
-
|
|
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 express from 'express';
|
|
16
|
+
import logger from '../utils/logger.js'
|
|
17
|
+
import chalk from 'chalk';
|
|
18
|
+
import fs from 'fs/promises';
|
|
19
|
+
import path from 'path';
|
|
20
|
+
import open from 'open';
|
|
21
|
+
import os from 'os';
|
|
22
|
+
import { spawn, exec } from 'child_process';
|
|
23
|
+
import { ensureWithinCwd } from '../utils/pathGuard.js';
|
|
24
|
+
import { asyncRoute, HttpError } from '../utils/asyncRoute.js';
|
|
25
|
+
import { invalidateCurrentProjectKey, invalidateRawConfigCache } from '../../../config.js';
|
|
26
|
+
import { invalidateCwdCache } from '../../../utils/index.js';
|
|
27
|
+
|
|
28
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
// /api/change_directory 路径白名单(SEC-PATH-2)
|
|
30
|
+
//
|
|
31
|
+
// 防止 chdir 到任意绝对路径(尤其 / 或 C:\\)后,下游 fs / git 操作都
|
|
32
|
+
// 跟着跨出项目。
|
|
33
|
+
// 规则:
|
|
34
|
+
// 1. 必须是字符串
|
|
35
|
+
// 2. 必须是绝对路径或相对 process.cwd() 能解析的
|
|
36
|
+
// 3. 解析后必须是已存在的目录
|
|
37
|
+
// 4. 不能包含 NUL / 控制字符 / 路径穿越 token("..")越过祖先之外
|
|
38
|
+
//
|
|
39
|
+
// 注意:即便合法,也最好在 chdir 后立即 invalidateCurrentProjectKey 与
|
|
40
|
+
// config 缓存,详见 fs.js 后续代码。
|
|
41
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
42
|
+
const FORBIDDEN_PATH_PATTERNS = /[\x00-\x1f]/
|
|
43
|
+
|
|
44
|
+
function validateChangeDirectoryPath(input) {
|
|
45
|
+
if (!input || typeof input !== 'string') {
|
|
46
|
+
return { ok: false, error: '目录路径必须是非空字符串' };
|
|
47
|
+
}
|
|
48
|
+
const trimmed = input.trim();
|
|
49
|
+
if (!trimmed) {
|
|
50
|
+
return { ok: false, error: '目录路径不能为空' };
|
|
51
|
+
}
|
|
52
|
+
if (FORBIDDEN_PATH_PATTERNS.test(trimmed)) {
|
|
53
|
+
return { ok: false, error: '目录路径包含非法控制字符' };
|
|
54
|
+
}
|
|
55
|
+
// 不允许长度过长的路径(防止 DoS / 解析爆炸)
|
|
56
|
+
if (trimmed.length > 4096) {
|
|
57
|
+
return { ok: false, error: '目录路径过长' };
|
|
58
|
+
}
|
|
59
|
+
const resolved = path.resolve(trimmed);
|
|
60
|
+
// 阻止 chdir 到根目录之外但仍是合理目录的边界场景:
|
|
61
|
+
// 这里采用宽松策略 — 只要 resolved 是已存在的目录即可(无法 100% 阻止
|
|
62
|
+
// chdir 到任意位置,这是产品要求:GUI 内的"切换项目"功能需要允许切到
|
|
63
|
+
// 任意用户目录)。安全护栏放在下游 fs/git 操作上(safePathInProject)。
|
|
64
|
+
return { ok: true, resolved };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 把 absolute path 准备好传给 spawn/exec,内部不再做字符串拼接,
|
|
68
|
+
// Windows 走 cmd /c start "" ... argv 模式;Linux 走 sh -c 但 argument 已被
|
|
69
|
+
// shQuote.js 转义
|
|
70
|
+
import { shQuote, psEscape } from '../utils/shellQuote.js';
|
|
71
|
+
|
|
72
|
+
export function registerFsRoutes({
|
|
73
|
+
app,
|
|
74
|
+
execGitCommand,
|
|
75
|
+
configManager,
|
|
76
|
+
io,
|
|
77
|
+
getCurrentProjectPath,
|
|
78
|
+
setCurrentProjectPath,
|
|
79
|
+
getProjectRoomId,
|
|
80
|
+
setProjectRoomId,
|
|
81
|
+
setIsGitRepo
|
|
82
|
+
}) {
|
|
83
|
+
// ── 解析并校验 user 输入路径在当前项目 cwd 内(防 ../ 父目录逃逸、startsWith 假阳性、Windows 大小写)──
|
|
84
|
+
const safePathInProject = async (userPath) => {
|
|
85
|
+
const cwd = getCurrentProjectPath() || process.cwd()
|
|
86
|
+
return ensureWithinCwd(userPath, cwd)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 新增获取当前工作目录接口
|
|
90
|
+
app.get('/api/current_directory', asyncRoute(async (req, res) => {
|
|
91
|
+
try {
|
|
92
|
+
const directory = process.cwd();
|
|
93
|
+
|
|
94
|
+
// 检查当前目录是否是Git仓库
|
|
95
|
+
try {
|
|
96
|
+
await execGitCommand(['rev-parse', '--is-inside-work-tree']);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
return res.status(400).json({
|
|
99
|
+
error: '当前目录不是一个Git仓库',
|
|
100
|
+
directory,
|
|
101
|
+
isGitRepo: false
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
res.json({
|
|
106
|
+
directory,
|
|
107
|
+
isGitRepo: true
|
|
108
|
+
});
|
|
109
|
+
} catch (error) {
|
|
110
|
+
res.status(500).json({ error: error.message });
|
|
111
|
+
}
|
|
112
|
+
}));
|
|
113
|
+
|
|
114
|
+
// 新增切换工作目录接口
|
|
115
|
+
app.post('/api/change_directory', asyncRoute(async (req, res) => {
|
|
116
|
+
try {
|
|
117
|
+
const { path: reqPath } = req.body;
|
|
118
|
+
|
|
119
|
+
if (!reqPath) {
|
|
120
|
+
throw new HttpError(400, '目录路径不能为空');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// SEC-PATH-2 防御:路径白名单 + 长度限制 + 控制字符拦截
|
|
124
|
+
const validation = validateChangeDirectoryPath(reqPath);
|
|
125
|
+
if (!validation.ok) {
|
|
126
|
+
throw new HttpError(400, validation.error);
|
|
127
|
+
}
|
|
128
|
+
const safeReqPath = validation.resolved;
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
// 解析后再次确认是已存在的目录,防止 race 期间目录被删
|
|
132
|
+
try {
|
|
133
|
+
const stat = await fs.stat(safeReqPath);
|
|
134
|
+
if (!stat.isDirectory()) {
|
|
135
|
+
throw new HttpError(400, '目标路径不是目录');
|
|
136
|
+
}
|
|
137
|
+
} catch (e) {
|
|
138
|
+
if (e instanceof HttpError) throw e;
|
|
139
|
+
throw new HttpError(400, `目标目录不存在: ${safeReqPath}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
process.chdir(safeReqPath);
|
|
143
|
+
// chdir 后立即失效项目键 + 原始 config 缓存,防止下一次 loadConfig /
|
|
144
|
+
// saveConfig 还命中旧 cwd 容器写到错误项目下。
|
|
145
|
+
// 同时清掉 utils/index.js 里 getCwd() 的 cwd 缓存,避免后续 execGitCommand
|
|
146
|
+
// 仍按 process.argv / 启动期 cwd 跑子命令,导致切到非 git 目录后
|
|
147
|
+
// /api/current_directory 等接口的 rev-parse 误判为 git 仓库。
|
|
148
|
+
invalidateCurrentProjectKey();
|
|
149
|
+
invalidateRawConfigCache();
|
|
150
|
+
invalidateCwdCache();
|
|
151
|
+
const newDirectory = process.cwd();
|
|
152
|
+
|
|
153
|
+
// 更新当前项目路径和房间ID
|
|
154
|
+
const oldProjectPath = getCurrentProjectPath();
|
|
155
|
+
const newProjectPath = newDirectory;
|
|
156
|
+
const newProjectRoomId = `project:${newProjectPath.replace(/[\\/:*?"<>|\s]/g, '_')}`;
|
|
157
|
+
|
|
158
|
+
console.log(chalk.yellow(`项目路径切换: ${oldProjectPath} -> ${newProjectPath}`));
|
|
159
|
+
console.log(chalk.yellow(`房间ID更新: ${getProjectRoomId()} -> ${newProjectRoomId}`));
|
|
160
|
+
|
|
161
|
+
// 检查新目录是否是Git仓库
|
|
162
|
+
try {
|
|
163
|
+
await execGitCommand(['rev-parse', '--is-inside-work-tree']);
|
|
164
|
+
|
|
165
|
+
// 更新全局变量
|
|
166
|
+
setCurrentProjectPath(newProjectPath);
|
|
167
|
+
setProjectRoomId(newProjectRoomId);
|
|
168
|
+
setIsGitRepo(true);
|
|
169
|
+
|
|
170
|
+
// 确保切换后立即初始化该项目的配置条目
|
|
171
|
+
try {
|
|
172
|
+
const currentCfg = await configManager.loadConfig();
|
|
173
|
+
await configManager.saveConfig(currentCfg);
|
|
174
|
+
// 将新目录加入最近目录
|
|
175
|
+
await configManager.saveRecentDirectory(newDirectory);
|
|
176
|
+
} catch (e) {
|
|
177
|
+
logger.warn('初始化项目配置失败:', e?.message || e);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 通知所有旧房间的客户端项目已切换
|
|
181
|
+
io.to(getProjectRoomId()).emit('project_changed', {
|
|
182
|
+
oldProjectPath: getCurrentProjectPath(),
|
|
183
|
+
newProjectPath: newProjectPath,
|
|
184
|
+
newProjectRoomId: newProjectRoomId
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
res.json({
|
|
188
|
+
success: true,
|
|
189
|
+
directory: newDirectory,
|
|
190
|
+
isGitRepo: true,
|
|
191
|
+
projectRoomId: newProjectRoomId
|
|
192
|
+
});
|
|
193
|
+
} catch (error) {
|
|
194
|
+
// 不是Git仓库,停止监控
|
|
195
|
+
|
|
196
|
+
// 更新全局变量
|
|
197
|
+
setCurrentProjectPath(newProjectPath);
|
|
198
|
+
setProjectRoomId(newProjectRoomId);
|
|
199
|
+
setIsGitRepo(false);
|
|
200
|
+
|
|
201
|
+
// 通知所有旧房间的客户端项目已切换
|
|
202
|
+
io.to(getProjectRoomId()).emit('project_changed', {
|
|
203
|
+
oldProjectPath: getCurrentProjectPath(),
|
|
204
|
+
newProjectPath: newProjectPath,
|
|
205
|
+
newProjectRoomId: newProjectRoomId
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// 即使不是Git仓库也初始化当前目录配置(使用CWD作为项目键)
|
|
209
|
+
try {
|
|
210
|
+
const currentCfg = await configManager.loadConfig();
|
|
211
|
+
await configManager.saveConfig(currentCfg);
|
|
212
|
+
// 将新目录加入最近目录
|
|
213
|
+
await configManager.saveRecentDirectory(newDirectory);
|
|
214
|
+
} catch (e) {
|
|
215
|
+
logger.warn('非Git目录初始化项目配置失败:', e?.message || e);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
res.json({
|
|
219
|
+
success: true,
|
|
220
|
+
directory: newDirectory,
|
|
221
|
+
isGitRepo: false,
|
|
222
|
+
warning: '新目录不是一个Git仓库',
|
|
223
|
+
projectRoomId: newProjectRoomId
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
} catch (error) {
|
|
227
|
+
if (error instanceof HttpError) {
|
|
228
|
+
return res.status(error.status).json({ success: false, error: error.message });
|
|
229
|
+
}
|
|
230
|
+
res.status(400).json({
|
|
231
|
+
success: false,
|
|
232
|
+
error: `切换到目录 "${safeReqPath}" 失败: ${error.message}`
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
} catch (error) {
|
|
236
|
+
res.status(500).json({ error: error.message });
|
|
237
|
+
}
|
|
238
|
+
}));
|
|
239
|
+
|
|
240
|
+
// 获取目录内容(用于浏览目录)
|
|
241
|
+
app.get('/api/browse_directory', asyncRoute(async (req, res) => {
|
|
242
|
+
try {
|
|
243
|
+
// 获取要浏览的目录路径,如果没有提供,则使用当前目录
|
|
244
|
+
const directoryPath = req.query.path || process.cwd();
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
// 读取目录内容
|
|
248
|
+
const items = await fs.readdir(directoryPath, { withFileTypes: true });
|
|
249
|
+
|
|
250
|
+
// 分离文件夹和文件
|
|
251
|
+
const directories = [];
|
|
252
|
+
const files = [];
|
|
253
|
+
|
|
254
|
+
for (const item of items) {
|
|
255
|
+
const fullPath = path.join(directoryPath, item.name);
|
|
256
|
+
if (item.isDirectory()) {
|
|
257
|
+
directories.push({
|
|
258
|
+
name: item.name,
|
|
259
|
+
path: fullPath,
|
|
260
|
+
type: 'directory'
|
|
261
|
+
});
|
|
262
|
+
} else if (item.isFile()) {
|
|
263
|
+
files.push({
|
|
264
|
+
name: item.name,
|
|
265
|
+
path: fullPath,
|
|
266
|
+
type: 'file'
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 优先显示目录,然后是文件,都按字母排序
|
|
272
|
+
directories.sort((a, b) => a.name.localeCompare(b.name));
|
|
273
|
+
files.sort((a, b) => a.name.localeCompare(b.name));
|
|
274
|
+
|
|
275
|
+
// 获取父目录路径
|
|
276
|
+
const parentPath = path.dirname(directoryPath);
|
|
277
|
+
|
|
278
|
+
res.json({
|
|
279
|
+
success: true,
|
|
280
|
+
currentPath: directoryPath,
|
|
281
|
+
parentPath: parentPath !== directoryPath ? parentPath : null,
|
|
282
|
+
items: [...directories, ...files]
|
|
283
|
+
});
|
|
284
|
+
} catch (error) {
|
|
285
|
+
res.status(400).json({
|
|
286
|
+
success: false,
|
|
287
|
+
error: `无法读取目录 "${directoryPath}": ${error.message}`
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
} catch (error) {
|
|
291
|
+
res.status(500).json({ error: error.message });
|
|
292
|
+
}
|
|
293
|
+
}));
|
|
294
|
+
|
|
295
|
+
// POST接口版本的浏览目录功能
|
|
296
|
+
app.post('/api/browse_directory', asyncRoute(async (req, res) => {
|
|
297
|
+
try {
|
|
298
|
+
// 获取要浏览的目录路径,如果没有提供,则使用当前目录
|
|
299
|
+
const directoryPath = req.body.currentPath || process.cwd();
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
// 读取目录内容
|
|
303
|
+
const items = await fs.readdir(directoryPath, { withFileTypes: true });
|
|
304
|
+
|
|
305
|
+
// 分离文件夹和文件
|
|
306
|
+
const directories = [];
|
|
307
|
+
|
|
308
|
+
for (const item of items) {
|
|
309
|
+
const fullPath = path.join(directoryPath, item.name);
|
|
310
|
+
if (item.isDirectory()) {
|
|
311
|
+
directories.push({
|
|
312
|
+
name: item.name,
|
|
313
|
+
path: fullPath,
|
|
314
|
+
type: 'directory'
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// 只返回目录,不返回文件
|
|
320
|
+
directories.sort((a, b) => a.name.localeCompare(b.name));
|
|
321
|
+
|
|
322
|
+
// 获取父目录路径
|
|
323
|
+
const parentPath = path.dirname(directoryPath);
|
|
324
|
+
|
|
325
|
+
// 返回选择的目录路径
|
|
326
|
+
res.json({
|
|
327
|
+
success: true,
|
|
328
|
+
path: directoryPath,
|
|
329
|
+
parentPath: parentPath !== directoryPath ? parentPath : null,
|
|
330
|
+
items: directories
|
|
331
|
+
});
|
|
332
|
+
} catch (error) {
|
|
333
|
+
res.status(400).json({
|
|
334
|
+
success: false,
|
|
335
|
+
error: `无法读取目录 "${directoryPath}": ${error.message}`
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
} catch (error) {
|
|
339
|
+
res.status(500).json({
|
|
340
|
+
success: false,
|
|
341
|
+
error: error.message
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}));
|
|
345
|
+
|
|
346
|
+
// 获取最近访问的目录列表
|
|
347
|
+
app.get('/api/recent_directories', asyncRoute(async (req, res) => {
|
|
348
|
+
try {
|
|
349
|
+
// 尝试从配置中获取最近的目录
|
|
350
|
+
const recentDirs = await configManager.getRecentDirectories();
|
|
351
|
+
// 并行检查每个目录是否存在
|
|
352
|
+
const checked = await Promise.all(
|
|
353
|
+
(recentDirs || []).map(async (dir) => {
|
|
354
|
+
let exists = false;
|
|
355
|
+
try {
|
|
356
|
+
await fs.access(dir);
|
|
357
|
+
exists = true;
|
|
358
|
+
} catch {
|
|
359
|
+
exists = false;
|
|
360
|
+
}
|
|
361
|
+
return { path: dir, exists };
|
|
362
|
+
})
|
|
363
|
+
);
|
|
364
|
+
res.json({
|
|
365
|
+
success: true,
|
|
366
|
+
directories: checked
|
|
367
|
+
});
|
|
368
|
+
} catch (error) {
|
|
369
|
+
res.status(500).json({
|
|
370
|
+
success: false,
|
|
371
|
+
error: error.message
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
}));
|
|
375
|
+
|
|
376
|
+
// 在资源管理器/访达中打开当前目录
|
|
377
|
+
app.post('/api/open_directory', asyncRoute(async (req, res) => {
|
|
378
|
+
try {
|
|
379
|
+
// 获取要打开的目录路径,如果没有提供,则使用当前目录
|
|
380
|
+
const directoryPath = req.body.path || process.cwd();
|
|
381
|
+
|
|
382
|
+
try {
|
|
383
|
+
// 检查目录是否存在
|
|
384
|
+
await fs.access(directoryPath);
|
|
385
|
+
|
|
386
|
+
// 使用open模块打开目录,自动处理不同操作系统
|
|
387
|
+
await open(directoryPath, { wait: false });
|
|
388
|
+
|
|
389
|
+
res.json({
|
|
390
|
+
success: true,
|
|
391
|
+
message: '已在文件管理器中打开目录'
|
|
392
|
+
});
|
|
393
|
+
} catch (error) {
|
|
394
|
+
res.status(400).json({
|
|
395
|
+
success: false,
|
|
396
|
+
error: `无法打开目录 "${directoryPath}": ${error.message}`
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
} catch (error) {
|
|
400
|
+
res.status(500).json({
|
|
401
|
+
success: false,
|
|
402
|
+
error: error.message
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}));
|
|
406
|
+
|
|
407
|
+
// 在终端中打开当前目录
|
|
408
|
+
app.post('/api/open_terminal', asyncRoute(async (req, res) => {
|
|
409
|
+
try {
|
|
410
|
+
// SEC-INJ-4 防御:先 path.resolve 规范化,后续所有 spawn 走 argv 数组
|
|
411
|
+
const directoryPath = path.resolve(String(req.body.path || process.cwd()));
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
// 检查目录是否存在
|
|
415
|
+
await fs.access(directoryPath);
|
|
416
|
+
|
|
417
|
+
// 根据不同操作系统打开终端
|
|
418
|
+
const platform = os.platform();
|
|
419
|
+
let command;
|
|
420
|
+
let args;
|
|
421
|
+
|
|
422
|
+
switch (platform) {
|
|
423
|
+
case 'win32':
|
|
424
|
+
// Windows: cmd /c start "" /D path cmd,所有参数走数组传入,
|
|
425
|
+
// 不拼字符串 - 避免 directoryPath 含双引号 / 反斜杠被二次解释
|
|
426
|
+
command = 'cmd';
|
|
427
|
+
args = ['/c', 'start', '', '/D', directoryPath, 'cmd'];
|
|
428
|
+
break;
|
|
429
|
+
case 'darwin':
|
|
430
|
+
// macOS: 使用 Terminal.app
|
|
431
|
+
command = 'open';
|
|
432
|
+
args = ['-a', 'Terminal', directoryPath];
|
|
433
|
+
break;
|
|
434
|
+
case 'linux': {
|
|
435
|
+
// Linux: 尝试使用常见的终端模拟器 - argv 数组模式
|
|
436
|
+
// xterm 路径用 bash -c 跑 cd(且 cd path 已经 JSON.stringify 转义)
|
|
437
|
+
const terminals = [
|
|
438
|
+
{ cmd: 'gnome-terminal', args: ['--working-directory', directoryPath] },
|
|
439
|
+
{ cmd: 'konsole', args: ['--workdir', directoryPath] },
|
|
440
|
+
{ cmd: 'xterm', args: ['-e', 'bash', '-c', `cd ${JSON.stringify(directoryPath)} && exec $SHELL`] },
|
|
441
|
+
];
|
|
442
|
+
|
|
443
|
+
// 用 spawn 探测命令存在(不走 shell)
|
|
444
|
+
let terminalFound = null;
|
|
445
|
+
for (const terminal of terminals) {
|
|
446
|
+
try {
|
|
447
|
+
const probe = await new Promise((resolve) => {
|
|
448
|
+
const p = spawn('sh', ['-c', `command -v ${terminal.cmd}`], { stdio: 'ignore' });
|
|
449
|
+
p.on('exit', (code) => resolve(code === 0));
|
|
450
|
+
p.on('error', () => resolve(false));
|
|
451
|
+
});
|
|
452
|
+
if (probe) {
|
|
453
|
+
terminalFound = terminal;
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
} catch (e) {
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (!terminalFound) {
|
|
462
|
+
return res.status(400).json({
|
|
463
|
+
success: false,
|
|
464
|
+
error: '未找到可用的终端模拟器'
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
command = terminalFound.cmd;
|
|
468
|
+
args = terminalFound.args;
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
default:
|
|
472
|
+
return res.status(400).json({
|
|
473
|
+
success: false,
|
|
474
|
+
error: `不支持的操作系统: ${platform}`
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// 执行命令打开终端
|
|
479
|
+
spawn(command, args, {
|
|
480
|
+
detached: true,
|
|
481
|
+
stdio: 'ignore'
|
|
482
|
+
}).unref();
|
|
483
|
+
|
|
484
|
+
res.json({
|
|
485
|
+
success: true,
|
|
486
|
+
message: '已在终端中打开目录'
|
|
487
|
+
});
|
|
488
|
+
} catch (error) {
|
|
489
|
+
res.status(400).json({
|
|
490
|
+
success: false,
|
|
491
|
+
error: `无法打开终端 "${directoryPath}": ${error.message}`
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
} catch (error) {
|
|
495
|
+
res.status(500).json({
|
|
496
|
+
success: false,
|
|
497
|
+
error: error.message
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
}));
|
|
501
|
+
|
|
502
|
+
// 新开 cmd 标签并在目标路径执行 g ui(SEC-INJ-4 修复)
|
|
503
|
+
app.post('/api/open-new-tab-gui', asyncRoute(async (req, res) => {
|
|
504
|
+
try {
|
|
505
|
+
const directoryPath = path.resolve(String(req.body.path || process.cwd()));
|
|
506
|
+
|
|
507
|
+
try {
|
|
508
|
+
await fs.access(directoryPath);
|
|
509
|
+
|
|
510
|
+
const platform = os.platform();
|
|
511
|
+
|
|
512
|
+
if (platform === 'win32') {
|
|
513
|
+
// Windows: 用 argv 数组启动 start "" /D path cmd /k g ui,
|
|
514
|
+
// directoryPath 通过数组传入 - Node 自动按 Windows CreateProcess
|
|
515
|
+
// 要求转义,无需手写 replace
|
|
516
|
+
spawn('cmd', ['/c', 'start', '', '/D', directoryPath, 'cmd', '/k', 'g', 'ui'], {
|
|
517
|
+
detached: true,
|
|
518
|
+
stdio: 'ignore',
|
|
519
|
+
windowsHide: false,
|
|
520
|
+
}).unref();
|
|
521
|
+
} else if (platform === 'darwin') {
|
|
522
|
+
spawn('open', ['-a', 'Terminal', directoryPath], {
|
|
523
|
+
detached: true,
|
|
524
|
+
stdio: 'ignore'
|
|
525
|
+
}).unref();
|
|
526
|
+
} else {
|
|
527
|
+
// Linux fallback: gnome-terminal 直接接 --working-directory,
|
|
528
|
+
// 内部命令(g ui; exec bash)作为 bash 的独立 argv 元素
|
|
529
|
+
spawn('gnome-terminal', [
|
|
530
|
+
'--working-directory', directoryPath,
|
|
531
|
+
'--', 'bash', '-c', 'g ui; exec bash',
|
|
532
|
+
], {
|
|
533
|
+
detached: true,
|
|
534
|
+
stdio: 'ignore'
|
|
535
|
+
}).unref();
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
res.json({ success: true });
|
|
539
|
+
} catch (error) {
|
|
540
|
+
res.status(400).json({ success: false, error: `无法打开: ${'$'}{error.message}` });
|
|
541
|
+
}
|
|
542
|
+
} catch (error) {
|
|
543
|
+
res.status(500).json({ success: false, error: error.message });
|
|
544
|
+
}
|
|
545
|
+
}));
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
// ========== 文件锁定相关 API ==========
|
|
549
|
+
|
|
550
|
+
// 获取锁定文件列表
|
|
551
|
+
app.get('/api/locked-files', asyncRoute(async (req, res) => {
|
|
552
|
+
try {
|
|
553
|
+
const lockedFiles = await configManager.getLockedFiles();
|
|
554
|
+
res.json({ success: true, lockedFiles });
|
|
555
|
+
} catch (error) {
|
|
556
|
+
res.status(500).json({ success: false, error: error.message });
|
|
557
|
+
}
|
|
558
|
+
}));
|
|
559
|
+
|
|
560
|
+
// 锁定文件
|
|
561
|
+
app.post('/api/lock-file', asyncRoute(async (req, res) => {
|
|
562
|
+
try {
|
|
563
|
+
const { filePath } = req.body;
|
|
564
|
+
if (!filePath) {
|
|
565
|
+
throw new HttpError(400, '缺少文件路径参数');
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const result = await configManager.lockFile(filePath);
|
|
569
|
+
res.json({ success: true, locked: result });
|
|
570
|
+
} catch (error) {
|
|
571
|
+
res.status(500).json({ success: false, error: error.message });
|
|
572
|
+
}
|
|
573
|
+
}));
|
|
574
|
+
|
|
575
|
+
// 解锁文件
|
|
576
|
+
app.post('/api/unlock-file', asyncRoute(async (req, res) => {
|
|
577
|
+
try {
|
|
578
|
+
const { filePath } = req.body;
|
|
579
|
+
if (!filePath) {
|
|
580
|
+
throw new HttpError(400, '缺少文件路径参数');
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const result = await configManager.unlockFile(filePath);
|
|
584
|
+
res.json({ success: true, unlocked: result });
|
|
585
|
+
} catch (error) {
|
|
586
|
+
res.status(500).json({ success: false, error: error.message });
|
|
587
|
+
}
|
|
588
|
+
}));
|
|
589
|
+
|
|
590
|
+
// 检查文件是否锁定
|
|
591
|
+
app.post('/api/check-file-lock', asyncRoute(async (req, res) => {
|
|
592
|
+
try {
|
|
593
|
+
const { filePath } = req.body;
|
|
594
|
+
if (!filePath) {
|
|
595
|
+
throw new HttpError(400, '缺少文件路径参数');
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const isLocked = await configManager.isFileLocked(filePath);
|
|
599
|
+
res.json({ success: true, isLocked });
|
|
600
|
+
} catch (error) {
|
|
601
|
+
res.status(500).json({ success: false, error: error.message });
|
|
602
|
+
}
|
|
603
|
+
}));
|
|
604
|
+
|
|
605
|
+
// ── 编辑器:读取文件内容 ────────────────────────────────────────
|
|
606
|
+
app.get('/api/editor/file', asyncRoute(async (req, res) => {
|
|
607
|
+
try {
|
|
608
|
+
const filePath = req.query.path;
|
|
609
|
+
if (!filePath) throw new HttpError(400, '缺少 path 参数');
|
|
610
|
+
const safe = await safePathInProject(filePath);
|
|
611
|
+
if (!safe) throw new HttpError(403, '禁止访问工作目录以外的文件');
|
|
612
|
+
const resolved = safe.safePath;
|
|
613
|
+
const stat = await fs.stat(resolved);
|
|
614
|
+
if (!stat.isFile()) throw new HttpError(400, '目标不是文件');
|
|
615
|
+
// 超过 2MB 不读取
|
|
616
|
+
if (stat.size > 2 * 1024 * 1024) {
|
|
617
|
+
return res.json({ success: false, error: '文件过大(> 2 MB),暂不支持在线编辑' });
|
|
618
|
+
}
|
|
619
|
+
const content = await fs.readFile(resolved, 'utf-8');
|
|
620
|
+
res.json({ success: true, content });
|
|
621
|
+
} catch (error) {
|
|
622
|
+
res.status(500).json({ success: false, error: error.message });
|
|
623
|
+
}
|
|
624
|
+
}));
|
|
625
|
+
|
|
626
|
+
// ── 编辑器:读取原始文件(用于图片等二进制文件预览)────────────────────
|
|
627
|
+
app.get('/api/editor/raw', asyncRoute(async (req, res) => {
|
|
628
|
+
try {
|
|
629
|
+
const filePath = req.query.path;
|
|
630
|
+
if (!filePath) return res.status(400).end();
|
|
631
|
+
const safe = await safePathInProject(filePath);
|
|
632
|
+
if (!safe) return res.status(403).end();
|
|
633
|
+
const resolved = safe.safePath;
|
|
634
|
+
const stat = await fs.stat(resolved);
|
|
635
|
+
if (!stat.isFile()) return res.status(400).end();
|
|
636
|
+
if (stat.size > 20 * 1024 * 1024) return res.status(413).end();
|
|
637
|
+
const ext = path.extname(resolved).toLowerCase().slice(1);
|
|
638
|
+
const mimeMap = {
|
|
639
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
|
|
640
|
+
gif: 'image/gif', webp: 'image/webp', ico: 'image/x-icon',
|
|
641
|
+
svg: 'image/svg+xml', bmp: 'image/bmp', tiff: 'image/tiff',
|
|
642
|
+
};
|
|
643
|
+
const mime = mimeMap[ext] || 'application/octet-stream';
|
|
644
|
+
const content = await fs.readFile(resolved);
|
|
645
|
+
res.setHeader('Content-Type', mime);
|
|
646
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
647
|
+
res.send(content);
|
|
648
|
+
} catch (error) {
|
|
649
|
+
res.status(500).end();
|
|
650
|
+
}
|
|
651
|
+
}));
|
|
652
|
+
|
|
653
|
+
// ── 编辑器:写入文件内容 ────────────────────────────────────────
|
|
654
|
+
app.put('/api/editor/file', express.json(), async (req, res) => {
|
|
655
|
+
try {
|
|
656
|
+
const { path: filePath, content } = req.body;
|
|
657
|
+
if (!filePath || content === undefined) {
|
|
658
|
+
throw new HttpError(400, '缺少 path 或 content 参数');
|
|
659
|
+
}
|
|
660
|
+
const safe = await safePathInProject(filePath);
|
|
661
|
+
if (!safe) throw new HttpError(403, '禁止写入工作目录以外的文件');
|
|
662
|
+
await fs.writeFile(safe.safePath, content, 'utf-8');
|
|
663
|
+
res.json({ success: true });
|
|
664
|
+
} catch (error) {
|
|
665
|
+
res.status(500).json({ success: false, error: error.message });
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
// ── 编辑器:新建文件 ───────────────────────────────────────────
|
|
670
|
+
app.post('/api/editor/file', express.json(), async (req, res) => {
|
|
671
|
+
try {
|
|
672
|
+
const { path: filePath } = req.body;
|
|
673
|
+
if (!filePath) throw new HttpError(400, '缺少 path 参数');
|
|
674
|
+
const safe = await safePathInProject(filePath);
|
|
675
|
+
if (!safe) throw new HttpError(403, '禁止在工作目录以外创建文件');
|
|
676
|
+
const resolved = safe.safePath;
|
|
677
|
+
// 如果已存在则拒绝
|
|
678
|
+
try {
|
|
679
|
+
await fs.access(resolved);
|
|
680
|
+
throw new HttpError(409, '文件已存在');
|
|
681
|
+
} catch { /* 不存在,继续 */ }
|
|
682
|
+
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
683
|
+
await fs.writeFile(resolved, '', 'utf-8');
|
|
684
|
+
res.json({ success: true });
|
|
685
|
+
} catch (error) {
|
|
686
|
+
res.status(500).json({ success: false, error: error.message });
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
// ── 编辑器:新建文件夹 ─────────────────────────────────────────
|
|
691
|
+
app.post('/api/editor/directory', express.json(), async (req, res) => {
|
|
692
|
+
try {
|
|
693
|
+
const { path: dirPath } = req.body;
|
|
694
|
+
if (!dirPath) throw new HttpError(400, '缺少 path 参数');
|
|
695
|
+
const safe = await safePathInProject(dirPath);
|
|
696
|
+
if (!safe) throw new HttpError(403, '禁止在工作目录以外创建目录');
|
|
697
|
+
const resolved = safe.safePath;
|
|
698
|
+
try {
|
|
699
|
+
await fs.access(resolved);
|
|
700
|
+
throw new HttpError(409, '目录已存在');
|
|
701
|
+
} catch { /* 不存在,继续 */ }
|
|
702
|
+
await fs.mkdir(resolved, { recursive: true });
|
|
703
|
+
res.json({ success: true });
|
|
704
|
+
} catch (error) {
|
|
705
|
+
res.status(500).json({ success: false, error: error.message });
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
// ── 编辑器:删除文件或文件夹 ───────────────────────────────────
|
|
710
|
+
app.delete('/api/editor/entry', express.json(), async (req, res) => {
|
|
711
|
+
try {
|
|
712
|
+
const filePath = req.query.path;
|
|
713
|
+
if (!filePath) throw new HttpError(400, '缺少 path 参数');
|
|
714
|
+
const safe = await safePathInProject(filePath);
|
|
715
|
+
if (!safe) throw new HttpError(403, '禁止删除工作目录以外的内容');
|
|
716
|
+
const resolved = safe.safePath;
|
|
717
|
+
const stat = await fs.stat(resolved);
|
|
718
|
+
if (stat.isDirectory()) {
|
|
719
|
+
await fs.rm(resolved, { recursive: true, force: true });
|
|
720
|
+
} else {
|
|
721
|
+
await fs.unlink(resolved);
|
|
722
|
+
}
|
|
723
|
+
res.json({ success: true });
|
|
724
|
+
} catch (error) {
|
|
725
|
+
res.status(500).json({ success: false, error: error.message });
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
// ── 编辑器:重命名文件或文件夹 ────────────────────────────────
|
|
730
|
+
app.put('/api/editor/rename', express.json(), async (req, res) => {
|
|
731
|
+
try {
|
|
732
|
+
const { oldPath, newPath } = req.body;
|
|
733
|
+
if (!oldPath || !newPath) throw new HttpError(400, '缺少参数');
|
|
734
|
+
const safeOld = await safePathInProject(oldPath)
|
|
735
|
+
const safeNew = await safePathInProject(newPath)
|
|
736
|
+
if (!safeOld || !safeNew) throw new HttpError(403, '禁止操作工作目录以外的内容');
|
|
737
|
+
try {
|
|
738
|
+
await fs.access(safeNew.safePath);
|
|
739
|
+
throw new HttpError(409, '目标名称已存在');
|
|
740
|
+
} catch { /* 不存在,继续 */ }
|
|
741
|
+
await fs.rename(safeOld.safePath, safeNew.safePath);
|
|
742
|
+
res.json({ success: true });
|
|
743
|
+
} catch (error) {
|
|
744
|
+
res.status(500).json({ success: false, error: error.message });
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
// ── 编辑器:在系统文件管理器中打开 / 选中 ───────────────────
|
|
749
|
+
// 行为:
|
|
750
|
+
// - 目录:直接用系统文件管理器打开
|
|
751
|
+
// - 文件:Windows / macOS 上在文件管理器中选中该文件;Linux 无标准接口,退化为打开所在目录
|
|
752
|
+
app.post('/api/editor/reveal', express.json(), async (req, res) => {
|
|
753
|
+
try {
|
|
754
|
+
const targetPath = req.body?.path;
|
|
755
|
+
if (!targetPath) throw new HttpError(400, '缺少 path 参数');
|
|
756
|
+
|
|
757
|
+
const safe = await safePathInProject(targetPath);
|
|
758
|
+
if (!safe) throw new HttpError(403, '禁止访问工作目录以外的内容');
|
|
759
|
+
const resolved = safe.safePath;
|
|
760
|
+
|
|
761
|
+
try {
|
|
762
|
+
await fs.access(resolved);
|
|
763
|
+
} catch {
|
|
764
|
+
throw new HttpError(404, '目标不存在');
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const stat = await fs.stat(resolved);
|
|
768
|
+
const isDirectory = stat.isDirectory();
|
|
769
|
+
|
|
770
|
+
const platform = process.platform;
|
|
771
|
+
if (isDirectory) {
|
|
772
|
+
await open(resolved, { wait: false });
|
|
773
|
+
} else if (platform === 'win32') {
|
|
774
|
+
// Windows: explorer.exe /select,<path>(逗号分隔,无双引号)
|
|
775
|
+
// resolved 已通过 safePathInProject 校验,argv 数组传入防二次解释
|
|
776
|
+
spawn('explorer.exe', ['/select,', resolved], { detached: true, stdio: 'ignore' }).unref();
|
|
777
|
+
} else if (platform === 'darwin') {
|
|
778
|
+
// macOS: open -R "<path>" 在 Finder 中选中
|
|
779
|
+
spawn('open', ['-R', resolved], { detached: true, stdio: 'ignore' }).unref();
|
|
780
|
+
} else {
|
|
781
|
+
// Linux / 其它: 没有统一标准,打开所在目录
|
|
782
|
+
await open(path.dirname(resolved), { wait: false });
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
res.json({ success: true });
|
|
786
|
+
} catch (error) {
|
|
787
|
+
res.status(500).json({ success: false, error: error.message });
|
|
788
|
+
}
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
|