src / core / godotCore.ts
/**
* Shared core for Godot engine operations.
*
* This module holds the platform-independent logic used by both the LM Studio
* plugin tools and (if desired) a standalone MCP server: Godot executable
* detection, path validation, parameter normalization and execution of the
* bundled GDScript operations file.
*/
import { spawn, execFile, type ChildProcess } from "child_process";
import { join, normalize, basename } from "path";
import { existsSync, readdirSync, readFileSync } from "fs";
import { promisify } from "util";
const execFileAsync = promisify(execFile);
export interface OperationParams {
[key: string]: unknown;
}
export interface GodotEngineConfig {
godotPath?: string;
debugMode?: boolean;
godotDebugMode?: boolean;
strictPathValidation?: boolean;
operationsScriptPath?: string;
}
interface RunningProcess {
process: ChildProcess;
output: string[];
errors: string[];
}
const PLATFORM_PATHS: Record<string, string[]> = {
darwin: [
"/Applications/Godot.app/Contents/MacOS/Godot",
"/Applications/Godot_4.app/Contents/MacOS/Godot",
`${process.env.HOME ?? ""}/Applications/Godot.app/Contents/MacOS/Godot`,
`${process.env.HOME ?? ""}/Applications/Godot_4.app/Contents/MacOS/Godot`,
],
win32: [
"C:\\Program Files\\Godot\\Godot.exe",
"C:\\Program Files (x86)\\Godot\\Godot.exe",
"C:\\Program Files\\Godot_4\\Godot.exe",
"C:\\Program Files (x86)\\Godot_4\\Godot.exe",
`${process.env.USERPROFILE ?? ""}\\Godot\\Godot.exe`,
],
linux: [
"/usr/bin/godot",
"/usr/local/bin/godot",
"/snap/bin/godot",
`${process.env.HOME ?? ""}/.local/bin/godot`,
],
};
// Base directory used to locate bundled assets such as the GDScript
// operations file. We deliberately rely on `process.cwd()` instead of
// `import.meta.url` so this module loads correctly whether it is run directly
// (ESM) or bundled into a CommonJS bundle by LM Studio's esbuild.
const BASE_DIR = process.cwd();
/**
* A thin wrapper around the Godot executable. It resolves the binary path,
* validates project/file paths and runs the bundled operations script.
*/
export class GodotEngine {
private godotPath: string | null = null;
private activeProcess: RunningProcess | null = null;
private readonly validatedPaths = new Map<string, boolean>();
private readonly strictPathValidation: boolean;
private readonly debugMode: boolean;
private readonly godotDebugMode: boolean;
private readonly operationsScriptPath: string;
private static readonly PARAMETER_MAPPINGS: Record<string, string> = {
project_path: "projectPath",
scene_path: "scenePath",
root_node_type: "rootNodeType",
parent_node_path: "parentNodePath",
node_type: "nodeType",
node_name: "nodeName",
texture_path: "texturePath",
node_path: "nodePath",
output_path: "outputPath",
mesh_item_names: "meshItemNames",
new_path: "newPath",
file_path: "filePath",
};
private readonly reverseParameterMappings: Record<string, string> = {};
constructor(config: GodotEngineConfig = {}) {
this.debugMode = config.debugMode === true;
this.godotDebugMode = config.godotDebugMode !== false; // default on
this.strictPathValidation = config.strictPathValidation === true;
this.operationsScriptPath =
config.operationsScriptPath ?? GodotEngine.resolveOperationsScriptPath();
for (const [snake, camel] of Object.entries(
GodotEngine.PARAMETER_MAPPINGS
)) {
this.reverseParameterMappings[camel] = snake;
}
if (config.godotPath) {
const normalized = normalize(config.godotPath);
if (this.isValidGodotPathSync(normalized)) {
this.godotPath = normalized;
}
}
}
// ---------------------------------------------------------------------------
// Logging / validation helpers
// ---------------------------------------------------------------------------
private logDebug(message: string): void {
if (this.debugMode) {
console.error(`[godot-plugin] ${message}`);
}
}
validatePath(pathValue: string): boolean {
if (!pathValue || typeof pathValue !== "string") return false;
if (pathValue.includes("..")) return false;
return true;
}
validateClassName(name: string): boolean {
if (!name) return false;
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
}
private isValidGodotPathSync(pathValue: string): boolean {
try {
return pathValue === "godot" || existsSync(pathValue);
} catch {
return false;
}
}
private async isValidGodotPath(pathValue: string): Promise<boolean> {
const cached = this.validatedPaths.get(pathValue);
if (cached !== undefined) return cached;
try {
if (pathValue !== "godot" && !existsSync(pathValue)) {
this.validatedPaths.set(pathValue, false);
return false;
}
await execFileAsync(pathValue, ["--version"]);
this.validatedPaths.set(pathValue, true);
return true;
} catch {
this.validatedPaths.set(pathValue, false);
return false;
}
}
// ---------------------------------------------------------------------------
// Godot executable detection
// ---------------------------------------------------------------------------
async detectGodotPath(explicitOverride?: string): Promise<string | null> {
if (explicitOverride) {
const normalized = normalize(explicitOverride);
if (await this.isValidGodotPath(normalized)) {
this.godotPath = normalized;
return normalized;
}
}
if (this.godotPath && (await this.isValidGodotPath(this.godotPath))) {
return this.godotPath;
}
const envPath = process.env.GODOT_PATH;
if (envPath) {
const normalized = normalize(envPath);
if (await this.isValidGodotPath(normalized)) {
this.godotPath = normalized;
return normalized;
}
}
const platform = process.platform;
for (const candidate of ["godot", ...(PLATFORM_PATHS[platform] ?? [])]) {
const normalized = normalize(candidate);
if (await this.isValidGodotPath(normalized)) {
this.godotPath = normalized;
return normalized;
}
}
// Non-strict fallback so the plugin can still load without a binary.
if (!this.strictPathValidation) {
const fallback =
PLATFORM_PATHS[platform]?.[0] ??
(platform === "win32"
? "C:\\Program Files\\Godot\\Godot.exe"
: platform === "darwin"
? "/Applications/Godot.app/Contents/MacOS/Godot"
: "/usr/bin/godot");
this.godotPath = normalize(fallback);
this.logDebug(`Godot executable not found; using fallback: ${this.godotPath}`);
}
return this.godotPath;
}
getResolvedGodotPath(): string | null {
return this.godotPath;
}
isGodot44OrLater(version: string): boolean {
const match = version.match(/^(\d+)\.(\d+)/);
if (!match) return false;
const major = parseInt(match[1], 10);
const minor = parseInt(match[2], 10);
return major > 4 || (major === 4 && minor >= 4);
}
// ---------------------------------------------------------------------------
// Parameter normalization
// ---------------------------------------------------------------------------
normalizeParameters(params: OperationParams): OperationParams {
if (!params || typeof params !== "object") return params ?? {};
const result: OperationParams = {};
for (const key of Object.keys(params)) {
const value = (params as Record<string, unknown>)[key];
let normalizedKey = key;
if (key.includes("_") && GodotEngine.PARAMETER_MAPPINGS[key]) {
normalizedKey = GodotEngine.PARAMETER_MAPPINGS[key];
}
if (
typeof value === "object" &&
value !== null &&
!Array.isArray(value)
) {
result[normalizedKey] = this.normalizeParameters(
value as OperationParams
);
} else {
result[normalizedKey] = value;
}
}
return result;
}
private toSnakeCase(params: OperationParams): OperationParams {
const result: OperationParams = {};
for (const key of Object.keys(params)) {
const value = (params as Record<string, unknown>)[key];
const camelKey = GodotEngine.PARAMETER_MAPPINGS[key] ?? key;
const snakeKey =
this.reverseParameterMappings[camelKey] ??
camelKey.replace(/[A-Z]/g, (l) => `_${l.toLowerCase()}`);
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
result[snakeKey] = this.toSnakeCase(value as OperationParams);
} else {
result[snakeKey] = value;
}
}
return result;
}
// ---------------------------------------------------------------------------
// Project helpers
// ---------------------------------------------------------------------------
private ensureValidProject(projectPath: string): string | null {
const projectFile = join(projectPath, "project.godot");
if (!existsSync(projectFile)) return null;
return projectFile;
}
private readProjectName(projectFile: string): string {
try {
const content = readFileSync(projectFile, "utf8");
const match = content.match(/config\/name="([^"]+)"/);
return match?.[1] ?? basename(projectFile);
} catch {
return basename(projectFile);
}
}
findGodotProjects(
directory: string,
recursive: boolean
): Array<{ path: string; name: string }> {
const projects: Array<{ path: string; name: string }> = [];
try {
const rootProject = join(directory, "project.godot");
if (existsSync(rootProject)) {
projects.push({ path: directory, name: basename(directory) });
}
if (!existsSync(directory)) return projects;
const scan = (currentPath: string, recurse: boolean): void => {
for (const entry of readdirSync(currentPath, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const subdir = join(currentPath, entry.name);
const projectFile = join(subdir, "project.godot");
if (existsSync(projectFile)) {
projects.push({ path: subdir, name: entry.name });
} else if (recurse && !entry.name.startsWith(".")) {
scan(subdir, true);
}
}
};
if (recursive) scan(directory, true);
else scan(directory, false);
} catch (error) {
this.logDebug(`Error searching directory ${directory}: ${error}`);
}
return projects;
}
// ---------------------------------------------------------------------------
// Godot command execution
// ---------------------------------------------------------------------------
/**
* Locate the bundled GDScript operations file. We search a few sensible
* locations relative to the current working directory (where `lms dev` /
* `lms push` are normally run) as well as an explicit override, because the
* runtime location of the script differs between direct execution and an
* esbuild CommonJS bundle.
*/
private static resolveOperationsScriptPath(): string {
const candidates: string[] = [];
const env = process.env.GODOT_OPERATIONS_SCRIPT;
if (env && env.trim()) candidates.push(env);
candidates.push(
join(BASE_DIR, "dist", "scripts", "godot_operations.gd"),
join(BASE_DIR, "src", "scripts", "godot_operations.gd"),
join(BASE_DIR, "scripts", "godot_operations.gd"),
);
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate;
}
// Fall back to the most likely location; callers will get a clear error.
return candidates[0];
}
async executeOperation(
operation: string,
params: OperationParams,
projectPath: string
): Promise<{ stdout: string; stderr: string }> {
if (!this.godotPath) {
await this.detectGodotPath();
if (!this.godotPath) throw new Error("Could not find a valid Godot executable.");
}
if (!existsSync(this.operationsScriptPath)) {
throw new Error(
`Godot operations script not found at "${this.operationsScriptPath}". ` +
"Build the project (npm run build) so that dist/scripts/godot_operations.gd exists, or set GODOT_OPERATIONS_SCRIPT."
);
}
const snakeCaseParams = this.toSnakeCase(params);
const args = [
"--headless",
"--path",
projectPath,
"--script",
this.operationsScriptPath,
operation,
JSON.stringify(snakeCaseParams),
];
if (this.godotDebugMode) args.push("--debug-godot");
try {
const { stdout, stderr } = await execFileAsync(this.godotPath!, args, {
timeout: 60000,
});
return { stdout: stdout ?? "", stderr: stderr ?? "" };
} catch (error) {
// A spawn-level failure (the executable path does not exist, or cannot be
// launched because of permissions) surfaces with empty stdout/stderr. We
// detect it explicitly so callers can distinguish "Godot could not be
// launched" from "Godot ran but produced no output". Without this the tool
// would report false success with an empty output string.
const anyError = error as { code?: unknown };
if (
error instanceof Error &&
typeof anyError.code === "string" &&
(anyError.code === "ENOENT" ||
anyError.code === "EACCES" ||
anyError.code === "EPERM")
) {
return {
stdout: "",
stderr: `Failed to spawn Godot executable "${this.godotPath}": ${anyError.code}`,
};
}
if (error instanceof Error && "stdout" in error && "stderr" in error) {
const execError = error as Error & { stdout?: string; stderr?: string };
return {
stdout: execError.stdout ?? "",
stderr: execError.stderr ?? "",
};
}
throw error;
}
}
getVersion(): Promise<string> {
if (!this.godotPath) {
// no-op to keep type-checkers happy; real resolution happens in callers.
}
return execFileAsync(this.godotPath!, ["--version"]).then((r) =>
(r.stdout ?? "").trim()
);
}
runProject(
projectPath: string,
scene?: string
): Promise<{ started: true }> {
if (!this.godotPath) {
throw new Error("Godot executable path is not resolved.");
}
if (this.activeProcess) {
this.activeProcess.process.kill();
this.activeProcess = null;
}
// NOTE: `--headless` is required on headless/servers where no X11/Wayland
// display server is available. Without it, Godot fails to create a
// DisplayServer and exits immediately (see project README investigation).
const cmdArgs = ["--headless", "--path", projectPath];
if (scene && this.validatePath(scene)) cmdArgs.push("--scene", scene);
const process = spawn(this.godotPath, cmdArgs, { stdio: "pipe" });
const output: string[] = [];
const errors: string[] = [];
process.stdout?.on("data", (data: Uint8Array) => {
output.push(...data.toString().split("\n"));
});
process.stderr?.on("data", (data: Uint8Array) => {
errors.push(...data.toString().split("\n"));
});
process.on("exit", () => {
if (this.activeProcess?.process === process) this.activeProcess = null;
});
process.on("error", () => {
if (this.activeProcess?.process === process) this.activeProcess = null;
});
this.activeProcess = { process, output, errors };
return Promise.resolve({ started: true });
}
getDebugOutput(): { output: string[]; errors: string[] } | null {
if (!this.activeProcess) return null;
return { output: this.activeProcess.output, errors: this.activeProcess.errors };
}
stopProject(): { stopped: true; finalOutput: string[]; finalErrors: string[] } | null {
if (!this.activeProcess) return null;
const { output, errors } = this.activeProcess;
this.activeProcess.process.kill();
this.activeProcess = null;
return { stopped: true, finalOutput: output, finalErrors: errors };
}
}
src / core / godotCore.ts
/**
* Shared core for Godot engine operations.
*
* This module holds the platform-independent logic used by both the LM Studio
* plugin tools and (if desired) a standalone MCP server: Godot executable
* detection, path validation, parameter normalization and execution of the
* bundled GDScript operations file.
*/
import { spawn, execFile, type ChildProcess } from "child_process";
import { join, normalize, basename } from "path";
import { existsSync, readdirSync, readFileSync } from "fs";
import { promisify } from "util";
const execFileAsync = promisify(execFile);
export interface OperationParams {
[key: string]: unknown;
}
export interface GodotEngineConfig {
godotPath?: string;
debugMode?: boolean;
godotDebugMode?: boolean;
strictPathValidation?: boolean;
operationsScriptPath?: string;
}
interface RunningProcess {
process: ChildProcess;
output: string[];
errors: string[];
}
const PLATFORM_PATHS: Record<string, string[]> = {
darwin: [
"/Applications/Godot.app/Contents/MacOS/Godot",
"/Applications/Godot_4.app/Contents/MacOS/Godot",
`${process.env.HOME ?? ""}/Applications/Godot.app/Contents/MacOS/Godot`,
`${process.env.HOME ?? ""}/Applications/Godot_4.app/Contents/MacOS/Godot`,
],
win32: [
"C:\\Program Files\\Godot\\Godot.exe",
"C:\\Program Files (x86)\\Godot\\Godot.exe",
"C:\\Program Files\\Godot_4\\Godot.exe",
"C:\\Program Files (x86)\\Godot_4\\Godot.exe",
`${process.env.USERPROFILE ?? ""}\\Godot\\Godot.exe`,
],
linux: [
"/usr/bin/godot",
"/usr/local/bin/godot",
"/snap/bin/godot",
`${process.env.HOME ?? ""}/.local/bin/godot`,
],
};
// Base directory used to locate bundled assets such as the GDScript
// operations file. We deliberately rely on `process.cwd()` instead of
// `import.meta.url` so this module loads correctly whether it is run directly
// (ESM) or bundled into a CommonJS bundle by LM Studio's esbuild.
const BASE_DIR = process.cwd();
/**
* A thin wrapper around the Godot executable. It resolves the binary path,
* validates project/file paths and runs the bundled operations script.
*/
export class GodotEngine {
private godotPath: string | null = null;
private activeProcess: RunningProcess | null = null;
private readonly validatedPaths = new Map<string, boolean>();
private readonly strictPathValidation: boolean;
private readonly debugMode: boolean;
private readonly godotDebugMode: boolean;
private readonly operationsScriptPath: string;
private static readonly PARAMETER_MAPPINGS: Record<string, string> = {
project_path: "projectPath",
scene_path: "scenePath",
root_node_type: "rootNodeType",
parent_node_path: "parentNodePath",
node_type: "nodeType",
node_name: "nodeName",
texture_path: "texturePath",
node_path: "nodePath",
output_path: "outputPath",
mesh_item_names: "meshItemNames",
new_path: "newPath",
file_path: "filePath",
};
private readonly reverseParameterMappings: Record<string, string> = {};
constructor(config: GodotEngineConfig = {}) {
this.debugMode = config.debugMode === true;
this.godotDebugMode = config.godotDebugMode !== false; // default on
this.strictPathValidation = config.strictPathValidation === true;
this.operationsScriptPath =
config.operationsScriptPath ?? GodotEngine.resolveOperationsScriptPath();
for (const [snake, camel] of Object.entries(
GodotEngine.PARAMETER_MAPPINGS
)) {
this.reverseParameterMappings[camel] = snake;
}
if (config.godotPath) {
const normalized = normalize(config.godotPath);
if (this.isValidGodotPathSync(normalized)) {
this.godotPath = normalized;
}
}
}
// ---------------------------------------------------------------------------
// Logging / validation helpers
// ---------------------------------------------------------------------------
private logDebug(message: string): void {
if (this.debugMode) {
console.error(`[godot-plugin] ${message}`);
}
}
validatePath(pathValue: string): boolean {
if (!pathValue || typeof pathValue !== "string") return false;
if (pathValue.includes("..")) return false;
return true;
}
validateClassName(name: string): boolean {
if (!name) return false;
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
}
private isValidGodotPathSync(pathValue: string): boolean {
try {
return pathValue === "godot" || existsSync(pathValue);
} catch {
return false;
}
}
private async isValidGodotPath(pathValue: string): Promise<boolean> {
const cached = this.validatedPaths.get(pathValue);
if (cached !== undefined) return cached;
try {
if (pathValue !== "godot" && !existsSync(pathValue)) {
this.validatedPaths.set(pathValue, false);
return false;
}
await execFileAsync(pathValue, ["--version"]);
this.validatedPaths.set(pathValue, true);
return true;
} catch {
this.validatedPaths.set(pathValue, false);
return false;
}
}
// ---------------------------------------------------------------------------
// Godot executable detection
// ---------------------------------------------------------------------------
async detectGodotPath(explicitOverride?: string): Promise<string | null> {
if (explicitOverride) {
const normalized = normalize(explicitOverride);
if (await this.isValidGodotPath(normalized)) {
this.godotPath = normalized;
return normalized;
}
}
if (this.godotPath && (await this.isValidGodotPath(this.godotPath))) {
return this.godotPath;
}
const envPath = process.env.GODOT_PATH;
if (envPath) {
const normalized = normalize(envPath);
if (await this.isValidGodotPath(normalized)) {
this.godotPath = normalized;
return normalized;
}
}
const platform = process.platform;
for (const candidate of ["godot", ...(PLATFORM_PATHS[platform] ?? [])]) {
const normalized = normalize(candidate);
if (await this.isValidGodotPath(normalized)) {
this.godotPath = normalized;
return normalized;
}
}
// Non-strict fallback so the plugin can still load without a binary.
if (!this.strictPathValidation) {
const fallback =
PLATFORM_PATHS[platform]?.[0] ??
(platform === "win32"
? "C:\\Program Files\\Godot\\Godot.exe"
: platform === "darwin"
? "/Applications/Godot.app/Contents/MacOS/Godot"
: "/usr/bin/godot");
this.godotPath = normalize(fallback);
this.logDebug(`Godot executable not found; using fallback: ${this.godotPath}`);
}
return this.godotPath;
}
getResolvedGodotPath(): string | null {
return this.godotPath;
}
isGodot44OrLater(version: string): boolean {
const match = version.match(/^(\d+)\.(\d+)/);
if (!match) return false;
const major = parseInt(match[1], 10);
const minor = parseInt(match[2], 10);
return major > 4 || (major === 4 && minor >= 4);
}
// ---------------------------------------------------------------------------
// Parameter normalization
// ---------------------------------------------------------------------------
normalizeParameters(params: OperationParams): OperationParams {
if (!params || typeof params !== "object") return params ?? {};
const result: OperationParams = {};
for (const key of Object.keys(params)) {
const value = (params as Record<string, unknown>)[key];
let normalizedKey = key;
if (key.includes("_") && GodotEngine.PARAMETER_MAPPINGS[key]) {
normalizedKey = GodotEngine.PARAMETER_MAPPINGS[key];
}
if (
typeof value === "object" &&
value !== null &&
!Array.isArray(value)
) {
result[normalizedKey] = this.normalizeParameters(
value as OperationParams
);
} else {
result[normalizedKey] = value;
}
}
return result;
}
private toSnakeCase(params: OperationParams): OperationParams {
const result: OperationParams = {};
for (const key of Object.keys(params)) {
const value = (params as Record<string, unknown>)[key];
const camelKey = GodotEngine.PARAMETER_MAPPINGS[key] ?? key;
const snakeKey =
this.reverseParameterMappings[camelKey] ??
camelKey.replace(/[A-Z]/g, (l) => `_${l.toLowerCase()}`);
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
result[snakeKey] = this.toSnakeCase(value as OperationParams);
} else {
result[snakeKey] = value;
}
}
return result;
}
// ---------------------------------------------------------------------------
// Project helpers
// ---------------------------------------------------------------------------
private ensureValidProject(projectPath: string): string | null {
const projectFile = join(projectPath, "project.godot");
if (!existsSync(projectFile)) return null;
return projectFile;
}
private readProjectName(projectFile: string): string {
try {
const content = readFileSync(projectFile, "utf8");
const match = content.match(/config\/name="([^"]+)"/);
return match?.[1] ?? basename(projectFile);
} catch {
return basename(projectFile);
}
}
findGodotProjects(
directory: string,
recursive: boolean
): Array<{ path: string; name: string }> {
const projects: Array<{ path: string; name: string }> = [];
try {
const rootProject = join(directory, "project.godot");
if (existsSync(rootProject)) {
projects.push({ path: directory, name: basename(directory) });
}
if (!existsSync(directory)) return projects;
const scan = (currentPath: string, recurse: boolean): void => {
for (const entry of readdirSync(currentPath, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const subdir = join(currentPath, entry.name);
const projectFile = join(subdir, "project.godot");
if (existsSync(projectFile)) {
projects.push({ path: subdir, name: entry.name });
} else if (recurse && !entry.name.startsWith(".")) {
scan(subdir, true);
}
}
};
if (recursive) scan(directory, true);
else scan(directory, false);
} catch (error) {
this.logDebug(`Error searching directory ${directory}: ${error}`);
}
return projects;
}
// ---------------------------------------------------------------------------
// Godot command execution
// ---------------------------------------------------------------------------
/**
* Locate the bundled GDScript operations file. We search a few sensible
* locations relative to the current working directory (where `lms dev` /
* `lms push` are normally run) as well as an explicit override, because the
* runtime location of the script differs between direct execution and an
* esbuild CommonJS bundle.
*/
private static resolveOperationsScriptPath(): string {
const candidates: string[] = [];
const env = process.env.GODOT_OPERATIONS_SCRIPT;
if (env && env.trim()) candidates.push(env);
candidates.push(
join(BASE_DIR, "dist", "scripts", "godot_operations.gd"),
join(BASE_DIR, "src", "scripts", "godot_operations.gd"),
join(BASE_DIR, "scripts", "godot_operations.gd"),
);
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate;
}
// Fall back to the most likely location; callers will get a clear error.
return candidates[0];
}
async executeOperation(
operation: string,
params: OperationParams,
projectPath: string
): Promise<{ stdout: string; stderr: string }> {
if (!this.godotPath) {
await this.detectGodotPath();
if (!this.godotPath) throw new Error("Could not find a valid Godot executable.");
}
if (!existsSync(this.operationsScriptPath)) {
throw new Error(
`Godot operations script not found at "${this.operationsScriptPath}". ` +
"Build the project (npm run build) so that dist/scripts/godot_operations.gd exists, or set GODOT_OPERATIONS_SCRIPT."
);
}
const snakeCaseParams = this.toSnakeCase(params);
const args = [
"--headless",
"--path",
projectPath,
"--script",
this.operationsScriptPath,
operation,
JSON.stringify(snakeCaseParams),
];
if (this.godotDebugMode) args.push("--debug-godot");
try {
const { stdout, stderr } = await execFileAsync(this.godotPath!, args, {
timeout: 60000,
});
return { stdout: stdout ?? "", stderr: stderr ?? "" };
} catch (error) {
// A spawn-level failure (the executable path does not exist, or cannot be
// launched because of permissions) surfaces with empty stdout/stderr. We
// detect it explicitly so callers can distinguish "Godot could not be
// launched" from "Godot ran but produced no output". Without this the tool
// would report false success with an empty output string.
const anyError = error as { code?: unknown };
if (
error instanceof Error &&
typeof anyError.code === "string" &&
(anyError.code === "ENOENT" ||
anyError.code === "EACCES" ||
anyError.code === "EPERM")
) {
return {
stdout: "",
stderr: `Failed to spawn Godot executable "${this.godotPath}": ${anyError.code}`,
};
}
if (error instanceof Error && "stdout" in error && "stderr" in error) {
const execError = error as Error & { stdout?: string; stderr?: string };
return {
stdout: execError.stdout ?? "",
stderr: execError.stderr ?? "",
};
}
throw error;
}
}
getVersion(): Promise<string> {
if (!this.godotPath) {
// no-op to keep type-checkers happy; real resolution happens in callers.
}
return execFileAsync(this.godotPath!, ["--version"]).then((r) =>
(r.stdout ?? "").trim()
);
}
runProject(
projectPath: string,
scene?: string
): Promise<{ started: true }> {
if (!this.godotPath) {
throw new Error("Godot executable path is not resolved.");
}
if (this.activeProcess) {
this.activeProcess.process.kill();
this.activeProcess = null;
}
// NOTE: `--headless` is required on headless/servers where no X11/Wayland
// display server is available. Without it, Godot fails to create a
// DisplayServer and exits immediately (see project README investigation).
const cmdArgs = ["--headless", "--path", projectPath];
if (scene && this.validatePath(scene)) cmdArgs.push("--scene", scene);
const process = spawn(this.godotPath, cmdArgs, { stdio: "pipe" });
const output: string[] = [];
const errors: string[] = [];
process.stdout?.on("data", (data: Uint8Array) => {
output.push(...data.toString().split("\n"));
});
process.stderr?.on("data", (data: Uint8Array) => {
errors.push(...data.toString().split("\n"));
});
process.on("exit", () => {
if (this.activeProcess?.process === process) this.activeProcess = null;
});
process.on("error", () => {
if (this.activeProcess?.process === process) this.activeProcess = null;
});
this.activeProcess = { process, output, errors };
return Promise.resolve({ started: true });
}
getDebugOutput(): { output: string[]; errors: string[] } | null {
if (!this.activeProcess) return null;
return { output: this.activeProcess.output, errors: this.activeProcess.errors };
}
stopProject(): { stopped: true; finalOutput: string[]; finalErrors: string[] } | null {
if (!this.activeProcess) return null;
const { output, errors } = this.activeProcess;
this.activeProcess.process.kill();
this.activeProcess = null;
return { stopped: true, finalOutput: output, finalErrors: errors };
}
}