@co0ontty/wand 1.24.0 → 1.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server.js +93 -3
- package/dist/web-ui/content/scripts.js +413 -39
- package/dist/web-ui/content/styles.css +457 -76
- package/package.json +1 -1
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 { lstat, mkdir, readdir, readFile, stat } from "node:fs/promises";
|
|
5
|
+
import { lstat, mkdir, readdir, readFile, rename, stat, unlink, writeFile } 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";
|
|
@@ -1063,6 +1063,24 @@ export async function startServer(config, configPath) {
|
|
|
1063
1063
|
res.status(500).json({ error: getErrorMessage(error, "保存证书失败。") });
|
|
1064
1064
|
}
|
|
1065
1065
|
});
|
|
1066
|
+
// ── Global npm install with ENOTEMPTY fallback ──
|
|
1067
|
+
async function npmInstallGlobal(pkg, timeoutMs) {
|
|
1068
|
+
try {
|
|
1069
|
+
await execAsync(`npm install -g ${pkg}`, { timeout: timeoutMs });
|
|
1070
|
+
}
|
|
1071
|
+
catch (error) {
|
|
1072
|
+
const msg = getErrorMessage(error, "");
|
|
1073
|
+
if (msg.includes("ENOTEMPTY")) {
|
|
1074
|
+
// Running process holds files in the install dir; uninstall first, then reinstall with --force.
|
|
1075
|
+
process.stdout.write(`[wand] npm install 遇到 ENOTEMPTY,尝试先卸载再安装...\n`);
|
|
1076
|
+
await execAsync(`npm uninstall -g ${pkg}`, { timeout: timeoutMs });
|
|
1077
|
+
await execAsync(`npm install -g --force ${pkg}`, { timeout: timeoutMs });
|
|
1078
|
+
}
|
|
1079
|
+
else {
|
|
1080
|
+
throw error;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1066
1084
|
app.get("/api/check-update", async (_req, res) => {
|
|
1067
1085
|
try {
|
|
1068
1086
|
const result = await checkNpmLatestVersion(true);
|
|
@@ -1085,7 +1103,7 @@ export async function startServer(config, configPath) {
|
|
|
1085
1103
|
res.json({ ok: true, message: "已经是最新版本。" });
|
|
1086
1104
|
return;
|
|
1087
1105
|
}
|
|
1088
|
-
await
|
|
1106
|
+
await npmInstallGlobal(`${PKG_NAME}@latest`, 120000);
|
|
1089
1107
|
res.json({ ok: true, message: `已更新到 ${latest},请重启 wand 服务以生效。` });
|
|
1090
1108
|
}
|
|
1091
1109
|
catch (error) {
|
|
@@ -1251,6 +1269,78 @@ export async function startServer(config, configPath) {
|
|
|
1251
1269
|
res.status(400).json({ error: getErrorMessage(error, "Failed to read file") });
|
|
1252
1270
|
}
|
|
1253
1271
|
});
|
|
1272
|
+
// Write/overwrite a text file's content. Used by the file-preview modal's
|
|
1273
|
+
// edit mode. Only text-classified files are writable, and only when the file
|
|
1274
|
+
// already exists (we never create files via this endpoint to keep the surface
|
|
1275
|
+
// narrow). Atomic via tmp-file + rename to avoid partial writes.
|
|
1276
|
+
const MAX_TEXT_WRITE_SIZE = 1024 * 1024; // 1 MB cap for safety
|
|
1277
|
+
app.post("/api/file-write", express.json({ limit: "2mb" }), async (req, res) => {
|
|
1278
|
+
const body = (req.body ?? {});
|
|
1279
|
+
const filePath = typeof body.path === "string" ? body.path : "";
|
|
1280
|
+
const content = typeof body.content === "string" ? body.content : null;
|
|
1281
|
+
if (!filePath || content === null) {
|
|
1282
|
+
res.status(400).json({ error: "缺少 path 或 content 参数。" });
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
const resolvedPath = path.resolve(filePath);
|
|
1286
|
+
if (isBlockedFolderPath(resolvedPath)) {
|
|
1287
|
+
res.status(403).json({ error: "访问被拒绝:无法修改系统目录下的文件。" });
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
// Encode-size check (UTF-8 byte length, not character length).
|
|
1291
|
+
const byteLength = Buffer.byteLength(content, "utf-8");
|
|
1292
|
+
if (byteLength > MAX_TEXT_WRITE_SIZE) {
|
|
1293
|
+
res.status(413).json({
|
|
1294
|
+
error: `内容超出保存上限(${Math.round(MAX_TEXT_WRITE_SIZE / 1024)} KB)。`,
|
|
1295
|
+
size: byteLength,
|
|
1296
|
+
maxSize: MAX_TEXT_WRITE_SIZE,
|
|
1297
|
+
});
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
try {
|
|
1301
|
+
const fileStat = await stat(resolvedPath);
|
|
1302
|
+
if (fileStat.isDirectory()) {
|
|
1303
|
+
res.status(400).json({ error: "目标是目录,无法写入。" });
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
if (!fileStat.isFile()) {
|
|
1307
|
+
res.status(400).json({ error: "目标不是普通文件。" });
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
const ext = path.extname(resolvedPath).toLowerCase();
|
|
1311
|
+
const baseName = path.basename(resolvedPath);
|
|
1312
|
+
const kind = classifyFile(ext, baseName);
|
|
1313
|
+
if (kind !== "text") {
|
|
1314
|
+
res.status(415).json({ error: "仅支持编辑文本类文件。" });
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
// Atomic write: dump to a sibling temp file, then rename.
|
|
1318
|
+
const dir = path.dirname(resolvedPath);
|
|
1319
|
+
const tmpPath = path.join(dir, `.${baseName}.wand-tmp-${crypto.randomBytes(6).toString("hex")}`);
|
|
1320
|
+
try {
|
|
1321
|
+
await writeFile(tmpPath, content, { encoding: "utf-8", mode: fileStat.mode & 0o777 });
|
|
1322
|
+
await rename(tmpPath, resolvedPath);
|
|
1323
|
+
}
|
|
1324
|
+
catch (writeError) {
|
|
1325
|
+
// Best-effort cleanup if rename failed but tmp got created.
|
|
1326
|
+
try {
|
|
1327
|
+
await unlink(tmpPath);
|
|
1328
|
+
}
|
|
1329
|
+
catch { }
|
|
1330
|
+
throw writeError;
|
|
1331
|
+
}
|
|
1332
|
+
const newStat = await stat(resolvedPath);
|
|
1333
|
+
res.json({
|
|
1334
|
+
ok: true,
|
|
1335
|
+
path: resolvedPath,
|
|
1336
|
+
size: newStat.size,
|
|
1337
|
+
mtime: newStat.mtime.toISOString(),
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
catch (error) {
|
|
1341
|
+
res.status(400).json({ error: getErrorMessage(error, "保存文件失败。") });
|
|
1342
|
+
}
|
|
1343
|
+
});
|
|
1254
1344
|
// Streams the raw bytes of a file for inline media previews (image/PDF/video/audio)
|
|
1255
1345
|
// and downloads. Honors HTTP Range so video/audio scrubbing works.
|
|
1256
1346
|
const RAW_MAX_BYTES_BY_KIND = {
|
|
@@ -1650,7 +1740,7 @@ export async function startServer(config, configPath) {
|
|
|
1650
1740
|
data: { kind: "auto-update-start", current: info.current, latest: info.latest },
|
|
1651
1741
|
});
|
|
1652
1742
|
try {
|
|
1653
|
-
await
|
|
1743
|
+
await npmInstallGlobal(`${PKG_NAME}@latest`, 120000);
|
|
1654
1744
|
process.stdout.write(`[wand] 自动更新完成,正在重启...\n`);
|
|
1655
1745
|
wsManager.emitEvent({
|
|
1656
1746
|
type: "notification",
|