feat: add Toolset service for temp0/ watching and GFF change detection
This commit is contained in:
@@ -11,6 +11,7 @@ import editorRouter from "./routes/editor.js";
|
|||||||
import terminalRouter from "./routes/terminal.js";
|
import terminalRouter from "./routes/terminal.js";
|
||||||
import buildRouter from "./routes/build.js";
|
import buildRouter from "./routes/build.js";
|
||||||
import serverRouter from "./routes/server.js";
|
import serverRouter from "./routes/server.js";
|
||||||
|
import toolsetRouter from "./routes/toolset.js";
|
||||||
import { attachWebSocket, createTerminalSession } from "./services/terminal.service.js";
|
import { attachWebSocket, createTerminalSession } from "./services/terminal.service.js";
|
||||||
import { attachLspWebSocket } from "./services/lsp.service.js";
|
import { attachLspWebSocket } from "./services/lsp.service.js";
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ app.use("/api/editor", editorRouter);
|
|||||||
app.use("/api/terminal", terminalRouter);
|
app.use("/api/terminal", terminalRouter);
|
||||||
app.use("/api/build", buildRouter);
|
app.use("/api/build", buildRouter);
|
||||||
app.use("/api/server", serverRouter);
|
app.use("/api/server", serverRouter);
|
||||||
|
app.use("/api/toolset", toolsetRouter);
|
||||||
|
|
||||||
const frontendDist = path.resolve(__dirname, "../../frontend/dist");
|
const frontendDist = path.resolve(__dirname, "../../frontend/dist");
|
||||||
app.use(express.static(frontendDist));
|
app.use(express.static(frontendDist));
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import {
|
||||||
|
startToolsetWatcher,
|
||||||
|
stopToolsetWatcher,
|
||||||
|
isWatcherActive,
|
||||||
|
getPendingChanges,
|
||||||
|
getChange,
|
||||||
|
applyChange,
|
||||||
|
applyAllChanges,
|
||||||
|
discardChange,
|
||||||
|
discardAllChanges,
|
||||||
|
} from "../services/toolset.service.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get("/status", (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
active: isWatcherActive(),
|
||||||
|
pendingCount: getPendingChanges().length,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/start", (_req, res) => {
|
||||||
|
startToolsetWatcher();
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/stop", (_req, res) => {
|
||||||
|
stopToolsetWatcher();
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/changes", (_req, res) => {
|
||||||
|
const changes = getPendingChanges().map((c) => ({
|
||||||
|
filename: c.filename,
|
||||||
|
gffType: c.gffType,
|
||||||
|
repoPath: c.repoPath,
|
||||||
|
timestamp: c.timestamp,
|
||||||
|
}));
|
||||||
|
res.json(changes);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/changes/:filename", (req, res) => {
|
||||||
|
const change = getChange(req.params.filename);
|
||||||
|
if (!change) return res.status(404).json({ error: "change not found" });
|
||||||
|
res.json(change);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/apply", async (req, res) => {
|
||||||
|
const { files } = req.body;
|
||||||
|
if (!files || !Array.isArray(files))
|
||||||
|
return res.status(400).json({ error: "files array required" });
|
||||||
|
const results = [];
|
||||||
|
for (const f of files) {
|
||||||
|
results.push({ file: f, applied: await applyChange(f) });
|
||||||
|
}
|
||||||
|
res.json(results);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/apply-all", async (_req, res) => {
|
||||||
|
const applied = await applyAllChanges();
|
||||||
|
res.json({ applied });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/discard", (req, res) => {
|
||||||
|
const { files } = req.body;
|
||||||
|
if (!files || !Array.isArray(files))
|
||||||
|
return res.status(400).json({ error: "files array required" });
|
||||||
|
for (const f of files) discardChange(f);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/discard-all", (_req, res) => {
|
||||||
|
discardAllChanges();
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import chokidar from "chokidar";
|
||||||
|
import { exec } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
import fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import { broadcast } from "./ws.service.js";
|
||||||
|
import { getWorkspacePath } from "./workspace.service.js";
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
const NWN_HOME = process.env.NWN_HOME_PATH || "/nwn-home";
|
||||||
|
const TEMP0_PATH = path.join(NWN_HOME, "modules", "temp0");
|
||||||
|
|
||||||
|
interface ToolsetChange {
|
||||||
|
filename: string;
|
||||||
|
gffType: string;
|
||||||
|
repoPath: string | null;
|
||||||
|
timestamp: number;
|
||||||
|
jsonContent?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingChanges = new Map<string, ToolsetChange>();
|
||||||
|
let watcher: ReturnType<typeof chokidar.watch> | null = null;
|
||||||
|
const debounceTimers = new Map<string, NodeJS.Timeout>();
|
||||||
|
|
||||||
|
const GFF_EXTENSIONS = new Set([
|
||||||
|
"are", "git", "utc", "uti", "utp", "dlg",
|
||||||
|
"utm", "ute", "utt", "utw", "uts", "utd",
|
||||||
|
"ifo", "itp",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function startToolsetWatcher(): void {
|
||||||
|
if (watcher) return;
|
||||||
|
|
||||||
|
watcher = chokidar.watch(TEMP0_PATH, {
|
||||||
|
persistent: true,
|
||||||
|
ignoreInitial: true,
|
||||||
|
usePolling: true,
|
||||||
|
interval: 1000,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.on("change", (filePath) => handleFileChange(filePath));
|
||||||
|
watcher.on("add", (filePath) => handleFileChange(filePath));
|
||||||
|
|
||||||
|
broadcast("toolset", "watcher:started", { path: TEMP0_PATH });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileChange(filePath: string): void {
|
||||||
|
const filename = path.basename(filePath);
|
||||||
|
const ext = filename.split(".").pop()?.toLowerCase();
|
||||||
|
if (!ext || !GFF_EXTENSIONS.has(ext)) return;
|
||||||
|
|
||||||
|
const existing = debounceTimers.get(filename);
|
||||||
|
if (existing) clearTimeout(existing);
|
||||||
|
|
||||||
|
debounceTimers.set(
|
||||||
|
filename,
|
||||||
|
setTimeout(async () => {
|
||||||
|
debounceTimers.delete(filename);
|
||||||
|
await processChange(filePath, filename, ext);
|
||||||
|
}, 500),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processChange(
|
||||||
|
filePath: string,
|
||||||
|
filename: string,
|
||||||
|
gffType: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tmpJson = `/tmp/forge-toolset-${filename}.json`;
|
||||||
|
await execAsync(`nwn_gff -i "${filePath}" -o "${tmpJson}"`);
|
||||||
|
const jsonContent = await fs.readFile(tmpJson, "utf-8");
|
||||||
|
await fs.unlink(tmpJson).catch(() => {});
|
||||||
|
|
||||||
|
const repoPath = mapToRepoPath(filename, gffType);
|
||||||
|
|
||||||
|
const change: ToolsetChange = {
|
||||||
|
filename,
|
||||||
|
gffType,
|
||||||
|
repoPath,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
jsonContent,
|
||||||
|
};
|
||||||
|
|
||||||
|
pendingChanges.set(filename, change);
|
||||||
|
broadcast("toolset", "change", {
|
||||||
|
filename,
|
||||||
|
gffType,
|
||||||
|
repoPath,
|
||||||
|
timestamp: change.timestamp,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
broadcast("toolset", "error", { filename, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapToRepoPath(filename: string, gffType: string): string | null {
|
||||||
|
// nasher conventions: areas -> areas/core/, everything else -> core/$ext/
|
||||||
|
const baseName = filename.replace(`.${gffType}`, "");
|
||||||
|
|
||||||
|
if (gffType === "are" || gffType === "git") {
|
||||||
|
return `areas/core/${baseName}.${gffType}.json`;
|
||||||
|
}
|
||||||
|
return `core/${gffType}/${baseName}.${gffType}.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPendingChanges(): ToolsetChange[] {
|
||||||
|
return Array.from(pendingChanges.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChange(filename: string): ToolsetChange | undefined {
|
||||||
|
return pendingChanges.get(filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyChange(filename: string): Promise<boolean> {
|
||||||
|
const change = pendingChanges.get(filename);
|
||||||
|
if (!change || !change.repoPath || !change.jsonContent) return false;
|
||||||
|
|
||||||
|
const repoFilePath = path.join(
|
||||||
|
getWorkspacePath(),
|
||||||
|
"repos",
|
||||||
|
"nwn-module",
|
||||||
|
change.repoPath,
|
||||||
|
);
|
||||||
|
await fs.mkdir(path.dirname(repoFilePath), { recursive: true });
|
||||||
|
await fs.writeFile(repoFilePath, change.jsonContent, "utf-8");
|
||||||
|
|
||||||
|
pendingChanges.delete(filename);
|
||||||
|
broadcast("toolset", "applied", {
|
||||||
|
filename,
|
||||||
|
repoPath: change.repoPath,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyAllChanges(): Promise<string[]> {
|
||||||
|
const applied: string[] = [];
|
||||||
|
for (const [filename] of pendingChanges) {
|
||||||
|
if (await applyChange(filename)) {
|
||||||
|
applied.push(filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return applied;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function discardChange(filename: string): void {
|
||||||
|
pendingChanges.delete(filename);
|
||||||
|
broadcast("toolset", "discarded", { filename });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function discardAllChanges(): void {
|
||||||
|
pendingChanges.clear();
|
||||||
|
broadcast("toolset", "discarded-all", {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopToolsetWatcher(): void {
|
||||||
|
if (watcher) {
|
||||||
|
watcher.close();
|
||||||
|
watcher = null;
|
||||||
|
broadcast("toolset", "watcher:stopped", {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWatcherActive(): boolean {
|
||||||
|
return watcher !== null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user