Files
layonara-forge/packages/backend/src/services/toolset.service.ts
T

169 lines
4.4 KiB
TypeScript

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