@co0ontty/wand 3.1.1 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth.d.ts +19 -5
- package/dist/auth.js +83 -45
- package/dist/build-info.json +3 -3
- package/dist/cert.d.ts +1 -1
- package/dist/cert.js +124 -74
- package/dist/config.js +25 -8
- package/dist/express-async.d.ts +6 -0
- package/dist/express-async.js +28 -0
- package/dist/git-quick-commit.d.ts +2 -0
- package/dist/git-quick-commit.js +215 -76
- package/dist/git-utils.d.ts +4 -0
- package/dist/git-utils.js +60 -11
- package/dist/git-worktree.d.ts +8 -1
- package/dist/git-worktree.js +406 -41
- package/dist/models.d.ts +34 -4
- package/dist/models.js +334 -48
- package/dist/process-manager.d.ts +22 -30
- package/dist/process-manager.js +374 -441
- package/dist/provider-history-scanner.d.ts +54 -0
- package/dist/provider-history-scanner.js +354 -0
- package/dist/request-limits.d.ts +1 -0
- package/dist/request-limits.js +8 -0
- package/dist/resume-policy.d.ts +2 -0
- package/dist/resume-policy.js +5 -0
- package/dist/runtime-config.d.ts +16 -0
- package/dist/runtime-config.js +49 -0
- package/dist/server-file-routes.d.ts +17 -0
- package/dist/server-file-routes.js +653 -0
- package/dist/server-session-routes.d.ts +16 -3
- package/dist/server-session-routes.js +170 -149
- package/dist/server-settings-routes.d.ts +43 -0
- package/dist/server-settings-routes.js +225 -0
- package/dist/server-update-routes.d.ts +61 -0
- package/dist/server-update-routes.js +215 -0
- package/dist/server.d.ts +6 -4
- package/dist/server.js +350 -1313
- package/dist/session-logger.d.ts +32 -2
- package/dist/session-logger.js +145 -15
- package/dist/session-registry.d.ts +27 -0
- package/dist/session-registry.js +153 -0
- package/dist/session-transport.d.ts +31 -0
- package/dist/session-transport.js +82 -0
- package/dist/storage.d.ts +24 -6
- package/dist/storage.js +291 -44
- package/dist/structured-claude-adapter.d.ts +19 -0
- package/dist/structured-claude-adapter.js +117 -0
- package/dist/structured-codex-adapter.d.ts +3 -0
- package/dist/structured-codex-adapter.js +29 -0
- package/dist/structured-opencode-adapter.d.ts +11 -0
- package/dist/structured-opencode-adapter.js +115 -0
- package/dist/structured-provider-common.d.ts +11 -0
- package/dist/structured-provider-common.js +77 -0
- package/dist/structured-session-manager.d.ts +32 -35
- package/dist/structured-session-manager.js +551 -605
- package/dist/types.d.ts +10 -0
- package/dist/update-helper.js +5 -1
- package/dist/web-ui/content/scripts.js +32 -32
- package/dist/web-ui/embedded-assets.d.ts +1 -1
- package/dist/web-ui/embedded-assets.js +2 -2
- package/dist/ws-broadcast.d.ts +16 -1
- package/dist/ws-broadcast.js +124 -58
- package/package.json +2 -1
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { exec } from "node:child_process";
|
|
3
|
+
import { createReadStream } from "node:fs";
|
|
4
|
+
import { lstat, readdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import process from "node:process";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { getErrorMessage } from "./error-utils.js";
|
|
9
|
+
import { asyncRoute } from "./express-async.js";
|
|
10
|
+
import { isBlockedFolderPath, isPathWithinBase, normalizeFolderPath } from "./middleware/path-safety.js";
|
|
11
|
+
import { parseBoundedInteger } from "./request-limits.js";
|
|
12
|
+
const execAsync = promisify(exec);
|
|
13
|
+
const DIRECTORY_MAX_ITEMS = 200;
|
|
14
|
+
const MAX_TEXT_PREVIEW_SIZE = 512 * 1024;
|
|
15
|
+
const MAX_TEXT_WRITE_SIZE = 1024 * 1024;
|
|
16
|
+
const MAX_RECENT_PATHS = 10;
|
|
17
|
+
/** Persist a cwd to recent paths. Used by REST and session creation hooks. */
|
|
18
|
+
export function recordRecentPath(storage, cwd) {
|
|
19
|
+
if (!cwd)
|
|
20
|
+
return;
|
|
21
|
+
const trimmed = cwd.trim();
|
|
22
|
+
if (!trimmed)
|
|
23
|
+
return;
|
|
24
|
+
let resolved;
|
|
25
|
+
try {
|
|
26
|
+
resolved = normalizeFolderPath(trimmed);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (isBlockedFolderPath(resolved))
|
|
32
|
+
return;
|
|
33
|
+
const stored = storage.getConfigValue("recent_paths");
|
|
34
|
+
let recent = parseStoredPathList(stored);
|
|
35
|
+
recent = recent.filter((item) => normalizeFolderPath(item.path) !== resolved);
|
|
36
|
+
recent.unshift({
|
|
37
|
+
path: resolved,
|
|
38
|
+
name: path.basename(resolved),
|
|
39
|
+
lastUsedAt: new Date().toISOString(),
|
|
40
|
+
});
|
|
41
|
+
storage.setConfigValue("recent_paths", JSON.stringify(recent.slice(0, MAX_RECENT_PATHS)));
|
|
42
|
+
}
|
|
43
|
+
export function registerFileRoutes(app, deps) {
|
|
44
|
+
const { storage, defaultCwd } = deps;
|
|
45
|
+
app.get("/api/path-suggestions", asyncRoute(async (req, res) => {
|
|
46
|
+
const query = typeof req.query.q === "string" ? req.query.q : "";
|
|
47
|
+
try {
|
|
48
|
+
res.json(await listPathSuggestions(query, defaultCwd));
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
res.status(400).json({ error: getErrorMessage(error, "无法加载路径建议。") });
|
|
52
|
+
}
|
|
53
|
+
}));
|
|
54
|
+
app.get("/api/directory", asyncRoute(async (req, res) => {
|
|
55
|
+
const q = typeof req.query.q === "string" ? req.query.q : "";
|
|
56
|
+
const includeGitStatus = req.query.gitStatus === "true";
|
|
57
|
+
const targetPath = path.resolve(q || defaultCwd);
|
|
58
|
+
try {
|
|
59
|
+
const entries = await readdir(targetPath, { withFileTypes: true });
|
|
60
|
+
const sorted = entries.sort((a, b) => {
|
|
61
|
+
if (a.isDirectory() && !b.isDirectory())
|
|
62
|
+
return -1;
|
|
63
|
+
if (!a.isDirectory() && b.isDirectory())
|
|
64
|
+
return 1;
|
|
65
|
+
return a.name.localeCompare(b.name);
|
|
66
|
+
});
|
|
67
|
+
const total = sorted.length;
|
|
68
|
+
const sliced = sorted.slice(0, DIRECTORY_MAX_ITEMS);
|
|
69
|
+
let items = await Promise.all(sliced.map(async (entry) => {
|
|
70
|
+
const fullPath = path.join(targetPath, entry.name);
|
|
71
|
+
const isDir = entry.isDirectory();
|
|
72
|
+
const base = {
|
|
73
|
+
path: fullPath,
|
|
74
|
+
name: entry.name,
|
|
75
|
+
type: isDir ? "dir" : "file",
|
|
76
|
+
};
|
|
77
|
+
if (isDir)
|
|
78
|
+
return base;
|
|
79
|
+
try {
|
|
80
|
+
const fileStat = await lstat(fullPath);
|
|
81
|
+
base.size = fileStat.size;
|
|
82
|
+
base.mtime = fileStat.mtime.toISOString();
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// Per-entry permission/race failures do not fail the whole listing.
|
|
86
|
+
}
|
|
87
|
+
return base;
|
|
88
|
+
}));
|
|
89
|
+
if (includeGitStatus)
|
|
90
|
+
items = await enrichWithGitStatus(items, targetPath);
|
|
91
|
+
const payload = {
|
|
92
|
+
items,
|
|
93
|
+
truncated: total > DIRECTORY_MAX_ITEMS,
|
|
94
|
+
total,
|
|
95
|
+
};
|
|
96
|
+
res.json(payload);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
res.status(400).json({ error: getErrorMessage(error, "无法读取目录。可能原因:路径不存在或权限不足。") });
|
|
100
|
+
}
|
|
101
|
+
}));
|
|
102
|
+
app.get("/api/file-preview", asyncRoute(async (req, res) => {
|
|
103
|
+
const filePath = typeof req.query.path === "string" ? req.query.path : "";
|
|
104
|
+
if (!filePath) {
|
|
105
|
+
res.status(400).json({ error: "Missing path parameter" });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const resolvedPath = path.resolve(filePath);
|
|
109
|
+
if (isBlockedFolderPath(resolvedPath)) {
|
|
110
|
+
res.status(403).json({ error: "Access denied" });
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const fileStat = await stat(resolvedPath);
|
|
115
|
+
if (fileStat.isDirectory()) {
|
|
116
|
+
res.status(400).json({ error: "Cannot preview a directory" });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
120
|
+
const baseName = path.basename(filePath);
|
|
121
|
+
const kind = classifyFile(ext, baseName);
|
|
122
|
+
const mime = mimeForExt(ext);
|
|
123
|
+
if (kind !== "text") {
|
|
124
|
+
const payload = {
|
|
125
|
+
kind,
|
|
126
|
+
path: resolvedPath,
|
|
127
|
+
name: baseName,
|
|
128
|
+
ext,
|
|
129
|
+
size: fileStat.size,
|
|
130
|
+
mime,
|
|
131
|
+
};
|
|
132
|
+
res.json(payload);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (fileStat.size > MAX_TEXT_PREVIEW_SIZE) {
|
|
136
|
+
res.status(413).json({
|
|
137
|
+
error: "文件太大,无法在线预览(限 512 KB)。",
|
|
138
|
+
truncated: true,
|
|
139
|
+
size: fileStat.size,
|
|
140
|
+
maxSize: MAX_TEXT_PREVIEW_SIZE,
|
|
141
|
+
});
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const content = await readFile(resolvedPath, "utf-8");
|
|
145
|
+
const payload = {
|
|
146
|
+
kind: "text",
|
|
147
|
+
path: resolvedPath,
|
|
148
|
+
name: baseName,
|
|
149
|
+
ext,
|
|
150
|
+
size: fileStat.size,
|
|
151
|
+
mime,
|
|
152
|
+
lang: getLanguageFromExt(ext, filePath),
|
|
153
|
+
content,
|
|
154
|
+
};
|
|
155
|
+
res.json(payload);
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
res.status(400).json({ error: getErrorMessage(error, "Failed to read file") });
|
|
159
|
+
}
|
|
160
|
+
}));
|
|
161
|
+
app.post("/api/file-write", asyncRoute(async (req, res) => {
|
|
162
|
+
const body = (req.body ?? {});
|
|
163
|
+
const filePath = typeof body.path === "string" ? body.path : "";
|
|
164
|
+
const content = typeof body.content === "string" ? body.content : null;
|
|
165
|
+
if (!filePath || content === null) {
|
|
166
|
+
res.status(400).json({ error: "缺少 path 或 content 参数。" });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const resolvedPath = path.resolve(filePath);
|
|
170
|
+
if (isBlockedFolderPath(resolvedPath)) {
|
|
171
|
+
res.status(403).json({ error: "访问被拒绝:无法修改系统目录下的文件。" });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const byteLength = Buffer.byteLength(content, "utf-8");
|
|
175
|
+
if (byteLength > MAX_TEXT_WRITE_SIZE) {
|
|
176
|
+
res.status(413).json({
|
|
177
|
+
error: `内容超出保存上限(${Math.round(MAX_TEXT_WRITE_SIZE / 1024)} KB)。`,
|
|
178
|
+
size: byteLength,
|
|
179
|
+
maxSize: MAX_TEXT_WRITE_SIZE,
|
|
180
|
+
});
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const fileStat = await stat(resolvedPath);
|
|
185
|
+
if (fileStat.isDirectory()) {
|
|
186
|
+
res.status(400).json({ error: "目标是目录,无法写入。" });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (!fileStat.isFile()) {
|
|
190
|
+
res.status(400).json({ error: "目标不是普通文件。" });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const ext = path.extname(resolvedPath).toLowerCase();
|
|
194
|
+
const baseName = path.basename(resolvedPath);
|
|
195
|
+
if (classifyFile(ext, baseName) !== "text") {
|
|
196
|
+
res.status(415).json({ error: "仅支持编辑文本类文件。" });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const tmpPath = path.join(path.dirname(resolvedPath), `.${baseName}.wand-tmp-${crypto.randomBytes(6).toString("hex")}`);
|
|
200
|
+
try {
|
|
201
|
+
await writeFile(tmpPath, content, { encoding: "utf-8", mode: fileStat.mode & 0o777 });
|
|
202
|
+
await rename(tmpPath, resolvedPath);
|
|
203
|
+
}
|
|
204
|
+
catch (writeError) {
|
|
205
|
+
try {
|
|
206
|
+
await unlink(tmpPath);
|
|
207
|
+
}
|
|
208
|
+
catch { /* best-effort temp cleanup */ }
|
|
209
|
+
throw writeError;
|
|
210
|
+
}
|
|
211
|
+
const newStat = await stat(resolvedPath);
|
|
212
|
+
res.json({
|
|
213
|
+
ok: true,
|
|
214
|
+
path: resolvedPath,
|
|
215
|
+
size: newStat.size,
|
|
216
|
+
mtime: newStat.mtime.toISOString(),
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
res.status(400).json({ error: getErrorMessage(error, "保存文件失败。") });
|
|
221
|
+
}
|
|
222
|
+
}));
|
|
223
|
+
app.get("/api/file-raw", asyncRoute(async (req, res) => {
|
|
224
|
+
const filePath = typeof req.query.path === "string" ? req.query.path : "";
|
|
225
|
+
const asDownload = req.query.download === "1" || req.query.download === "true";
|
|
226
|
+
if (!filePath) {
|
|
227
|
+
res.status(400).json({ error: "Missing path parameter" });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const resolvedPath = path.resolve(filePath);
|
|
231
|
+
if (isBlockedFolderPath(resolvedPath)) {
|
|
232
|
+
res.status(403).json({ error: "Access denied" });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
const fileStat = await stat(resolvedPath);
|
|
237
|
+
if (!fileStat.isFile()) {
|
|
238
|
+
res.status(400).json({ error: "Not a regular file" });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
242
|
+
const baseName = path.basename(filePath);
|
|
243
|
+
const kind = classifyFile(ext, baseName);
|
|
244
|
+
const cap = RAW_MAX_BYTES_BY_KIND[kind] ?? RAW_MAX_BYTES_BY_KIND.binary;
|
|
245
|
+
if (fileStat.size > cap) {
|
|
246
|
+
res.status(413).json({
|
|
247
|
+
error: `文件超出可在线预览的上限(${Math.round(cap / 1024 / 1024)} MB)。`,
|
|
248
|
+
size: fileStat.size,
|
|
249
|
+
maxSize: cap,
|
|
250
|
+
});
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const encodedName = encodeURIComponent(baseName);
|
|
254
|
+
streamFileWithRange(req, res, {
|
|
255
|
+
filePath: resolvedPath,
|
|
256
|
+
size: fileStat.size,
|
|
257
|
+
contentType: kind === "binary" ? "application/octet-stream" : mimeForExt(ext),
|
|
258
|
+
disposition: `${asDownload ? "attachment" : "inline"}; filename*=UTF-8''${encodedName}`,
|
|
259
|
+
headers: {
|
|
260
|
+
"Cache-Control": "private, max-age=60",
|
|
261
|
+
"X-Content-Type-Options": "nosniff",
|
|
262
|
+
},
|
|
263
|
+
readErrorMessage: "Failed to read file",
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
res.status(400).json({ error: getErrorMessage(error, "Failed to read file") });
|
|
268
|
+
}
|
|
269
|
+
}));
|
|
270
|
+
app.get("/api/folders", asyncRoute(async (req, res) => {
|
|
271
|
+
const q = typeof req.query.q === "string" ? req.query.q : "/tmp";
|
|
272
|
+
const targetPath = normalizeFolderPath(q);
|
|
273
|
+
if (isBlockedFolderPath(targetPath)) {
|
|
274
|
+
res.status(403).json({ error: "访问被拒绝:无法访问系统敏感目录。" });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
const entries = await readdir(targetPath, { withFileTypes: true });
|
|
279
|
+
const items = [];
|
|
280
|
+
const parentPath = path.dirname(targetPath);
|
|
281
|
+
if (parentPath !== targetPath) {
|
|
282
|
+
items.push({ path: parentPath, name: "..", type: "parent", isParent: true });
|
|
283
|
+
}
|
|
284
|
+
entries
|
|
285
|
+
.filter((entry) => entry.isDirectory())
|
|
286
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
287
|
+
.slice(0, 100)
|
|
288
|
+
.forEach((entry) => {
|
|
289
|
+
items.push({ path: path.join(targetPath, entry.name), name: entry.name, type: "dir" });
|
|
290
|
+
});
|
|
291
|
+
res.json({ currentPath: targetPath, items });
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
|
|
295
|
+
if (code === "ENOENT") {
|
|
296
|
+
res.status(404).json({ error: "路径不存在:" + q, currentPath: q, items: [] });
|
|
297
|
+
}
|
|
298
|
+
else if (code === "EACCES") {
|
|
299
|
+
res.status(403).json({ error: "权限不足,无法访问:" + q, currentPath: q, items: [] });
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
res.status(400).json({ error: "无法读取目录:" + getErrorMessage(error, "未知错误"), currentPath: q, items: [] });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}));
|
|
306
|
+
app.get("/api/quick-paths", asyncRoute(async (_req, res) => {
|
|
307
|
+
const home = process.env.HOME || process.env.USERPROFILE || "/home";
|
|
308
|
+
res.json([
|
|
309
|
+
{ path: "/tmp", name: "临时目录", icon: "🗑️" },
|
|
310
|
+
{ path: home, name: "主目录", icon: "🏠" },
|
|
311
|
+
{ path: process.cwd(), name: "当前目录", icon: "📂" },
|
|
312
|
+
{ path: "/", name: "根目录", icon: "📁" },
|
|
313
|
+
]);
|
|
314
|
+
}));
|
|
315
|
+
app.get("/api/recent-paths", (_req, res) => {
|
|
316
|
+
const recent = parseStoredPathList(storage.getConfigValue("recent_paths"));
|
|
317
|
+
res.json(recent.filter((item) => !isBlockedFolderPath(normalizeFolderPath(item.path))));
|
|
318
|
+
});
|
|
319
|
+
app.post("/api/recent-paths", (req, res) => {
|
|
320
|
+
const { path: usedPath } = req.body;
|
|
321
|
+
if (!usedPath) {
|
|
322
|
+
res.status(400).json({ error: "路径不能为空。" });
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const resolvedRecentPath = normalizeFolderPath(usedPath);
|
|
326
|
+
if (isBlockedFolderPath(resolvedRecentPath)) {
|
|
327
|
+
res.status(403).json({ error: "访问被拒绝:无法保存系统敏感目录。" });
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
recordRecentPath(storage, resolvedRecentPath);
|
|
331
|
+
res.json({
|
|
332
|
+
path: resolvedRecentPath,
|
|
333
|
+
name: path.basename(resolvedRecentPath),
|
|
334
|
+
lastUsedAt: new Date().toISOString(),
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
app.get("/api/validate-path", asyncRoute(async (req, res) => {
|
|
338
|
+
const inputPath = typeof req.query.path === "string" ? req.query.path : "";
|
|
339
|
+
if (!inputPath.trim()) {
|
|
340
|
+
res.json({ valid: false, error: "路径不能为空" });
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
const resolvedPath = normalizeFolderPath(inputPath);
|
|
345
|
+
if (isBlockedFolderPath(resolvedPath)) {
|
|
346
|
+
res.json({ valid: false, error: "访问被拒绝:无法访问系统敏感目录。", resolvedPath });
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const stats = await stat(resolvedPath);
|
|
350
|
+
if (!stats.isDirectory()) {
|
|
351
|
+
res.json({ valid: false, error: "路径不是目录", resolvedPath });
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
await readdir(resolvedPath);
|
|
356
|
+
res.json({ valid: true, resolvedPath, name: path.basename(resolvedPath) });
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
res.json({ valid: false, error: "没有读取权限", resolvedPath });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
const err = error;
|
|
364
|
+
if (err.code === "ENOENT")
|
|
365
|
+
res.json({ valid: false, error: "路径不存在" });
|
|
366
|
+
else if (err.code === "EACCES")
|
|
367
|
+
res.json({ valid: false, error: "没有访问权限" });
|
|
368
|
+
else
|
|
369
|
+
res.json({ valid: false, error: `无效路径: ${err.message}` });
|
|
370
|
+
}
|
|
371
|
+
}));
|
|
372
|
+
app.get("/api/file-search", asyncRoute(async (req, res) => {
|
|
373
|
+
const query = typeof req.query.q === "string" ? req.query.q.trim().slice(0, 256) : "";
|
|
374
|
+
const cwd = typeof req.query.cwd === "string" ? req.query.cwd : process.cwd();
|
|
375
|
+
const maxDepth = parseBoundedInteger(req.query.depth, 5, 0, 8);
|
|
376
|
+
const maxResults = parseBoundedInteger(req.query.limit, 50, 1, 200);
|
|
377
|
+
const ignoredDirectories = new Set([".git", "node_modules", ".next", "dist", "build", "coverage", ".wand-uploads"]);
|
|
378
|
+
const maxVisitedEntries = 20_000;
|
|
379
|
+
const allowedBase = process.cwd();
|
|
380
|
+
const resolvedCwd = path.resolve(allowedBase, cwd);
|
|
381
|
+
if (!isPathWithinBase(resolvedCwd, allowedBase)) {
|
|
382
|
+
res.status(403).json({ error: "访问被拒绝:路径必须在项目目录内。" });
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (!query) {
|
|
386
|
+
res.json({ results: [], query: "", cwd: resolvedCwd });
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
try {
|
|
390
|
+
const results = [];
|
|
391
|
+
const queryLower = query.toLowerCase();
|
|
392
|
+
let visitedEntries = 0;
|
|
393
|
+
async function searchDir(dirPath, currentDepth) {
|
|
394
|
+
if (currentDepth > maxDepth || results.length >= maxResults || visitedEntries >= maxVisitedEntries)
|
|
395
|
+
return;
|
|
396
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
397
|
+
for (const entry of entries) {
|
|
398
|
+
if (results.length >= maxResults || visitedEntries >= maxVisitedEntries)
|
|
399
|
+
break;
|
|
400
|
+
visitedEntries += 1;
|
|
401
|
+
if (entry.isDirectory() && ignoredDirectories.has(entry.name))
|
|
402
|
+
continue;
|
|
403
|
+
const entryPath = path.join(dirPath, entry.name);
|
|
404
|
+
const matchIndex = entry.name.toLowerCase().indexOf(queryLower);
|
|
405
|
+
if (matchIndex !== -1) {
|
|
406
|
+
results.push({
|
|
407
|
+
path: entryPath,
|
|
408
|
+
name: entry.name,
|
|
409
|
+
type: entry.isDirectory() ? "dir" : "file",
|
|
410
|
+
matchScore: matchIndex,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
if (entry.isDirectory())
|
|
414
|
+
await searchDir(entryPath, currentDepth + 1);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
await searchDir(resolvedCwd, 0);
|
|
418
|
+
results.sort((a, b) => a.matchScore !== b.matchScore
|
|
419
|
+
? a.matchScore - b.matchScore
|
|
420
|
+
: a.name.localeCompare(b.name));
|
|
421
|
+
res.json({ results: results.slice(0, maxResults), query, cwd: resolvedCwd });
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
res.status(400).json({ error: getErrorMessage(error, "搜索失败。可能原因:路径不存在或权限不足。") });
|
|
425
|
+
}
|
|
426
|
+
}));
|
|
427
|
+
}
|
|
428
|
+
export function streamFileWithRange(req, res, options) {
|
|
429
|
+
res.setHeader("Content-Type", options.contentType);
|
|
430
|
+
if (options.disposition)
|
|
431
|
+
res.setHeader("Content-Disposition", options.disposition);
|
|
432
|
+
for (const [name, value] of Object.entries(options.headers ?? {}))
|
|
433
|
+
res.setHeader(name, value);
|
|
434
|
+
res.setHeader("Accept-Ranges", "bytes");
|
|
435
|
+
if (options.size === 0) {
|
|
436
|
+
if (req.headers.range?.trim().startsWith("bytes=")) {
|
|
437
|
+
res.status(416).setHeader("Content-Range", "bytes */0").end();
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
res.setHeader("Content-Length", "0");
|
|
441
|
+
res.end();
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const parsedRange = parseByteRange(req.headers.range, options.size);
|
|
445
|
+
if (parsedRange === "invalid") {
|
|
446
|
+
res.status(416).setHeader("Content-Range", `bytes */${options.size}`).end();
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const start = parsedRange?.start ?? 0;
|
|
450
|
+
const end = parsedRange?.end ?? options.size - 1;
|
|
451
|
+
if (parsedRange) {
|
|
452
|
+
res.status(206);
|
|
453
|
+
res.setHeader("Content-Range", `bytes ${start}-${end}/${options.size}`);
|
|
454
|
+
}
|
|
455
|
+
res.setHeader("Content-Length", String(end - start + 1));
|
|
456
|
+
const stream = createReadStream(options.filePath, { start, end });
|
|
457
|
+
stream.on("error", (error) => {
|
|
458
|
+
if (!res.headersSent) {
|
|
459
|
+
res.status(500).json({ error: getErrorMessage(error, options.readErrorMessage ?? "读取文件失败。") });
|
|
460
|
+
}
|
|
461
|
+
else {
|
|
462
|
+
res.destroy();
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
stream.pipe(res);
|
|
466
|
+
}
|
|
467
|
+
async function listPathSuggestions(input, fallbackCwd) {
|
|
468
|
+
const normalizedInput = input.trim();
|
|
469
|
+
const resolvedInput = normalizeFolderPath(normalizedInput || fallbackCwd);
|
|
470
|
+
const endsWithSeparator = /[\\/]$/.test(normalizedInput);
|
|
471
|
+
const searchDir = endsWithSeparator ? resolvedInput : path.dirname(resolvedInput);
|
|
472
|
+
const partialName = endsWithSeparator ? "" : path.basename(resolvedInput);
|
|
473
|
+
const entries = await readdir(searchDir, { withFileTypes: true });
|
|
474
|
+
return entries
|
|
475
|
+
.filter((entry) => entry.isDirectory())
|
|
476
|
+
.filter((entry) => !partialName || entry.name.toLowerCase().startsWith(partialName.toLowerCase()))
|
|
477
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
478
|
+
.slice(0, 8)
|
|
479
|
+
.map((entry) => ({ path: path.join(searchDir, entry.name), name: entry.name, isDirectory: true }));
|
|
480
|
+
}
|
|
481
|
+
function parseStoredPathList(raw) {
|
|
482
|
+
if (!raw)
|
|
483
|
+
return [];
|
|
484
|
+
try {
|
|
485
|
+
const parsed = JSON.parse(raw);
|
|
486
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
return [];
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
async function getGitRepoRoot(dirPath) {
|
|
493
|
+
try {
|
|
494
|
+
const { stdout } = await execAsync("git rev-parse --show-toplevel", { cwd: dirPath });
|
|
495
|
+
return stdout.trim();
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
async function getGitStatusMap(gitRoot) {
|
|
502
|
+
const statusMap = new Map();
|
|
503
|
+
try {
|
|
504
|
+
const { stdout: stagedStdout } = await execAsync("git status --porcelain -uno", { cwd: gitRoot });
|
|
505
|
+
const { stdout: untrackedStdout } = await execAsync("git ls-files --others --exclude-standard", { cwd: gitRoot });
|
|
506
|
+
for (const line of stagedStdout.split("\n").filter((item) => item.trim())) {
|
|
507
|
+
if (line.length < 4)
|
|
508
|
+
continue;
|
|
509
|
+
const stagedChar = line[0];
|
|
510
|
+
const unstagedChar = line[1];
|
|
511
|
+
const filePath = line.slice(3).trim();
|
|
512
|
+
if (!filePath)
|
|
513
|
+
continue;
|
|
514
|
+
const status = {};
|
|
515
|
+
if (stagedChar === "M")
|
|
516
|
+
status.staged = "modified";
|
|
517
|
+
else if (stagedChar === "A")
|
|
518
|
+
status.staged = "added";
|
|
519
|
+
else if (stagedChar === "D")
|
|
520
|
+
status.staged = "deleted";
|
|
521
|
+
else if (stagedChar === "R")
|
|
522
|
+
status.staged = "renamed";
|
|
523
|
+
if (unstagedChar === "M")
|
|
524
|
+
status.unstaged = "modified";
|
|
525
|
+
else if (unstagedChar === "D")
|
|
526
|
+
status.unstaged = "deleted";
|
|
527
|
+
statusMap.set(filePath, status);
|
|
528
|
+
}
|
|
529
|
+
for (const filePath of untrackedStdout.split("\n").filter((item) => item.trim())) {
|
|
530
|
+
const existing = statusMap.get(filePath);
|
|
531
|
+
if (existing)
|
|
532
|
+
existing.untracked = true;
|
|
533
|
+
else
|
|
534
|
+
statusMap.set(filePath, { untracked: true });
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
catch {
|
|
538
|
+
// Git decoration is optional.
|
|
539
|
+
}
|
|
540
|
+
return statusMap;
|
|
541
|
+
}
|
|
542
|
+
async function enrichWithGitStatus(items, dirPath) {
|
|
543
|
+
try {
|
|
544
|
+
const gitRoot = await getGitRepoRoot(dirPath);
|
|
545
|
+
if (!gitRoot)
|
|
546
|
+
return items;
|
|
547
|
+
const gitStatusMap = await getGitStatusMap(gitRoot);
|
|
548
|
+
return items.map((item) => {
|
|
549
|
+
const relativePath = path.relative(gitRoot, item.path).replace(/\\/g, "/");
|
|
550
|
+
return { ...item, gitStatus: gitStatusMap.get(relativePath) };
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
return items;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
function getLanguageFromExt(ext, filePath) {
|
|
558
|
+
const map = {
|
|
559
|
+
".ts": "typescript", ".tsx": "tsx", ".js": "javascript", ".jsx": "jsx",
|
|
560
|
+
".json": "json", ".html": "html", ".htm": "html", ".css": "css", ".scss": "scss", ".less": "less",
|
|
561
|
+
".py": "python", ".rb": "ruby", ".go": "go", ".rs": "rust", ".java": "java", ".c": "c", ".cpp": "cpp",
|
|
562
|
+
".h": "c", ".hpp": "cpp", ".cs": "csharp", ".swift": "swift", ".kt": "kotlin", ".scala": "scala",
|
|
563
|
+
".php": "php", ".sh": "bash", ".bash": "bash", ".zsh": "bash", ".yaml": "yaml", ".yml": "yaml",
|
|
564
|
+
".toml": "toml", ".ini": "ini", ".xml": "xml", ".sql": "sql", ".graphql": "graphql", ".md": "markdown",
|
|
565
|
+
".markdown": "markdown", ".mdown": "markdown", ".mkd": "markdown", ".mkdn": "markdown", ".dockerfile": "dockerfile",
|
|
566
|
+
".gitignore": "plaintext", ".diff": "diff", ".patch": "diff", ".proto": "protobuf", ".env": "bash",
|
|
567
|
+
".editorconfig": "ini", ".mdx": "markdown", ".vue": "html", ".svelte": "html",
|
|
568
|
+
};
|
|
569
|
+
const baseName = path.basename(filePath).toLowerCase();
|
|
570
|
+
if (baseName === "dockerfile")
|
|
571
|
+
return "dockerfile";
|
|
572
|
+
if (baseName === ".gitignore")
|
|
573
|
+
return "plaintext";
|
|
574
|
+
return map[ext] || "plaintext";
|
|
575
|
+
}
|
|
576
|
+
const TEXT_PREVIEWABLE_EXTS = new Set([
|
|
577
|
+
".md", ".markdown", ".mdown", ".mkd", ".mkdn", ".mdx", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
|
|
578
|
+
".json", ".jsonc", ".html", ".htm", ".css", ".scss", ".less", ".py", ".rb", ".go", ".rs", ".java",
|
|
579
|
+
".c", ".cpp", ".h", ".hpp", ".cs", ".swift", ".kt", ".scala", ".php", ".sh", ".bash", ".zsh", ".fish",
|
|
580
|
+
".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".env", ".xml", ".sql", ".graphql", ".proto",
|
|
581
|
+
".dockerfile", ".gitignore", ".editorconfig", ".vue", ".svelte", ".txt", ".log", ".diff", ".patch", ".lua",
|
|
582
|
+
".r", ".dart", ".pl", ".pm",
|
|
583
|
+
]);
|
|
584
|
+
const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif", ".bmp", ".ico", ".heic", ".heif"]);
|
|
585
|
+
const VIDEO_EXTS = new Set([".mp4", ".webm", ".mov", ".mkv", ".m4v", ".ogv"]);
|
|
586
|
+
const AUDIO_EXTS = new Set([".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".opus"]);
|
|
587
|
+
const PDF_EXTS = new Set([".pdf"]);
|
|
588
|
+
const TEXT_BASENAME_ALLOW = new Set([
|
|
589
|
+
"dockerfile", ".gitignore", ".dockerignore", ".env", ".env.local", ".env.development", ".env.production", ".env.test",
|
|
590
|
+
"makefile", "readme", "license", "changelog",
|
|
591
|
+
]);
|
|
592
|
+
const MIME_BY_EXT = {
|
|
593
|
+
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp",
|
|
594
|
+
".svg": "image/svg+xml", ".avif": "image/avif", ".bmp": "image/bmp", ".ico": "image/x-icon", ".heic": "image/heic",
|
|
595
|
+
".heif": "image/heif", ".pdf": "application/pdf", ".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime",
|
|
596
|
+
".mkv": "video/x-matroska", ".m4v": "video/x-m4v", ".ogv": "video/ogg", ".mp3": "audio/mpeg", ".wav": "audio/wav",
|
|
597
|
+
".ogg": "audio/ogg", ".m4a": "audio/mp4", ".flac": "audio/flac", ".aac": "audio/aac", ".opus": "audio/opus",
|
|
598
|
+
};
|
|
599
|
+
const RAW_MAX_BYTES_BY_KIND = {
|
|
600
|
+
text: 5 * 1024 * 1024,
|
|
601
|
+
image: 50 * 1024 * 1024,
|
|
602
|
+
pdf: 50 * 1024 * 1024,
|
|
603
|
+
video: 200 * 1024 * 1024,
|
|
604
|
+
audio: 200 * 1024 * 1024,
|
|
605
|
+
binary: 50 * 1024 * 1024,
|
|
606
|
+
};
|
|
607
|
+
function classifyFile(ext, baseName) {
|
|
608
|
+
const lowerExt = ext.toLowerCase();
|
|
609
|
+
const lowerBase = baseName.toLowerCase();
|
|
610
|
+
if (IMAGE_EXTS.has(lowerExt))
|
|
611
|
+
return "image";
|
|
612
|
+
if (PDF_EXTS.has(lowerExt))
|
|
613
|
+
return "pdf";
|
|
614
|
+
if (VIDEO_EXTS.has(lowerExt))
|
|
615
|
+
return "video";
|
|
616
|
+
if (AUDIO_EXTS.has(lowerExt))
|
|
617
|
+
return "audio";
|
|
618
|
+
if (TEXT_PREVIEWABLE_EXTS.has(lowerExt) || TEXT_BASENAME_ALLOW.has(lowerBase))
|
|
619
|
+
return "text";
|
|
620
|
+
if (lowerExt === "" && /^[a-z0-9._-]+$/i.test(lowerBase))
|
|
621
|
+
return "text";
|
|
622
|
+
return "binary";
|
|
623
|
+
}
|
|
624
|
+
function mimeForExt(ext) {
|
|
625
|
+
return MIME_BY_EXT[ext.toLowerCase()] || "application/octet-stream";
|
|
626
|
+
}
|
|
627
|
+
function parseByteRange(rangeHeader, total) {
|
|
628
|
+
if (!rangeHeader)
|
|
629
|
+
return null;
|
|
630
|
+
const trimmed = rangeHeader.trim();
|
|
631
|
+
if (!trimmed.startsWith("bytes="))
|
|
632
|
+
return null;
|
|
633
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(trimmed);
|
|
634
|
+
if (!match || (match[1] === "" && match[2] === ""))
|
|
635
|
+
return "invalid";
|
|
636
|
+
let start;
|
|
637
|
+
let end;
|
|
638
|
+
if (match[1] === "") {
|
|
639
|
+
const suffixLength = Number(match[2]);
|
|
640
|
+
if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0)
|
|
641
|
+
return "invalid";
|
|
642
|
+
start = Math.max(0, total - suffixLength);
|
|
643
|
+
end = total - 1;
|
|
644
|
+
}
|
|
645
|
+
else {
|
|
646
|
+
start = Number(match[1]);
|
|
647
|
+
end = match[2] === "" ? total - 1 : Math.min(Number(match[2]), total - 1);
|
|
648
|
+
}
|
|
649
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || start > end || start >= total) {
|
|
650
|
+
return "invalid";
|
|
651
|
+
}
|
|
652
|
+
return { start, end };
|
|
653
|
+
}
|
|
@@ -2,7 +2,9 @@ import type { Express } from "express";
|
|
|
2
2
|
import { ProcessManager } from "./process-manager.js";
|
|
3
3
|
import { StructuredSessionManager } from "./structured-session-manager.js";
|
|
4
4
|
import { WandStorage } from "./storage.js";
|
|
5
|
-
import { ExecutionMode, SessionSource, WandConfig } from "./types.js";
|
|
5
|
+
import { ExecutionMode, SessionSnapshot, SessionSource, WandConfig } from "./types.js";
|
|
6
|
+
import { SessionRegistry } from "./session-registry.js";
|
|
7
|
+
export declare function parseExecutionMode(value: unknown, fallback: ExecutionMode): ExecutionMode;
|
|
6
8
|
export declare function parseSessionCreationOrigin(body: {
|
|
7
9
|
sessionSource?: unknown;
|
|
8
10
|
automationId?: unknown;
|
|
@@ -18,6 +20,17 @@ type SessionDeletionStructured = Pick<StructuredSessionManager, "get" | "delete"
|
|
|
18
20
|
* immediately reappear as a "non-Wand" session after the Wand row is removed.
|
|
19
21
|
*/
|
|
20
22
|
export declare function deleteSessionWithProviderHistory(processes: SessionDeletionProcesses, structured: SessionDeletionStructured, storage: Pick<WandStorage, "getConfigValue" | "setConfigValue">, id: string): void;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
type ProviderHistorySession = {
|
|
24
|
+
claudeSessionId: string;
|
|
25
|
+
managedByWand: boolean;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Provider history is scanned by ProcessManager, but structured sessions live
|
|
29
|
+
* in StructuredSessionManager. Annotate against the combined session list so a
|
|
30
|
+
* structured conversation is not also exposed as a recoverable native history
|
|
31
|
+
* entry. Return copies for matches because ProcessManager caches scan results.
|
|
32
|
+
*/
|
|
33
|
+
export declare function markManagedProviderHistory<T extends ProviderHistorySession>(history: T[], sessions: SessionSnapshot[], provider: "claude" | "codex"): T[];
|
|
34
|
+
export declare function registerSessionRoutes(app: Express, processes: ProcessManager, structured: StructuredSessionManager, storage: WandStorage, defaultMode: ExecutionMode, config: WandConfig, sessions: SessionRegistry, onSessionCreated?: (cwd: string | undefined | null) => void): void;
|
|
35
|
+
export declare function registerClaudeHistoryRoutes(app: Express, processes: ProcessManager, structured: StructuredSessionManager, storage: WandStorage, sessionRegistry: SessionRegistry): void;
|
|
23
36
|
export {};
|