226 lines
7.3 KiB
TypeScript
226 lines
7.3 KiB
TypeScript
/**
|
|
* bootstrap.ts — Generate a temp UE5 project and manage the commandlet lifecycle.
|
|
*
|
|
* The temp project contains:
|
|
* TestProject.uproject — minimal JSON pointing at BlueprintMCP plugin
|
|
* Plugins/BlueprintMCP/ — directory junction to the real plugin source
|
|
* Content/ — empty; tests create Blueprints into /Game/Test/
|
|
*/
|
|
|
|
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import * as os from "node:os";
|
|
import { execSync, spawn, type ChildProcess } from "node:child_process";
|
|
|
|
/** Port used by the test commandlet (distinct from editor's 9847). */
|
|
export const TEST_PORT = 19847;
|
|
export const TEST_BASE_URL = `http://localhost:${TEST_PORT}`;
|
|
|
|
/** Absolute path to the plugin root (two levels up from test/). */
|
|
const PLUGIN_ROOT = path.resolve(import.meta.dirname, "..", "..");
|
|
|
|
let tempDir: string | null = null;
|
|
let cmdProcess: ChildProcess | null = null;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Temp project generation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function generateTempProject(): string {
|
|
const dir = path.join(os.tmpdir(), `BlueprintMCP_Test_${Date.now()}`);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
|
|
// Minimal .uproject — engine version must match the compiled plugin DLL
|
|
const engineVersion = detectEngineVersion();
|
|
const uproject = {
|
|
FileVersion: 3,
|
|
EngineAssociation: engineVersion,
|
|
Plugins: [{ Name: "BlueprintMCP", Enabled: true }],
|
|
};
|
|
fs.writeFileSync(
|
|
path.join(dir, "TestProject.uproject"),
|
|
JSON.stringify(uproject, null, "\t") + "\n",
|
|
);
|
|
|
|
// Content directory (blueprints land here)
|
|
fs.mkdirSync(path.join(dir, "Content"), { recursive: true });
|
|
|
|
// Plugins/BlueprintMCP → junction to real plugin
|
|
const pluginsDir = path.join(dir, "Plugins");
|
|
fs.mkdirSync(pluginsDir, { recursive: true });
|
|
|
|
const junctionTarget = path.join(pluginsDir, "BlueprintMCP");
|
|
// cmd /c mklink /J works without admin on Windows
|
|
execSync(`cmd /c mklink /J "${junctionTarget}" "${PLUGIN_ROOT}"`, {
|
|
stdio: "ignore",
|
|
});
|
|
|
|
tempDir = dir;
|
|
console.log(`[bootstrap] Temp project created at ${dir}`);
|
|
return dir;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Commandlet lifecycle
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Detect the UE engine version by scanning for installed engines (prefer newest). */
|
|
function detectEngineVersion(): string {
|
|
const base = "C:\\Program Files\\Epic Games";
|
|
try {
|
|
const dirs = fs.readdirSync(base).filter((d) => d.startsWith("UE_"));
|
|
// Sort descending so newest version is first
|
|
dirs.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
|
|
if (dirs.length > 0) {
|
|
return dirs[0].replace("UE_", "");
|
|
}
|
|
} catch { /* directory not found */ }
|
|
return "5.4"; // fallback
|
|
}
|
|
|
|
function findEditorCmd(): string | null {
|
|
if (process.env.UE_EDITOR_CMD && fs.existsSync(process.env.UE_EDITOR_CMD)) {
|
|
return process.env.UE_EDITOR_CMD;
|
|
}
|
|
// Scan for installed UE versions, preferring newest
|
|
const base = "C:\\Program Files\\Epic Games";
|
|
const bases = [base, "C:\\Program Files (x86)\\Epic Games"];
|
|
for (const b of bases) {
|
|
try {
|
|
const dirs = fs.readdirSync(b).filter((d) => d.startsWith("UE_"));
|
|
dirs.sort((a, bv) => bv.localeCompare(a, undefined, { numeric: true }));
|
|
for (const d of dirs) {
|
|
const cmd = path.join(b, d, "Engine", "Binaries", "Win64", "UnrealEditor-Cmd.exe");
|
|
if (fs.existsSync(cmd)) return cmd;
|
|
}
|
|
} catch { /* directory not found */ }
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function waitForHealth(timeoutMs: number = 240_000): Promise<boolean> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const resp = await fetch(`${TEST_BASE_URL}/api/health`, {
|
|
signal: AbortSignal.timeout(2000),
|
|
});
|
|
if (resp.ok) return true;
|
|
} catch {
|
|
// not ready yet
|
|
}
|
|
if (!cmdProcess) return false; // process died
|
|
await new Promise((r) => setTimeout(r, 2000));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export async function spawnCommandlet(projectDir: string): Promise<void> {
|
|
const editorCmd = findEditorCmd();
|
|
if (!editorCmd) {
|
|
throw new Error(
|
|
"UnrealEditor-Cmd.exe not found. Set UE_EDITOR_CMD env var.",
|
|
);
|
|
}
|
|
|
|
const uproject = path.join(projectDir, "TestProject.uproject");
|
|
const logPath = path.join(projectDir, "Saved", "Logs", "Test_server.log");
|
|
|
|
console.log(`[bootstrap] Spawning commandlet on port ${TEST_PORT}...`);
|
|
cmdProcess = spawn(
|
|
editorCmd,
|
|
[
|
|
uproject,
|
|
"-run=BlueprintMCP",
|
|
`-port=${TEST_PORT}`,
|
|
"-unattended",
|
|
"-nopause",
|
|
"-nullrhi",
|
|
`-LOG=${logPath}`,
|
|
],
|
|
{ stdio: ["ignore", "pipe", "pipe"], windowsHide: true },
|
|
);
|
|
|
|
cmdProcess.stdout?.on("data", (d: Buffer) => {
|
|
process.stderr.write(`[UE5:out] ${d.toString().trimEnd()}\n`);
|
|
});
|
|
cmdProcess.stderr?.on("data", (d: Buffer) => {
|
|
process.stderr.write(`[UE5:err] ${d.toString().trimEnd()}\n`);
|
|
});
|
|
cmdProcess.on("exit", (code) => {
|
|
console.log(`[bootstrap] Commandlet exited with code ${code}`);
|
|
cmdProcess = null;
|
|
});
|
|
|
|
console.log("[bootstrap] Waiting for health (up to 4 min)...");
|
|
const ok = await waitForHealth(240_000);
|
|
if (!ok) {
|
|
// Dump the log file if it exists
|
|
try {
|
|
const log = fs.readFileSync(logPath, "utf-8");
|
|
console.error("[bootstrap] === Commandlet log (last 80 lines) ===");
|
|
console.error(log.split("\n").slice(-80).join("\n"));
|
|
} catch { /* no log */ }
|
|
|
|
if (cmdProcess) {
|
|
cmdProcess.kill();
|
|
cmdProcess = null;
|
|
}
|
|
throw new Error("Commandlet failed to become healthy within 4 minutes.");
|
|
}
|
|
console.log("[bootstrap] Commandlet is healthy.");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shutdown & cleanup
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function shutdownCommandlet(): Promise<void> {
|
|
if (!cmdProcess) return;
|
|
|
|
// Graceful HTTP shutdown
|
|
try {
|
|
await fetch(`${TEST_BASE_URL}/api/shutdown`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: "{}",
|
|
signal: AbortSignal.timeout(3000),
|
|
});
|
|
} catch { /* may already be gone */ }
|
|
|
|
// Wait for exit
|
|
const proc = cmdProcess;
|
|
const exited = await new Promise<boolean>((resolve) => {
|
|
const timer = setTimeout(() => resolve(false), 15_000);
|
|
proc.on("exit", () => {
|
|
clearTimeout(timer);
|
|
resolve(true);
|
|
});
|
|
});
|
|
|
|
if (!exited && cmdProcess) {
|
|
console.log("[bootstrap] Force-killing commandlet.");
|
|
cmdProcess.kill();
|
|
cmdProcess = null;
|
|
}
|
|
}
|
|
|
|
export function cleanupTempProject(): void {
|
|
if (!tempDir) return;
|
|
const junctionPath = path.join(tempDir, "Plugins", "BlueprintMCP");
|
|
|
|
// Remove the junction first — rmdir removes only the junction, not the target
|
|
try {
|
|
execSync(`cmd /c rmdir "${junctionPath}"`, { stdio: "ignore" });
|
|
} catch { /* may already be gone */ }
|
|
|
|
// Remove the rest of the temp directory
|
|
try {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
console.log(`[bootstrap] Cleaned up ${tempDir}`);
|
|
} catch (e) {
|
|
console.error(`[bootstrap] Warning: could not clean up ${tempDir}:`, e);
|
|
}
|
|
tempDir = null;
|
|
}
|