diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 22c15c1..aa7a015 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -11,6 +11,7 @@ import editorRouter from "./routes/editor.js"; import terminalRouter from "./routes/terminal.js"; import buildRouter from "./routes/build.js"; import serverRouter from "./routes/server.js"; +import toolsetRouter from "./routes/toolset.js"; import { attachWebSocket, createTerminalSession } from "./services/terminal.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/build", buildRouter); app.use("/api/server", serverRouter); +app.use("/api/toolset", toolsetRouter); const frontendDist = path.resolve(__dirname, "../../frontend/dist"); app.use(express.static(frontendDist)); diff --git a/packages/backend/src/routes/toolset.ts b/packages/backend/src/routes/toolset.ts new file mode 100644 index 0000000..af20275 --- /dev/null +++ b/packages/backend/src/routes/toolset.ts @@ -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; diff --git a/packages/backend/src/services/toolset.service.ts b/packages/backend/src/services/toolset.service.ts new file mode 100644 index 0000000..7a9063a --- /dev/null +++ b/packages/backend/src/services/toolset.service.ts @@ -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(); +let watcher: ReturnType | null = null; +const debounceTimers = new Map(); + +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 { + 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 { + 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 { + 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; +}