@co0ontty/wand 1.21.12 → 1.21.14

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/server.js CHANGED
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
2
2
  import compression from "compression";
3
3
  import express from "express";
4
4
  import { createReadStream, existsSync, readFileSync, writeFileSync } from "node:fs";
5
- import { mkdir, readdir, readFile, stat } from "node:fs/promises";
5
+ import { lstat, mkdir, readdir, readFile, stat } from "node:fs/promises";
6
6
  import { createServer as createHttpServer } from "node:http";
7
7
  import { createServer as createHttpsServer } from "node:https";
8
8
  import { exec, spawn } from "node:child_process";
@@ -536,6 +536,106 @@ function getLanguageFromExt(ext, filePath) {
536
536
  return "plaintext";
537
537
  return map[ext] || "plaintext";
538
538
  }
539
+ // ── File preview classification ──
540
+ const TEXT_PREVIEWABLE_EXTS = new Set([
541
+ ".md", ".markdown", ".mdown", ".mkd", ".mkdn", ".mdx",
542
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
543
+ ".json", ".jsonc", ".html", ".htm", ".css", ".scss", ".less",
544
+ ".py", ".rb", ".go", ".rs", ".java", ".c", ".cpp", ".h", ".hpp",
545
+ ".cs", ".swift", ".kt", ".scala", ".php", ".sh", ".bash", ".zsh", ".fish",
546
+ ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".env",
547
+ ".xml", ".sql", ".graphql", ".proto",
548
+ ".dockerfile", ".gitignore", ".editorconfig",
549
+ ".vue", ".svelte",
550
+ ".txt", ".log", ".diff", ".patch",
551
+ ".lua", ".r", ".dart", ".pl", ".pm",
552
+ ]);
553
+ const IMAGE_EXTS = new Set([
554
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif",
555
+ ".bmp", ".ico", ".heic", ".heif",
556
+ ]);
557
+ const VIDEO_EXTS = new Set([
558
+ ".mp4", ".webm", ".mov", ".mkv", ".m4v", ".ogv",
559
+ ]);
560
+ const AUDIO_EXTS = new Set([
561
+ ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".opus",
562
+ ]);
563
+ const PDF_EXTS = new Set([".pdf"]);
564
+ const MIME_BY_EXT = {
565
+ ".png": "image/png",
566
+ ".jpg": "image/jpeg",
567
+ ".jpeg": "image/jpeg",
568
+ ".gif": "image/gif",
569
+ ".webp": "image/webp",
570
+ ".svg": "image/svg+xml",
571
+ ".avif": "image/avif",
572
+ ".bmp": "image/bmp",
573
+ ".ico": "image/x-icon",
574
+ ".heic": "image/heic",
575
+ ".heif": "image/heif",
576
+ ".pdf": "application/pdf",
577
+ ".mp4": "video/mp4",
578
+ ".webm": "video/webm",
579
+ ".mov": "video/quicktime",
580
+ ".mkv": "video/x-matroska",
581
+ ".m4v": "video/x-m4v",
582
+ ".ogv": "video/ogg",
583
+ ".mp3": "audio/mpeg",
584
+ ".wav": "audio/wav",
585
+ ".ogg": "audio/ogg",
586
+ ".m4a": "audio/mp4",
587
+ ".flac": "audio/flac",
588
+ ".aac": "audio/aac",
589
+ ".opus": "audio/opus",
590
+ };
591
+ const TEXT_BASENAME_ALLOW = new Set([
592
+ "dockerfile", ".gitignore", ".dockerignore", ".env", ".env.local",
593
+ ".env.development", ".env.production", ".env.test",
594
+ "makefile", "readme", "license", "changelog",
595
+ ]);
596
+ function classifyFile(ext, baseName) {
597
+ const lowerExt = ext.toLowerCase();
598
+ const lowerBase = baseName.toLowerCase();
599
+ if (IMAGE_EXTS.has(lowerExt))
600
+ return "image";
601
+ if (PDF_EXTS.has(lowerExt))
602
+ return "pdf";
603
+ if (VIDEO_EXTS.has(lowerExt))
604
+ return "video";
605
+ if (AUDIO_EXTS.has(lowerExt))
606
+ return "audio";
607
+ if (TEXT_PREVIEWABLE_EXTS.has(lowerExt))
608
+ return "text";
609
+ if (TEXT_BASENAME_ALLOW.has(lowerBase))
610
+ return "text";
611
+ // Files with no extension that look like text-y dotfiles
612
+ if (lowerExt === "" && /^[a-z0-9._-]+$/i.test(lowerBase))
613
+ return "text";
614
+ return "binary";
615
+ }
616
+ function mimeForExt(ext) {
617
+ return MIME_BY_EXT[ext.toLowerCase()] || "application/octet-stream";
618
+ }
619
+ /** Hidden files that should still surface even when "show hidden" is off. */
620
+ const HIDDEN_ALLOWLIST = new Set([
621
+ ".gitignore", ".gitattributes", ".gitmodules",
622
+ ".env", ".env.local", ".env.example",
623
+ ".editorconfig", ".prettierrc", ".eslintrc",
624
+ ".dockerignore", ".npmrc", ".nvmrc",
625
+ ".browserslistrc", ".babelrc",
626
+ ]);
627
+ function isHiddenEntry(name) {
628
+ if (!name.startsWith("."))
629
+ return false;
630
+ if (HIDDEN_ALLOWLIST.has(name))
631
+ return false;
632
+ // Common patterns like `.env.production` are also kept visible
633
+ for (const allowed of HIDDEN_ALLOWLIST) {
634
+ if (name.startsWith(allowed + "."))
635
+ return false;
636
+ }
637
+ return true;
638
+ }
539
639
  export async function startServer(config, configPath) {
540
640
  const app = express();
541
641
  const storage = new WandStorage(resolveDatabasePath(configPath));
@@ -1034,9 +1134,11 @@ export async function startServer(config, configPath) {
1034
1134
  }
1035
1135
  });
