@co0ontty/wand 0.2.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/README.md +13 -0
- package/dist/auth.d.ts +5 -0
- package/dist/auth.js +54 -0
- package/dist/cert.d.ts +8 -0
- package/dist/cert.js +124 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +121 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.js +120 -0
- package/dist/message-parser.d.ts +2 -0
- package/dist/message-parser.js +189 -0
- package/dist/process-manager.d.ts +59 -0
- package/dist/process-manager.js +1132 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +875 -0
- package/dist/session-logger.d.ts +23 -0
- package/dist/session-logger.js +78 -0
- package/dist/storage.d.ts +32 -0
- package/dist/storage.js +181 -0
- package/dist/types.d.ts +105 -0
- package/dist/types.js +1 -0
- package/dist/web-ui/content/scripts.js +4908 -0
- package/dist/web-ui/content/styles.css +4018 -0
- package/dist/web-ui/index.d.ts +1 -0
- package/dist/web-ui/index.js +42 -0
- package/dist/web-ui/scripts.d.ts +1 -0
- package/dist/web-ui/scripts.js +15 -0
- package/dist/web-ui/styles.d.ts +1 -0
- package/dist/web-ui/styles.js +13 -0
- package/dist/web-ui/utils.d.ts +4 -0
- package/dist/web-ui/utils.js +12 -0
- package/dist/web-ui.d.ts +1 -0
- package/dist/web-ui.js +2 -0
- package/package.json +57 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,875 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { readdir } from "node:fs/promises";
|
|
3
|
+
import { createServer as createHttpServer } from "node:http";
|
|
4
|
+
import { createServer as createHttpsServer } from "node:https";
|
|
5
|
+
import { exec } from "node:child_process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import process from "node:process";
|
|
9
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
10
|
+
const execAsync = promisify(exec);
|
|
11
|
+
import { createSession, revokeSession, setAuthStorage, validateSession } from "./auth.js";
|
|
12
|
+
import { ensureCertificates } from "./cert.js";
|
|
13
|
+
import { isExecutionMode, resolveConfigDir } from "./config.js";
|
|
14
|
+
import { ProcessManager } from "./process-manager.js";
|
|
15
|
+
import { resolveDatabasePath, WandStorage } from "./storage.js";
|
|
16
|
+
import { renderApp } from "./web-ui.js";
|
|
17
|
+
import { parseMessages } from "./message-parser.js";
|
|
18
|
+
function getErrorMessage(error, fallback) {
|
|
19
|
+
return error instanceof Error ? error.message : fallback;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Check if a directory is inside a git repository
|
|
23
|
+
*/
|
|
24
|
+
async function isGitRepo(dirPath) {
|
|
25
|
+
try {
|
|
26
|
+
await execAsync("git rev-parse --is-inside-work-tree", { cwd: dirPath });
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Get the git repository root directory
|
|
35
|
+
*/
|
|
36
|
+
async function getGitRepoRoot(dirPath) {
|
|
37
|
+
try {
|
|
38
|
+
const { stdout } = await execAsync("git rev-parse --show-toplevel", { cwd: dirPath });
|
|
39
|
+
return stdout.trim();
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Get git status for all files in a directory
|
|
47
|
+
* Returns a map of relative file paths to their git status
|
|
48
|
+
*/
|
|
49
|
+
async function getGitStatusMap(gitRoot) {
|
|
50
|
+
const statusMap = new Map();
|
|
51
|
+
try {
|
|
52
|
+
// Get git status in porcelain format (stable for parsing)
|
|
53
|
+
// -uno: don't list untracked files (we'll get them separately)
|
|
54
|
+
const { stdout: stagedStdout } = await execAsync("git status --porcelain -uno", { cwd: gitRoot });
|
|
55
|
+
// Get untracked files separately
|
|
56
|
+
const { stdout: untrackedStdout } = await execAsync("git ls-files --others --exclude-standard", { cwd: gitRoot });
|
|
57
|
+
// Parse staged/unstaged changes
|
|
58
|
+
const lines = stagedStdout.split("\n").filter(line => line.trim());
|
|
59
|
+
for (const line of lines) {
|
|
60
|
+
if (line.length < 4)
|
|
61
|
+
continue;
|
|
62
|
+
const stagedChar = line[0];
|
|
63
|
+
const unstagedChar = line[1];
|
|
64
|
+
const filePath = line.slice(3).trim();
|
|
65
|
+
if (!filePath)
|
|
66
|
+
continue;
|
|
67
|
+
const status = {};
|
|
68
|
+
// Parse staged status
|
|
69
|
+
if (stagedChar === "M")
|
|
70
|
+
status.staged = "modified";
|
|
71
|
+
else if (stagedChar === "A")
|
|
72
|
+
status.staged = "added";
|
|
73
|
+
else if (stagedChar === "D")
|
|
74
|
+
status.staged = "deleted";
|
|
75
|
+
else if (stagedChar === "R")
|
|
76
|
+
status.staged = "renamed";
|
|
77
|
+
// Parse unstaged status
|
|
78
|
+
if (unstagedChar === "M")
|
|
79
|
+
status.unstaged = "modified";
|
|
80
|
+
else if (unstagedChar === "D")
|
|
81
|
+
status.unstaged = "deleted";
|
|
82
|
+
statusMap.set(filePath, status);
|
|
83
|
+
}
|
|
84
|
+
// Parse untracked files
|
|
85
|
+
const untrackedFiles = untrackedStdout.split("\n").filter(line => line.trim());
|
|
86
|
+
for (const filePath of untrackedFiles) {
|
|
87
|
+
const existing = statusMap.get(filePath);
|
|
88
|
+
if (existing) {
|
|
89
|
+
existing.untracked = true;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
statusMap.set(filePath, { untracked: true });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return statusMap;
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
// Git command failed, return empty map
|
|
99
|
+
return statusMap;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Enrich file entries with git status
|
|
104
|
+
*/
|
|
105
|
+
async function enrichWithGitStatus(items, dirPath) {
|
|
106
|
+
try {
|
|
107
|
+
const gitRoot = await getGitRepoRoot(dirPath);
|
|
108
|
+
if (!gitRoot) {
|
|
109
|
+
return items;
|
|
110
|
+
}
|
|
111
|
+
const gitStatusMap = await getGitStatusMap(gitRoot);
|
|
112
|
+
return items.map((item) => {
|
|
113
|
+
// Get path relative to git root
|
|
114
|
+
const relativePath = path.relative(gitRoot, item.path);
|
|
115
|
+
// Normalize path separators for cross-platform compatibility
|
|
116
|
+
const normalizedPath = relativePath.replace(/\\/g, '/');
|
|
117
|
+
const gitStatus = gitStatusMap.get(normalizedPath);
|
|
118
|
+
return {
|
|
119
|
+
...item,
|
|
120
|
+
gitStatus: gitStatus || undefined
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return items;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Simple in-memory rate limiter for login attempts
|
|
129
|
+
const loginAttempts = new Map();
|
|
130
|
+
const RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes
|
|
131
|
+
const RATE_LIMIT_MAX = 10; // 10 attempts per window
|
|
132
|
+
function checkRateLimit(ip) {
|
|
133
|
+
const now = Date.now();
|
|
134
|
+
const record = loginAttempts.get(ip);
|
|
135
|
+
if (!record || now > record.resetAt) {
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
return record.count < RATE_LIMIT_MAX;
|
|
139
|
+
}
|
|
140
|
+
function recordFailedLogin(ip) {
|
|
141
|
+
const now = Date.now();
|
|
142
|
+
const record = loginAttempts.get(ip);
|
|
143
|
+
if (!record || now > record.resetAt) {
|
|
144
|
+
loginAttempts.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
record.count++;
|
|
148
|
+
}
|
|
149
|
+
function resetRateLimit(ip) {
|
|
150
|
+
loginAttempts.delete(ip);
|
|
151
|
+
}
|
|
152
|
+
function cleanupRateLimiter() {
|
|
153
|
+
const now = Date.now();
|
|
154
|
+
for (const [ip, record] of loginAttempts.entries()) {
|
|
155
|
+
if (now > record.resetAt) {
|
|
156
|
+
loginAttempts.delete(ip);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// Cleanup rate limiter every 5 minutes
|
|
161
|
+
setInterval(cleanupRateLimiter, 5 * 60 * 1000);
|
|
162
|
+
// Catch-all for unexpected startup errors
|
|
163
|
+
process.on("uncaughtException", (err) => {
|
|
164
|
+
wandError("服务器异常", err.message, "请检查配置是否正确,或尝试重启服务。");
|
|
165
|
+
process.exit(1);
|
|
166
|
+
});
|
|
167
|
+
process.on("unhandledRejection", (reason) => {
|
|
168
|
+
const msg = reason instanceof Error ? reason.message : String(reason);
|
|
169
|
+
wandError("未处理的异步错误", msg);
|
|
170
|
+
});
|
|
171
|
+
// ── Friendly error / warn / info helpers ──────────────────────────────────
|
|
172
|
+
function wandError(label, message, suggestion) {
|
|
173
|
+
process.stderr.write(`\n✗ [wand] ${label}:${message}\n`);
|
|
174
|
+
if (suggestion)
|
|
175
|
+
process.stderr.write(` 解决方法:${suggestion}\n`);
|
|
176
|
+
process.stderr.write("\n");
|
|
177
|
+
}
|
|
178
|
+
function wandWarn(message, hint) {
|
|
179
|
+
process.stderr.write(`⚠️ [wand] 警告:${message}\n`);
|
|
180
|
+
if (hint)
|
|
181
|
+
process.stderr.write(` 提示:${hint}\n`);
|
|
182
|
+
}
|
|
183
|
+
function wandInfo(message) {
|
|
184
|
+
process.stdout.write(`ℹ️ [wand] ${message}\n`);
|
|
185
|
+
}
|
|
186
|
+
export async function startServer(config, configPath) {
|
|
187
|
+
const app = express();
|
|
188
|
+
const storage = new WandStorage(resolveDatabasePath(configPath));
|
|
189
|
+
setAuthStorage(storage);
|
|
190
|
+
const processes = new ProcessManager(config, storage, resolveConfigDir(configPath));
|
|
191
|
+
const useHttps = config.https !== false; // Default to true
|
|
192
|
+
const protocol = useHttps ? "https" : "http";
|
|
193
|
+
app.use(express.json({ limit: "1mb" }));
|
|
194
|
+
app.use("/vendor/xterm", express.static(path.resolve(process.cwd(), "node_modules/xterm")));
|
|
195
|
+
app.use("/vendor/xterm-addon-fit", express.static(path.resolve(process.cwd(), "node_modules/@xterm/addon-fit")));
|
|
196
|
+
app.get("/", (_req, res) => {
|
|
197
|
+
res.type("html").send(renderApp(configPath));
|
|
198
|
+
});
|
|
199
|
+
// PWA manifest
|
|
200
|
+
app.get("/manifest.json", (_req, res) => {
|
|
201
|
+
res.type("json").send(JSON.stringify({
|
|
202
|
+
name: "Wand Console",
|
|
203
|
+
short_name: "Wand",
|
|
204
|
+
description: "Local CLI Console for Vibe Coding",
|
|
205
|
+
start_url: "/",
|
|
206
|
+
display: "standalone",
|
|
207
|
+
background_color: "#f6f1e8",
|
|
208
|
+
theme_color: "#c5653d",
|
|
209
|
+
orientation: "any",
|
|
210
|
+
icons: [
|
|
211
|
+
{ src: "/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any maskable" },
|
|
212
|
+
{ src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any maskable" }
|
|
213
|
+
],
|
|
214
|
+
categories: ["developer tools", "productivity"],
|
|
215
|
+
shortcuts: [
|
|
216
|
+
{ name: "New Session", short_name: "New", url: "/?action=new", description: "Start a new CLI session" }
|
|
217
|
+
]
|
|
218
|
+
}));
|
|
219
|
+
});
|
|
220
|
+
// PWA icons (SVG data URL converted to simple PNG-like response)
|
|
221
|
+
const iconSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
|
|
222
|
+
<defs><linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
223
|
+
<stop offset="0%" style="stop-color:#d77a52"/>
|
|
224
|
+
<stop offset="100%" style="stop-color:#a95130"/>
|
|
225
|
+
</linearGradient></defs>
|
|
226
|
+
<rect width="192" height="192" rx="38" fill="url(#g)"/>
|
|
227
|
+
<text x="96" y="128" text-anchor="middle" font-family="system-ui,sans-serif" font-size="88" font-weight="700" fill="white">W</text>
|
|
228
|
+
</svg>`;
|
|
229
|
+
app.get("/icon-192.png", (_req, res) => {
|
|
230
|
+
res.type("svg").send(iconSvg);
|
|
231
|
+
});
|
|
232
|
+
app.get("/icon-512.png", (_req, res) => {
|
|
233
|
+
res.type("svg").send(iconSvg);
|
|
234
|
+
});
|
|
235
|
+
// Service Worker for offline support
|
|
236
|
+
app.get("/sw.js", (_req, res) => {
|
|
237
|
+
res.type("javascript").send(`
|
|
238
|
+
const CACHE_NAME = 'wand-v1';
|
|
239
|
+
const STATIC_ASSETS = [
|
|
240
|
+
'/',
|
|
241
|
+
'/vendor/xterm/css/xterm.css',
|
|
242
|
+
'/vendor/xterm/lib/xterm.js',
|
|
243
|
+
'/vendor/xterm-addon-fit/lib/addon-fit.js'
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
self.addEventListener('install', (event) => {
|
|
247
|
+
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)));
|
|
248
|
+
self.skipWaiting();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
self.addEventListener('activate', (event) => {
|
|
252
|
+
event.waitUntil(caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))));
|
|
253
|
+
self.clients.claim();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
self.addEventListener('fetch', (event) => {
|
|
257
|
+
const url = new URL(event.request.url);
|
|
258
|
+
// API calls should always go to network
|
|
259
|
+
if (url.pathname.startsWith('/api/')) {
|
|
260
|
+
event.respondWith(fetch(event.request).catch(() => new Response(JSON.stringify({ error: 'Offline' }), { status: 503, headers: { 'Content-Type': 'application/json' } })));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
// Static assets: cache first, network fallback
|
|
264
|
+
event.respondWith(caches.match(event.request).then((cached) => cached || fetch(event.request).then((response) => {
|
|
265
|
+
if (response.ok && event.request.method === 'GET') {
|
|
266
|
+
const clone = response.clone();
|
|
267
|
+
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
|
268
|
+
}
|
|
269
|
+
return response;
|
|
270
|
+
}).catch(() => caches.match('/'))));
|
|
271
|
+
});
|
|
272
|
+
`);
|
|
273
|
+
});
|
|
274
|
+
app.post("/api/login", (req, res) => {
|
|
275
|
+
const clientIp = req.ip || req.socket.remoteAddress || "unknown";
|
|
276
|
+
if (!checkRateLimit(clientIp)) {
|
|
277
|
+
res.status(429).json({ error: "登录尝试次数过多,请在 15 分钟后再试。" });
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const { password } = req.body;
|
|
281
|
+
// Check password: prefer database password, fallback to config password
|
|
282
|
+
const dbPassword = storage.getPassword();
|
|
283
|
+
const effectivePassword = dbPassword ?? config.password;
|
|
284
|
+
if (password !== effectivePassword) {
|
|
285
|
+
recordFailedLogin(clientIp);
|
|
286
|
+
res.status(401).json({ error: "密码错误,请重试。" });
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
resetRateLimit(clientIp);
|
|
290
|
+
const token = createSession();
|
|
291
|
+
res.cookie("wand_session", token, {
|
|
292
|
+
httpOnly: true,
|
|
293
|
+
sameSite: "strict",
|
|
294
|
+
secure: useHttps,
|
|
295
|
+
maxAge: 1000 * 60 * 60 * 12
|
|
296
|
+
});
|
|
297
|
+
res.json({ ok: true });
|
|
298
|
+
});
|
|
299
|
+
app.post("/api/logout", (req, res) => {
|
|
300
|
+
revokeSession(readSessionCookie(req));
|
|
301
|
+
res.clearCookie("wand_session");
|
|
302
|
+
res.json({ ok: true });
|
|
303
|
+
});
|
|
304
|
+
// Set password endpoint (requires auth)
|
|
305
|
+
app.post("/api/set-password", requireAuth, (req, res) => {
|
|
306
|
+
const { password } = req.body;
|
|
307
|
+
if (!password || password.length < 6) {
|
|
308
|
+
res.status(400).json({ error: "密码长度至少为 6 个字符。" });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
storage.setPassword(password);
|
|
312
|
+
res.json({ ok: true });
|
|
313
|
+
});
|
|
314
|
+
app.use("/api", requireAuth);
|
|
315
|
+
app.get("/api/config", (_req, res) => {
|
|
316
|
+
res.json({
|
|
317
|
+
host: config.host,
|
|
318
|
+
port: config.port,
|
|
319
|
+
defaultMode: config.defaultMode,
|
|
320
|
+
defaultCwd: config.defaultCwd,
|
|
321
|
+
commandPresets: config.commandPresets
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
app.get("/api/sessions", (_req, res) => {
|
|
325
|
+
res.json(processes.list());
|
|
326
|
+
});
|
|
327
|
+
app.get("/api/path-suggestions", async (req, res) => {
|
|
328
|
+
const query = typeof req.query.q === "string" ? req.query.q : "";
|
|
329
|
+
try {
|
|
330
|
+
const suggestions = await listPathSuggestions(query, config.defaultCwd);
|
|
331
|
+
res.json(suggestions);
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
res.status(400).json({ error: getErrorMessage(error, "无法加载路径建议。") });
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
app.get("/api/directory", async (req, res) => {
|
|
338
|
+
const q = typeof req.query.q === "string" ? req.query.q : "";
|
|
339
|
+
const includeGitStatus = req.query.gitStatus === "true";
|
|
340
|
+
const targetPath = path.resolve(process.cwd(), q);
|
|
341
|
+
// Security check: ensure the resolved path is within the current working directory
|
|
342
|
+
const allowedBase = process.cwd();
|
|
343
|
+
if (!targetPath.startsWith(allowedBase)) {
|
|
344
|
+
res.status(403).json({ error: "访问被拒绝:路径必须在项目目录内。" });
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
try {
|
|
348
|
+
const entries = await readdir(targetPath, { withFileTypes: true });
|
|
349
|
+
let items = entries
|
|
350
|
+
.sort((a, b) => {
|
|
351
|
+
// Directories first, then alphabetically
|
|
352
|
+
if (a.isDirectory() && !b.isDirectory())
|
|
353
|
+
return -1;
|
|
354
|
+
if (!a.isDirectory() && b.isDirectory())
|
|
355
|
+
return 1;
|
|
356
|
+
return a.name.localeCompare(b.name);
|
|
357
|
+
})
|
|
358
|
+
.slice(0, 100)
|
|
359
|
+
.map((entry) => ({
|
|
360
|
+
path: path.join(targetPath, entry.name),
|
|
361
|
+
name: entry.name,
|
|
362
|
+
type: entry.isDirectory() ? "dir" : "file"
|
|
363
|
+
}));
|
|
364
|
+
// Enrich with git status if requested
|
|
365
|
+
if (includeGitStatus) {
|
|
366
|
+
items = await enrichWithGitStatus(items, targetPath);
|
|
367
|
+
}
|
|
368
|
+
res.json(items);
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
res.status(400).json({ error: getErrorMessage(error, "无法读取目录。可能原因:路径不存在或权限不足。") });
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
// Folder picker API - starts from /tmp by default, supports navigation
|
|
375
|
+
app.get("/api/folders", async (req, res) => {
|
|
376
|
+
const q = typeof req.query.q === "string" ? req.query.q : "/tmp";
|
|
377
|
+
const targetPath = path.resolve(q);
|
|
378
|
+
// Security check: prevent accessing sensitive system paths
|
|
379
|
+
const blockedPaths = ['/etc', '/root', '/boot'];
|
|
380
|
+
for (const blocked of blockedPaths) {
|
|
381
|
+
if (targetPath.startsWith(blocked)) {
|
|
382
|
+
res.status(403).json({ error: "访问被拒绝:无法访问系统敏感目录。" });
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
try {
|
|
387
|
+
const entries = await readdir(targetPath, { withFileTypes: true });
|
|
388
|
+
const items = [];
|
|
389
|
+
// Add parent directory navigation (..)
|
|
390
|
+
const parentPath = path.dirname(targetPath);
|
|
391
|
+
if (parentPath !== targetPath) {
|
|
392
|
+
items.push({
|
|
393
|
+
path: parentPath,
|
|
394
|
+
name: "..",
|
|
395
|
+
type: "parent",
|
|
396
|
+
isParent: true
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
// Add subdirectories
|
|
400
|
+
entries
|
|
401
|
+
.filter((entry) => entry.isDirectory())
|
|
402
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
403
|
+
.slice(0, 100)
|
|
404
|
+
.forEach((entry) => {
|
|
405
|
+
items.push({
|
|
406
|
+
path: path.join(targetPath, entry.name),
|
|
407
|
+
name: entry.name,
|
|
408
|
+
type: "dir"
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
res.json({
|
|
412
|
+
currentPath: targetPath,
|
|
413
|
+
items: items
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
catch (error) {
|
|
417
|
+
if (error.code === 'ENOENT') {
|
|
418
|
+
res.status(404).json({ error: "路径不存在:" + q, currentPath: q, items: [] });
|
|
419
|
+
}
|
|
420
|
+
else if (error.code === 'EACCES') {
|
|
421
|
+
res.status(403).json({ error: "权限不足,无法访问:" + q, currentPath: q, items: [] });
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
res.status(400).json({ error: "无法读取目录:" + getErrorMessage(error, "未知错误"), currentPath: q, items: [] });
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
// Quick paths API - returns common paths for quick access
|
|
429
|
+
app.get("/api/quick-paths", async (req, res) => {
|
|
430
|
+
const home = process.env.HOME || process.env.USERPROFILE || '/home';
|
|
431
|
+
const quickPaths = [
|
|
432
|
+
{ path: "/tmp", name: "临时目录", icon: "🗑️" },
|
|
433
|
+
{ path: home, name: "主目录", icon: "🏠" },
|
|
434
|
+
{ path: process.cwd(), name: "当前目录", icon: "📂" },
|
|
435
|
+
{ path: "/", name: "根目录", icon: "📁" }
|
|
436
|
+
];
|
|
437
|
+
res.json(quickPaths);
|
|
438
|
+
});
|
|
439
|
+
app.get("/api/favorite-paths", (_req, res) => {
|
|
440
|
+
const stored = storage.getConfigValue("favorite_paths");
|
|
441
|
+
const favorites = stored ? JSON.parse(stored) : [];
|
|
442
|
+
res.json(favorites);
|
|
443
|
+
});
|
|
444
|
+
app.post("/api/favorite-paths", (req, res) => {
|
|
445
|
+
const { path: favPath, name, icon } = req.body;
|
|
446
|
+
if (!favPath) {
|
|
447
|
+
res.status(400).json({ error: "路径不能为空。" });
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
const stored = storage.getConfigValue("favorite_paths");
|
|
451
|
+
const favorites = stored ? JSON.parse(stored) : [];
|
|
452
|
+
// Check if already exists
|
|
453
|
+
if (favorites.some((f) => f.path === favPath)) {
|
|
454
|
+
res.status(400).json({ error: "该路径已在收藏列表中。" });
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
const newFavorite = {
|
|
458
|
+
path: favPath,
|
|
459
|
+
name: name || path.basename(favPath),
|
|
460
|
+
icon: icon || "⭐",
|
|
461
|
+
addedAt: new Date().toISOString()
|
|
462
|
+
};
|
|
463
|
+
favorites.push(newFavorite);
|
|
464
|
+
storage.setConfigValue("favorite_paths", JSON.stringify(favorites));
|
|
465
|
+
res.status(201).json(newFavorite);
|
|
466
|
+
});
|
|
467
|
+
app.delete("/api/favorite-paths", (req, res) => {
|
|
468
|
+
const { path: delPath } = req.body;
|
|
469
|
+
if (!delPath) {
|
|
470
|
+
res.status(400).json({ error: "路径不能为空。" });
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
const stored = storage.getConfigValue("favorite_paths");
|
|
474
|
+
const favorites = stored ? JSON.parse(stored) : [];
|
|
475
|
+
const index = favorites.findIndex((f) => f.path === delPath);
|
|
476
|
+
if (index === -1) {
|
|
477
|
+
res.status(404).json({ error: "未找到该收藏路径。" });
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
favorites.splice(index, 1);
|
|
481
|
+
storage.setConfigValue("favorite_paths", JSON.stringify(favorites));
|
|
482
|
+
res.json({ ok: true });
|
|
483
|
+
});
|
|
484
|
+
const MAX_RECENT_PATHS = 10;
|
|
485
|
+
app.get("/api/recent-paths", (_req, res) => {
|
|
486
|
+
const stored = storage.getConfigValue("recent_paths");
|
|
487
|
+
const recent = stored ? JSON.parse(stored) : [];
|
|
488
|
+
res.json(recent);
|
|
489
|
+
});
|
|
490
|
+
app.post("/api/recent-paths", (req, res) => {
|
|
491
|
+
const { path: usedPath } = req.body;
|
|
492
|
+
if (!usedPath) {
|
|
493
|
+
res.status(400).json({ error: "路径不能为空。" });
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const stored = storage.getConfigValue("recent_paths");
|
|
497
|
+
let recent = stored ? JSON.parse(stored) : [];
|
|
498
|
+
// Remove existing entry for this path (to update position)
|
|
499
|
+
recent = recent.filter((r) => r.path !== usedPath);
|
|
500
|
+
// Add to front
|
|
501
|
+
const newRecent = {
|
|
502
|
+
path: usedPath,
|
|
503
|
+
name: path.basename(usedPath),
|
|
504
|
+
lastUsedAt: new Date().toISOString()
|
|
505
|
+
};
|
|
506
|
+
recent.unshift(newRecent);
|
|
507
|
+
// Keep only last N entries
|
|
508
|
+
recent = recent.slice(0, MAX_RECENT_PATHS);
|
|
509
|
+
storage.setConfigValue("recent_paths", JSON.stringify(recent));
|
|
510
|
+
res.json(newRecent);
|
|
511
|
+
});
|
|
512
|
+
// ============ Path Validation API ============
|
|
513
|
+
app.get("/api/validate-path", async (req, res) => {
|
|
514
|
+
const inputPath = typeof req.query.path === "string" ? req.query.path : "";
|
|
515
|
+
if (!inputPath.trim()) {
|
|
516
|
+
res.json({ valid: false, error: "路径不能为空" });
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
try {
|
|
520
|
+
const resolvedPath = path.resolve(inputPath);
|
|
521
|
+
const stats = await import("node:fs/promises").then(fs => fs.stat(resolvedPath));
|
|
522
|
+
if (!stats.isDirectory()) {
|
|
523
|
+
res.json({ valid: false, error: "路径不是目录", resolvedPath });
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
// Check read permission
|
|
527
|
+
try {
|
|
528
|
+
await readdir(resolvedPath);
|
|
529
|
+
res.json({ valid: true, resolvedPath, name: path.basename(resolvedPath) });
|
|
530
|
+
}
|
|
531
|
+
catch (permError) {
|
|
532
|
+
res.json({ valid: false, error: "没有读取权限", resolvedPath });
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
catch (error) {
|
|
536
|
+
const err = error;
|
|
537
|
+
if (err.code === "ENOENT") {
|
|
538
|
+
res.json({ valid: false, error: "路径不存在" });
|
|
539
|
+
}
|
|
540
|
+
else if (err.code === "EACCES") {
|
|
541
|
+
res.json({ valid: false, error: "没有访问权限" });
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
res.json({ valid: false, error: `无效路径: ${err.message}` });
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
// File search API - supports fuzzy matching across directory tree
|
|
549
|
+
app.get("/api/file-search", async (req, res) => {
|
|
550
|
+
const query = typeof req.query.q === "string" ? req.query.q : "";
|
|
551
|
+
const cwd = typeof req.query.cwd === "string" ? req.query.cwd : process.cwd();
|
|
552
|
+
const maxDepth = typeof req.query.depth === "string" ? parseInt(req.query.depth, 10) : 5;
|
|
553
|
+
const maxResults = typeof req.query.limit === "string" ? parseInt(req.query.limit, 10) : 50;
|
|
554
|
+
// Security check: ensure cwd is within allowed base
|
|
555
|
+
const allowedBase = process.cwd();
|
|
556
|
+
const resolvedCwd = path.resolve(allowedBase, cwd);
|
|
557
|
+
if (!resolvedCwd.startsWith(allowedBase)) {
|
|
558
|
+
res.status(403).json({ error: "访问被拒绝:路径必须在项目目录内。" });
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (!query) {
|
|
562
|
+
res.json({ results: [], query: "", cwd: resolvedCwd });
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
try {
|
|
566
|
+
const results = [];
|
|
567
|
+
const queryLower = query.toLowerCase();
|
|
568
|
+
// Recursive search function
|
|
569
|
+
async function searchDir(dirPath, currentDepth) {
|
|
570
|
+
if (currentDepth > maxDepth || results.length >= maxResults)
|
|
571
|
+
return;
|
|
572
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
573
|
+
for (const entry of entries) {
|
|
574
|
+
if (results.length >= maxResults)
|
|
575
|
+
break;
|
|
576
|
+
// Skip hidden files and node_modules
|
|
577
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules")
|
|
578
|
+
continue;
|
|
579
|
+
const entryPath = path.join(dirPath, entry.name);
|
|
580
|
+
const nameLower = entry.name.toLowerCase();
|
|
581
|
+
// Check if name matches query (fuzzy match)
|
|
582
|
+
const matchIndex = nameLower.indexOf(queryLower);
|
|
583
|
+
if (matchIndex !== -1) {
|
|
584
|
+
results.push({
|
|
585
|
+
path: entryPath,
|
|
586
|
+
name: entry.name,
|
|
587
|
+
type: entry.isDirectory() ? "dir" : "file",
|
|
588
|
+
matchScore: matchIndex // Lower score = better match (appears earlier in name)
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
// Recurse into directories
|
|
592
|
+
if (entry.isDirectory()) {
|
|
593
|
+
await searchDir(entryPath, currentDepth + 1);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
await searchDir(resolvedCwd, 0);
|
|
598
|
+
// Sort by match score (earlier match = better) and then alphabetically
|
|
599
|
+
results.sort((a, b) => {
|
|
600
|
+
if (a.matchScore !== b.matchScore)
|
|
601
|
+
return a.matchScore - b.matchScore;
|
|
602
|
+
return a.name.localeCompare(b.name);
|
|
603
|
+
});
|
|
604
|
+
res.json({ results: results.slice(0, maxResults), query, cwd: resolvedCwd });
|
|
605
|
+
}
|
|
606
|
+
catch (error) {
|
|
607
|
+
res.status(400).json({ error: getErrorMessage(error, "搜索失败。可能原因:路径不存在或权限不足。") });
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
app.get("/api/sessions/:id", (req, res) => {
|
|
611
|
+
const snapshot = processes.get(req.params.id);
|
|
612
|
+
if (!snapshot) {
|
|
613
|
+
res.status(404).json({ error: "未找到该会话,可能已被删除。" });
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (req.query.format === "chat") {
|
|
617
|
+
// Prefer structured messages from JSON chat mode, fall back to PTY parsing
|
|
618
|
+
const messages = snapshot.messages && snapshot.messages.length > 0
|
|
619
|
+
? snapshot.messages
|
|
620
|
+
: parseMessages(snapshot.output);
|
|
621
|
+
res.json({ ...snapshot, messages });
|
|
622
|
+
}
|
|
623
|
+
else {
|
|
624
|
+
res.json(snapshot);
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
app.post("/api/commands", (req, res) => {
|
|
628
|
+
const body = req.body;
|
|
629
|
+
if (!body.command?.trim()) {
|
|
630
|
+
res.status(400).json({ error: "请输入要执行的命令。" });
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
const initialInput = body.initialInput?.trim();
|
|
634
|
+
try {
|
|
635
|
+
const snapshot = processes.start(body.command, body.cwd, normalizeMode(body.mode, config.defaultMode), initialInput || undefined);
|
|
636
|
+
res.status(201).json(snapshot);
|
|
637
|
+
}
|
|
638
|
+
catch (error) {
|
|
639
|
+
res.status(400).json({ error: getErrorMessage(error, "无法启动命令。请检查命令是否正确安装。") });
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
app.post("/api/sessions/:id/input", (req, res) => {
|
|
643
|
+
const body = req.body;
|
|
644
|
+
try {
|
|
645
|
+
const snapshot = processes.sendInput(req.params.id, body.input ?? "", body.view);
|
|
646
|
+
res.json(snapshot);
|
|
647
|
+
}
|
|
648
|
+
catch (error) {
|
|
649
|
+
res.status(400).json({ error: getErrorMessage(error, "会话已结束,请启动新会话。") });
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
app.post("/api/sessions/:id/resize", (req, res) => {
|
|
653
|
+
const body = req.body;
|
|
654
|
+
try {
|
|
655
|
+
const snapshot = processes.resize(req.params.id, body.cols ?? 0, body.rows ?? 0);
|
|
656
|
+
res.json(snapshot);
|
|
657
|
+
}
|
|
658
|
+
catch (error) {
|
|
659
|
+
res.status(400).json({ error: getErrorMessage(error, "无法调整终端大小。") });
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
app.post("/api/sessions/:id/stop", (req, res) => {
|
|
663
|
+
try {
|
|
664
|
+
const snapshot = processes.stop(req.params.id);
|
|
665
|
+
res.json(snapshot);
|
|
666
|
+
}
|
|
667
|
+
catch (error) {
|
|
668
|
+
res.status(400).json({ error: getErrorMessage(error, "无法停止会话。") });
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
app.delete("/api/sessions/:id", (req, res) => {
|
|
672
|
+
try {
|
|
673
|
+
processes.delete(req.params.id);
|
|
674
|
+
res.json({ ok: true });
|
|
675
|
+
}
|
|
676
|
+
catch (error) {
|
|
677
|
+
res.status(400).json({ error: getErrorMessage(error, "无法删除会话。") });
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
await processes.runStartupCommands();
|
|
681
|
+
// Create server (HTTP or HTTPS) - useHttps and protocol already defined above
|
|
682
|
+
const server = useHttps
|
|
683
|
+
? (() => {
|
|
684
|
+
const ssl = ensureCertificates(resolveConfigDir(configPath));
|
|
685
|
+
return createHttpsServer({ key: ssl.key, cert: ssl.cert }, app);
|
|
686
|
+
})()
|
|
687
|
+
: createHttpServer(app);
|
|
688
|
+
const wss = new WebSocketServer({ server, path: "/ws" });
|
|
689
|
+
const wsClients = new Set();
|
|
690
|
+
const MAX_QUEUE_SIZE = 500; // Max messages in queue before applying backpressure
|
|
691
|
+
const OUTPUT_DEBOUNCE_MS = 50; // Debounce PTY output updates to reduce flicker
|
|
692
|
+
// Output debounce cache - batch rapid output events per session
|
|
693
|
+
const outputDebounceCache = new Map();
|
|
694
|
+
// Process send queue for a WebSocket client
|
|
695
|
+
function processWsQueue(client) {
|
|
696
|
+
if (client.sendInProgress || client.sendQueue.length === 0 || client.backpressurePaused) {
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
client.sendInProgress = true;
|
|
700
|
+
const message = client.sendQueue.shift();
|
|
701
|
+
if (client.ws.readyState === WebSocket.OPEN) {
|
|
702
|
+
client.ws.send(message, (err) => {
|
|
703
|
+
client.sendInProgress = false;
|
|
704
|
+
if (err) {
|
|
705
|
+
// Error sending, drop message
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
// Check backpressure threshold
|
|
709
|
+
const threshold = MAX_QUEUE_SIZE * 0.8;
|
|
710
|
+
if (client.backpressurePaused && client.sendQueue.length < threshold) {
|
|
711
|
+
client.backpressurePaused = false;
|
|
712
|
+
}
|
|
713
|
+
// Continue processing queue
|
|
714
|
+
processWsQueue(client);
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
else {
|
|
718
|
+
client.sendInProgress = false;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
// Broadcast process events to WebSocket clients with debouncing and backpressure control
|
|
722
|
+
processes.on("process", (event) => {
|
|
723
|
+
// Debounce output events to reduce flicker during rapid streaming
|
|
724
|
+
if (event.type === "output") {
|
|
725
|
+
const existing = outputDebounceCache.get(event.sessionId);
|
|
726
|
+
if (existing) {
|
|
727
|
+
clearTimeout(existing.timer);
|
|
728
|
+
}
|
|
729
|
+
const timer = setTimeout(() => {
|
|
730
|
+
outputDebounceCache.delete(event.sessionId);
|
|
731
|
+
broadcastEvent(event);
|
|
732
|
+
}, OUTPUT_DEBOUNCE_MS);
|
|
733
|
+
outputDebounceCache.set(event.sessionId, { event, timer });
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
// Non-output events (started, ended, status) are sent immediately
|
|
737
|
+
broadcastEvent(event);
|
|
738
|
+
});
|
|
739
|
+
function broadcastEvent(event) {
|
|
740
|
+
const message = JSON.stringify(event);
|
|
741
|
+
for (const client of wsClients) {
|
|
742
|
+
if (client.ws.readyState === WebSocket.OPEN) {
|
|
743
|
+
// Apply backpressure if queue is too large
|
|
744
|
+
if (client.sendQueue.length >= MAX_QUEUE_SIZE) {
|
|
745
|
+
client.backpressurePaused = true;
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
if (!client.backpressurePaused) {
|
|
749
|
+
client.sendQueue.push(message);
|
|
750
|
+
processWsQueue(client);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
wss.on("connection", (ws, req) => {
|
|
756
|
+
const sessionToken = readSessionCookie(req);
|
|
757
|
+
if (!sessionToken || !validateSession(sessionToken)) {
|
|
758
|
+
ws.close(1008, "Unauthorized");
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
const client = {
|
|
762
|
+
ws,
|
|
763
|
+
sendQueue: [],
|
|
764
|
+
sendInProgress: false,
|
|
765
|
+
backpressurePaused: false,
|
|
766
|
+
lastOutputBySession: new Map()
|
|
767
|
+
};
|
|
768
|
+
wsClients.add(client);
|
|
769
|
+
ws.on("close", () => {
|
|
770
|
+
wsClients.delete(client);
|
|
771
|
+
});
|
|
772
|
+
ws.on("error", () => {
|
|
773
|
+
// Already closed, ignore
|
|
774
|
+
});
|
|
775
|
+
ws.on("message", (data) => {
|
|
776
|
+
try {
|
|
777
|
+
const msg = JSON.parse(data.toString());
|
|
778
|
+
// Handle subscribe/unsubscribe for specific sessions
|
|
779
|
+
if (msg.type === "subscribe" && msg.sessionId) {
|
|
780
|
+
// Client wants updates for a specific session
|
|
781
|
+
const snapshot = processes.get(msg.sessionId);
|
|
782
|
+
if (snapshot) {
|
|
783
|
+
// Send full session snapshot including messages for reconnection recovery
|
|
784
|
+
ws.send(JSON.stringify({
|
|
785
|
+
type: "init",
|
|
786
|
+
sessionId: msg.sessionId,
|
|
787
|
+
data: {
|
|
788
|
+
...snapshot,
|
|
789
|
+
// Ensure messages are included for chat mode recovery
|
|
790
|
+
messages: snapshot.messages,
|
|
791
|
+
// Include full output for terminal mode recovery
|
|
792
|
+
output: snapshot.output
|
|
793
|
+
}
|
|
794
|
+
}));
|
|
795
|
+
}
|
|
796
|
+
else {
|
|
797
|
+
// Session not found - might be deleted or never existed
|
|
798
|
+
ws.send(JSON.stringify({
|
|
799
|
+
type: "error",
|
|
800
|
+
sessionId: msg.sessionId,
|
|
801
|
+
error: "Session not found"
|
|
802
|
+
}));
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
catch {
|
|
807
|
+
// Ignore malformed messages
|
|
808
|
+
}
|
|
809
|
+
});
|
|
810
|
+
});
|
|
811
|
+
// Start server
|
|
812
|
+
await new Promise((resolve, reject) => {
|
|
813
|
+
server.listen(config.port, config.host, () => {
|
|
814
|
+
const listenAddr = config.host === "0.0.0.0" ? "0.0.0.0 (所有接口)" : config.host;
|
|
815
|
+
process.stdout.write(`[wand] Web console listening on ${listenAddr}:${config.port}\n` +
|
|
816
|
+
`[wand] 本地访问: ${protocol}://127.0.0.1:${config.port}\n`);
|
|
817
|
+
resolve();
|
|
818
|
+
});
|
|
819
|
+
server.on("error", (err) => {
|
|
820
|
+
if (err.code === "EADDRINUSE") {
|
|
821
|
+
wandError(`端口 ${config.port} 已被占用`, `可能有另一个 Wand 进程正在运行。`, `解决方法(二选一):\n1. 在浏览器中访问当前运行的 Wand\n2. 或者终止占用端口的进程:\n kill $(lsof -ti :${config.port})\n\n如果你确定没有其他实例在运行,可能是有程序意外占用了端口。`);
|
|
822
|
+
process.exit(1);
|
|
823
|
+
}
|
|
824
|
+
reject(err);
|
|
825
|
+
});
|
|
826
|
+
});
|
|
827
|
+
// Print security warnings
|
|
828
|
+
if (!storage.hasCustomPassword() && config.password === "change-me") {
|
|
829
|
+
wandWarn("正在使用默认密码(change-me),任何能访问本机的人都可以登录。", "修改方法:在界面右上角「设置」中修改密码,或运行:node dist/cli.js config:set password <你的新密码>");
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
function requireAuth(req, res, next) {
|
|
833
|
+
if (!validateSession(readSessionCookie(req))) {
|
|
834
|
+
res.status(401).json({ error: "未授权,请先登录。" });
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
next();
|
|
838
|
+
}
|
|
839
|
+
function normalizeMode(input, fallback) {
|
|
840
|
+
return isExecutionMode(input) ? input : fallback;
|
|
841
|
+
}
|
|
842
|
+
function readSessionCookie(req) {
|
|
843
|
+
const cookie = req.headers.cookie;
|
|
844
|
+
if (!cookie) {
|
|
845
|
+
return undefined;
|
|
846
|
+
}
|
|
847
|
+
const match = cookie
|
|
848
|
+
.split(";")
|
|
849
|
+
.map((part) => part.trim())
|
|
850
|
+
.find((part) => part.startsWith("wand_session="));
|
|
851
|
+
return match?.slice("wand_session=".length);
|
|
852
|
+
}
|
|
853
|
+
async function listPathSuggestions(input, fallbackCwd) {
|
|
854
|
+
const normalizedInput = input.trim();
|
|
855
|
+
const baseInput = normalizedInput || fallbackCwd;
|
|
856
|
+
const resolvedInput = path.resolve(process.cwd(), baseInput);
|
|
857
|
+
const endsWithSeparator = /[\\/]$/.test(normalizedInput);
|
|
858
|
+
let searchDir = resolvedInput;
|
|
859
|
+
let partialName = "";
|
|
860
|
+
if (!endsWithSeparator) {
|
|
861
|
+
searchDir = path.dirname(resolvedInput);
|
|
862
|
+
partialName = path.basename(resolvedInput);
|
|
863
|
+
}
|
|
864
|
+
const entries = await readdir(searchDir, { withFileTypes: true });
|
|
865
|
+
return entries
|
|
866
|
+
.filter((entry) => entry.isDirectory())
|
|
867
|
+
.filter((entry) => !partialName || entry.name.toLowerCase().startsWith(partialName.toLowerCase()))
|
|
868
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
869
|
+
.slice(0, 8)
|
|
870
|
+
.map((entry) => ({
|
|
871
|
+
path: path.join(searchDir, entry.name),
|
|
872
|
+
name: entry.name,
|
|
873
|
+
isDirectory: true
|
|
874
|
+
}));
|
|
875
|
+
}
|