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; }