1036
1136
  // ── File browsing ──
1137
+ const DIRECTORY_MAX_ITEMS = 200;
1037
1138
  app.get("/api/directory", async (req, res) => {
1038
1139
  const q = typeof req.query.q === "string" ? req.query.q : "";
1039
1140
  const includeGitStatus = req.query.gitStatus === "true";
1141
+ const showHidden = req.query.showHidden === "true";
1040
1142
  const targetPath = path.resolve(q || config.defaultCwd);
1041
1143
  if (isBlockedFolderPath(targetPath)) {
1042
1144
  res.status(403).json({ error: "访问被拒绝:无法访问系统敏感目录。" });
@@ -1044,30 +1146,49 @@ export async function startServer(config, configPath) {
1044
1146
  }
1045
1147
  try {
1046
1148
  const entries = await readdir(targetPath, { withFileTypes: true });
1047
- let items = entries
1048
- .sort((a, b) => {
1149
+ const visible = showHidden ? entries : entries.filter((e) => !isHiddenEntry(e.name));
1150
+ const sorted = visible.sort((a, b) => {
1049
1151
  if (a.isDirectory() && !b.isDirectory())
1050
1152
  return -1;
1051
1153
  if (!a.isDirectory() && b.isDirectory())
1052
1154
  return 1;
1053
1155
  return a.name.localeCompare(b.name);
1054
- })
1055
- .slice(0, 100)
1056
- .map((entry) => ({
1057
- path: path.join(targetPath, entry.name),
1058
- name: entry.name,
1059
- type: entry.isDirectory() ? "dir" : "file",
1156
+ });
1157
+ const total = sorted.length;
1158
+ const truncated = total > DIRECTORY_MAX_ITEMS;
1159
+ const sliced = sorted.slice(0, DIRECTORY_MAX_ITEMS);
1160
+ // Fetch size/mtime in parallel; tolerate per-entry failures.
1161
+ let items = await Promise.all(sliced.map(async (entry) => {
1162
+ const fullPath = path.join(targetPath, entry.name);
1163
+ const isDir = entry.isDirectory();
1164
+ const base = {
1165
+ path: fullPath,
1166
+ name: entry.name,
1167
+ type: isDir ? "dir" : "file",
1168
+ };
1169
+ if (isDir)
1170
+ return base;
1171
+ try {
1172
+ const st = await lstat(fullPath);
1173
+ base.size = st.size;
1174
+ base.mtime = st.mtime.toISOString();
1175
+ }
1176
+ catch {
1177
+ // Permission errors etc — leave size/mtime undefined.
1178
+ }
1179
+ return base;
1060
1180
  }));
1061
1181
  if (includeGitStatus) {
1062
1182
  items = await enrichWithGitStatus(items, targetPath);
1063
1183
  }
1064
- res.json(items);
1184
+ const payload = { items, truncated, total };
1185
+ res.json(payload);
1065
1186
  }
1066
1187
  catch (error) {
1067
1188
  res.status(400).json({ error: getErrorMessage(error, "无法读取目录。可能原因:路径不存在或权限不足。") });
1068
1189
  }
1069
1190
  });
1070
- const MAX_FILE_SIZE = 512 * 1024;
1191
+ const MAX_TEXT_PREVIEW_SIZE = 512 * 1024;
1071
1192
  app.get("/api/file-preview", async (req, res) => {
1072
1193
  const filePath = typeof req.query.path === "string" ? req.query.path : "";
1073
1194
  if (!filePath) {
@@ -1085,32 +1206,134 @@ export async function startServer(config, configPath) {
1085
1206
  res.status(400).json({ error: "Cannot preview a directory" });
1086
1207
  return;
1087
1208
  }
1088
- if (fileStat.size > MAX_FILE_SIZE) {
1089
- res.status(413).json({ error: "File too large", truncated: true, size: fileStat.size, maxSize: MAX_FILE_SIZE });
1209
+ const ext = path.extname(filePath).toLowerCase();
1210
+ const baseName = path.basename(filePath);
1211
+ const kind = classifyFile(ext, baseName);
1212
+ const mime = mimeForExt(ext);
1213
+ // Non-text kinds: respond with metadata so the client can pick a renderer.
1214
+ if (kind !== "text") {
1215
+ const payload = {
1216
+ kind,
1217
+ path: resolvedPath,
1218
+ name: baseName,
1219
+ ext,
1220
+ size: fileStat.size,
1221
+ mime,
1222
+ };
1223
+ res.json(payload);
1090
1224
  return;
1091
1225
  }
1092
- const ext = path.extname(filePath).toLowerCase();
1093
- const previewableExts = [
1094
- ".md", ".markdown", ".mdown", ".mkd", ".mkdn",
1095
- ".ts", ".tsx", ".js", ".jsx", ".json", ".html", ".css", ".scss", ".less",
1096
- ".py", ".rb", ".go", ".rs", ".java", ".c", ".cpp", ".h", ".hpp",
1097
- ".cs", ".swift", ".kt", ".scala", ".php", ".sh", ".bash", ".zsh",
1098
- ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".env",
1099
- ".xml", ".sql", ".graphql", ".proto",
1100
- ".dockerfile", ".gitignore", ".env", ".editorconfig",
1101
- ".mdx", ".vue", ".svelte",
1102
- ".txt", ".log", ".diff", ".patch",
1103
- ];
1104
- const isText = previewableExts.includes(ext) ||
1105
- ext === "" ||
1106
- [".gitignore", "dockerfile", ".env.local", ".env.development"].some((e) => filePath.toLowerCase().endsWith(e));
1107
- if (!isText) {
1108
- res.status(415).json({ error: "Unsupported file type", ext });
1226
+ // Text/code preview path — still subject to the 512 KB cap.
1227
+ if (fileStat.size > MAX_TEXT_PREVIEW_SIZE) {
1228
+ res.status(413).json({
1229
+ error: "文件太大,无法在线预览(限 512 KB)。",
1230
+ truncated: true,
1231
+ size: fileStat.size,
1232
+ maxSize: MAX_TEXT_PREVIEW_SIZE,
1233
+ });
1109
1234
  return;
1110
1235
  }
1111
1236
  const content = await readFile(resolvedPath, "utf-8");
1112
1237
  const lang = getLanguageFromExt(ext, filePath);
1113
- res.json({ path: resolvedPath, name: path.basename(filePath), ext, lang, content, size: fileStat.size });
1238
+ const payload = {
1239
+ kind: "text",
1240
+ path: resolvedPath,
1241
+ name: baseName,
1242
+ ext,
1243
+ size: fileStat.size,
1244
+ mime,
1245
+ lang,
1246
+ content,
1247
+ };
1248
+ res.json(payload);
1249
+ }
1250
+ catch (error) {
1251
+ res.status(400).json({ error: getErrorMessage(error, "Failed to read file") });
1252
+ }
1253
+ });
1254
+ // Streams the raw bytes of a file for inline media previews (image/PDF/video/audio)
1255
+ // and downloads. Honors HTTP Range so video/audio scrubbing works.
1256
+ const RAW_MAX_BYTES_BY_KIND = {
1257
+ text: 5 * 1024 * 1024,
1258
+ image: 50 * 1024 * 1024,
1259
+ pdf: 50 * 1024 * 1024,
1260
+ video: 200 * 1024 * 1024,
1261
+ audio: 200 * 1024 * 1024,
1262
+ binary: 50 * 1024 * 1024,
1263
+ };
1264
+ app.get("/api/file-raw", async (req, res) => {
1265
+ const filePath = typeof req.query.path === "string" ? req.query.path : "";
1266
+ const asDownload = req.query.download === "1" || req.query.download === "true";
1267
+ if (!filePath) {
1268
+ res.status(400).json({ error: "Missing path parameter" });
1269
+ return;
1270
+ }
1271
+ const resolvedPath = path.resolve(filePath);
1272
+ if (isBlockedFolderPath(resolvedPath)) {
1273
+ res.status(403).json({ error: "Access denied" });
1274
+ return;
1275
+ }
1276
+ try {
1277
+ const fileStat = await stat(resolvedPath);
1278
+ if (!fileStat.isFile()) {
1279
+ res.status(400).json({ error: "Not a regular file" });
1280
+ return;
1281
+ }
1282
+ const ext = path.extname(filePath).toLowerCase();
1283
+ const baseName = path.basename(filePath);
1284
+ const kind = classifyFile(ext, baseName);
1285
+ const cap = RAW_MAX_BYTES_BY_KIND[kind] ?? RAW_MAX_BYTES_BY_KIND.binary;
1286
+ if (fileStat.size > cap) {
1287
+ res.status(413).json({
1288
+ error: `文件超出可在线预览的上限(${Math.round(cap / 1024 / 1024)} MB)。`,
1289
+ size: fileStat.size,
1290
+ maxSize: cap,
1291
+ });
1292
+ return;
1293
+ }
1294
+ const mime = mimeForExt(ext);
1295
+ // SVG can be served with its proper type; binary fallback uses octet-stream.
1296
+ const contentType = kind === "binary" ? "application/octet-stream" : mime;
1297
+ // Encode the filename for Content-Disposition (RFC 5987).
1298
+ const encodedName = encodeURIComponent(baseName);
1299
+ const disposition = asDownload
1300
+ ? `attachment; filename*=UTF-8''${encodedName}`
1301
+ : `inline; filename*=UTF-8''${encodedName}`;
1302
+ const total = fileStat.size;
1303
+ const range = req.headers.range;
1304
+ // Safe to cache raw bytes briefly inside the user's browser.
1305
+ res.setHeader("Cache-Control", "private, max-age=60");
1306
+ res.setHeader("Content-Type", contentType);
1307
+ res.setHeader("Content-Disposition", disposition);
1308
+ res.setHeader("Accept-Ranges", "bytes");
1309
+ res.setHeader("X-Content-Type-Options", "nosniff");
1310
+ if (range && /^bytes=/.test(range)) {
1311
+ const match = /^bytes=(\d*)-(\d*)$/.exec(range);
1312
+ if (!match) {
1313
+ res.status(416).setHeader("Content-Range", `bytes */${total}`).end();
1314
+ return;
1315
+ }
1316
+ const startStr = match[1];
1317
+ const endStr = match[2];
1318
+ let start = startStr === "" ? 0 : parseInt(startStr, 10);
1319
+ let end = endStr === "" ? total - 1 : parseInt(endStr, 10);
1320
+ if (Number.isNaN(start) || Number.isNaN(end) || start > end || start < 0 || end >= total) {
1321
+ res.status(416).setHeader("Content-Range", `bytes */${total}`).end();
1322
+ return;
1323
+ }
1324
+ const chunkSize = end - start + 1;
1325
+ res.status(206);
1326
+ res.setHeader("Content-Range", `bytes ${start}-${end}/${total}`);
1327
+ res.setHeader("Content-Length", String(chunkSize));
1328
+ const stream = createReadStream(resolvedPath, { start, end });
1329
+ stream.on("error", () => res.destroy());
1330
+ stream.pipe(res);
1331
+ return;
1332
+ }
1333
+ res.setHeader("Content-Length", String(total));
1334
+ const stream = createReadStream(resolvedPath);
1335
+ stream.on("error", () => res.destroy());
1336
+ stream.pipe(res);
1114
1337
  }
1115
1338
  catch (error) {
1116
1339
  res.status(400).json({ error: getErrorMessage(error, "Failed to read file") });
@@ -18,6 +18,12 @@ export declare class StructuredSessionManager {
18
18
  private readonly sessions;
19
19
  private readonly pendingChildren;
20
20
  private readonly pendingSdkAbort;
21
+ /**
22
+ * Active SDK Query handle per session, kept around so we can call
23
+ * `query.interrupt()` for a graceful stop instead of aborting via signal.
24
+ * Only populated while an SDK call is in flight.
25
+ */
26
+ private readonly pendingSdkQueries;
21
27
  private readonly interruptedWith;
22
28
  /** Last wall-clock time (ms) we did a full saveSession for a streaming session. */
23
29
  private readonly lastStreamSaveAt;
@@ -70,7 +76,6 @@ export declare class StructuredSessionManager {
70
76
  private emit;
71
77
  private resolvePermission;
72
78
  private incrementApprovalStats;
73
- private buildPermissionArgs;
74
79
  private buildCodexArgs;
75
80
  private runCodexStreaming;
76
81
  /**
@@ -96,7 +101,6 @@ export declare class StructuredSessionManager {
96
101
  * SDKAssistantMessage with the authoritative complete content.
97
102
  */
98
103
  private runClaudeSdkStreaming;
99
- private _runClaudeSdkStreamingAsync;
100
104
  private extractAssistantMessage;
101
105
  private compactContentBlocks;
102
106
  private normalizeToolInput;