gdharness 0.5.8 → 0.5.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/cli.js +124 -38
- package/build/godot/addons/gdharness_editor/bridge_client.gd +136 -1
- package/build/index.js +122 -44
- package/package.json +1 -1
package/build/cli.js
CHANGED
|
@@ -30646,6 +30646,47 @@ var init_stdio2 = __esm(() => {
|
|
|
30646
30646
|
init_stdio();
|
|
30647
30647
|
});
|
|
30648
30648
|
|
|
30649
|
+
// src/bridge-announce.ts
|
|
30650
|
+
import { mkdirSync as mkdirSync5, rmSync as rmSync6, writeFileSync as writeFileSync7 } from "node:fs";
|
|
30651
|
+
import { dirname as dirname6, join as join11 } from "node:path";
|
|
30652
|
+
function announcementPath(projectPath) {
|
|
30653
|
+
return join11(projectPath, ".godot", "gdharness-bridge.json");
|
|
30654
|
+
}
|
|
30655
|
+
function announceBridge(projectPath, bridge) {
|
|
30656
|
+
const path = announcementPath(projectPath);
|
|
30657
|
+
try {
|
|
30658
|
+
mkdirSync5(dirname6(path), { recursive: true });
|
|
30659
|
+
const announcement = {
|
|
30660
|
+
protocol: BRIDGE_ANNOUNCE_PROTOCOL,
|
|
30661
|
+
host: bridge.host,
|
|
30662
|
+
port: bridge.port,
|
|
30663
|
+
pid: process.pid,
|
|
30664
|
+
version: bridge.version,
|
|
30665
|
+
startedAt: new Date().toISOString()
|
|
30666
|
+
};
|
|
30667
|
+
writeFileSync7(path, `${JSON.stringify(announcement, null, 2)}
|
|
30668
|
+
`, "utf8");
|
|
30669
|
+
return path;
|
|
30670
|
+
} catch (error) {
|
|
30671
|
+
console.error(`[SERVER] Could not announce the editor bridge at ${path}: ${errorMessage(error)}`);
|
|
30672
|
+
return null;
|
|
30673
|
+
}
|
|
30674
|
+
}
|
|
30675
|
+
function withdrawBridge(path) {
|
|
30676
|
+
if (path === null) {
|
|
30677
|
+
return;
|
|
30678
|
+
}
|
|
30679
|
+
try {
|
|
30680
|
+
rmSync6(path, { force: true });
|
|
30681
|
+
} catch (error) {
|
|
30682
|
+
console.error(`[SERVER] Could not withdraw the editor bridge announcement: ${errorMessage(error)}`);
|
|
30683
|
+
}
|
|
30684
|
+
}
|
|
30685
|
+
var BRIDGE_ANNOUNCE_PROTOCOL = 1;
|
|
30686
|
+
var init_bridge_announce = __esm(() => {
|
|
30687
|
+
init_errors();
|
|
30688
|
+
});
|
|
30689
|
+
|
|
30649
30690
|
// src/framing.ts
|
|
30650
30691
|
function frame(message) {
|
|
30651
30692
|
const body = Buffer.from(JSON.stringify(message), "utf8");
|
|
@@ -34113,8 +34154,8 @@ function resolveDefaultBridgeHost() {
|
|
|
34113
34154
|
const host = process.env["GDHARNESS_BRIDGE_HOST"]?.trim();
|
|
34114
34155
|
return host === undefined || host === "" ? DEFAULT_HOST : host;
|
|
34115
34156
|
}
|
|
34116
|
-
function getDefaultBridge() {
|
|
34117
|
-
defaultBridge ??= new GodotBridge(resolveDefaultBridgePort(), resolveDefaultBridgeHost());
|
|
34157
|
+
function getDefaultBridge(mayMove = false) {
|
|
34158
|
+
defaultBridge ??= new GodotBridge(resolveDefaultBridgePort(), resolveDefaultBridgeHost(), DEFAULT_TIMEOUT_MS, mayMove);
|
|
34118
34159
|
return defaultBridge;
|
|
34119
34160
|
}
|
|
34120
34161
|
var DEFAULT_PORT = 6505, DEFAULT_HOST = "127.0.0.1", DEFAULT_TIMEOUT_MS = 30000, KEEPALIVE_INTERVAL_MS = 1e4, SECOND_CONNECTION_CLOSE_CODE = 4000, GodotBridge, defaultBridge = null;
|
|
@@ -34130,19 +34171,37 @@ var init_godot_bridge = __esm(() => {
|
|
|
34130
34171
|
connectionInfo = null;
|
|
34131
34172
|
pendingRequests = new Map;
|
|
34132
34173
|
resourceQueues = new Map;
|
|
34133
|
-
|
|
34174
|
+
wantedPort;
|
|
34175
|
+
boundPort = null;
|
|
34134
34176
|
host;
|
|
34135
34177
|
timeoutMs;
|
|
34136
|
-
|
|
34178
|
+
mayMove;
|
|
34179
|
+
constructor(port = DEFAULT_PORT, host = DEFAULT_HOST, timeoutMs = DEFAULT_TIMEOUT_MS, mayMove = false) {
|
|
34137
34180
|
super();
|
|
34138
|
-
this.
|
|
34181
|
+
this.wantedPort = port;
|
|
34139
34182
|
this.host = host;
|
|
34140
34183
|
this.timeoutMs = timeoutMs;
|
|
34184
|
+
this.mayMove = mayMove;
|
|
34185
|
+
}
|
|
34186
|
+
get port() {
|
|
34187
|
+
return this.boundPort ?? this.wantedPort;
|
|
34141
34188
|
}
|
|
34142
|
-
start() {
|
|
34189
|
+
async start() {
|
|
34143
34190
|
if (this.httpServer) {
|
|
34144
|
-
return
|
|
34191
|
+
return;
|
|
34192
|
+
}
|
|
34193
|
+
try {
|
|
34194
|
+
await this.listenOn(this.wantedPort);
|
|
34195
|
+
} catch (error) {
|
|
34196
|
+
const held = error instanceof Error && "code" in error && error.code === "EADDRINUSE";
|
|
34197
|
+
if (!held || !this.mayMove) {
|
|
34198
|
+
throw error;
|
|
34199
|
+
}
|
|
34200
|
+
this.log("warn", `Editor bridge port ${this.wantedPort} is held; taking another one.`);
|
|
34201
|
+
await this.listenOn(0);
|
|
34145
34202
|
}
|
|
34203
|
+
}
|
|
34204
|
+
listenOn(port) {
|
|
34146
34205
|
return new Promise((resolve, reject) => {
|
|
34147
34206
|
const server = http.createServer((_req, res) => {
|
|
34148
34207
|
res.writeHead(404);
|
|
@@ -34166,6 +34225,8 @@ var init_godot_bridge = __esm(() => {
|
|
|
34166
34225
|
settled = true;
|
|
34167
34226
|
this.httpServer = server;
|
|
34168
34227
|
this.godotWss = godotWss;
|
|
34228
|
+
const bound = server.address();
|
|
34229
|
+
this.boundPort = typeof bound === "object" && bound !== null ? bound.port : port;
|
|
34169
34230
|
this.log("info", `Editor bridge listening on ${this.host}:${this.port}`);
|
|
34170
34231
|
resolve();
|
|
34171
34232
|
});
|
|
@@ -34182,7 +34243,7 @@ var init_godot_bridge = __esm(() => {
|
|
|
34182
34243
|
godotWss.on("error", (error) => {
|
|
34183
34244
|
this.log("error", `Godot WebSocket server error: ${error.message}`);
|
|
34184
34245
|
});
|
|
34185
|
-
server.listen(
|
|
34246
|
+
server.listen(port, this.host);
|
|
34186
34247
|
});
|
|
34187
34248
|
}
|
|
34188
34249
|
async stop() {
|
|
@@ -34216,6 +34277,7 @@ var init_godot_bridge = __esm(() => {
|
|
|
34216
34277
|
this.httpServer = null;
|
|
34217
34278
|
}
|
|
34218
34279
|
await Promise.all(closeTasks);
|
|
34280
|
+
this.boundPort = null;
|
|
34219
34281
|
this.connectionInfo = null;
|
|
34220
34282
|
this.log("info", "WebSocket bridge stopped");
|
|
34221
34283
|
}
|
|
@@ -34865,7 +34927,7 @@ var init_junit = __esm(() => {
|
|
|
34865
34927
|
import { realpathSync } from "node:fs";
|
|
34866
34928
|
import { readFile, realpath } from "node:fs/promises";
|
|
34867
34929
|
import { createConnection as createConnection2 } from "node:net";
|
|
34868
|
-
import { dirname as
|
|
34930
|
+
import { dirname as dirname7, resolve as resolve3 } from "node:path";
|
|
34869
34931
|
import { fileURLToPath as fileURLToPath3, pathToFileURL } from "node:url";
|
|
34870
34932
|
function diagnosticsKey(uri) {
|
|
34871
34933
|
let decoded;
|
|
@@ -34991,7 +35053,7 @@ class GodotLSPClient {
|
|
|
34991
35053
|
if (this.initialized) {
|
|
34992
35054
|
return;
|
|
34993
35055
|
}
|
|
34994
|
-
const rootPath = this.rootPath ??
|
|
35056
|
+
const rootPath = this.rootPath ?? dirname7(resolve3(filePath));
|
|
34995
35057
|
await this.initialize(rootPath);
|
|
34996
35058
|
}
|
|
34997
35059
|
async sendRequest(method, params) {
|
|
@@ -35366,7 +35428,7 @@ var init_lsp_client = __esm(() => {
|
|
|
35366
35428
|
|
|
35367
35429
|
// src/project-scan.ts
|
|
35368
35430
|
import { readdirSync as readdirSync4, readFileSync as readFileSync9 } from "node:fs";
|
|
35369
|
-
import { join as
|
|
35431
|
+
import { join as join12 } from "node:path";
|
|
35370
35432
|
function projectStructure(projectPath) {
|
|
35371
35433
|
const structure = { scenes: 0, scripts: 0, assets: 0, other: 0 };
|
|
35372
35434
|
const visit = (directory) => {
|
|
@@ -35375,7 +35437,7 @@ function projectStructure(projectPath) {
|
|
|
35375
35437
|
continue;
|
|
35376
35438
|
}
|
|
35377
35439
|
if (entry.isDirectory()) {
|
|
35378
|
-
visit(
|
|
35440
|
+
visit(join12(directory, entry.name));
|
|
35379
35441
|
} else if (entry.isFile()) {
|
|
35380
35442
|
const extension = entry.name.split(".").pop()?.toLowerCase() ?? "";
|
|
35381
35443
|
if (extension === "tscn") {
|
|
@@ -35417,7 +35479,7 @@ function searchProject(projectPath, options) {
|
|
|
35417
35479
|
if (SKIPPED.has(entry.name)) {
|
|
35418
35480
|
continue;
|
|
35419
35481
|
}
|
|
35420
|
-
const entryPath =
|
|
35482
|
+
const entryPath = join12(directory, entry.name);
|
|
35421
35483
|
if (entry.isDirectory()) {
|
|
35422
35484
|
visit(entryPath);
|
|
35423
35485
|
continue;
|
|
@@ -35459,20 +35521,20 @@ var init_project_scan = __esm(() => {
|
|
|
35459
35521
|
import { existsSync as existsSync10, readdirSync as readdirSync5, readFileSync as readFileSync10, unlinkSync } from "node:fs";
|
|
35460
35522
|
import { createConnection as createConnection3 } from "node:net";
|
|
35461
35523
|
import { tmpdir as tmpdir4 } from "node:os";
|
|
35462
|
-
import { join as
|
|
35524
|
+
import { join as join13, resolve as resolve4 } from "node:path";
|
|
35463
35525
|
function runtimeDirectory(variables = process.env) {
|
|
35464
35526
|
const explicit = envValue("GDHARNESS_RUNTIME_DIR", variables);
|
|
35465
35527
|
if (explicit) {
|
|
35466
35528
|
return explicit;
|
|
35467
35529
|
}
|
|
35468
35530
|
const perUser = envValue("XDG_RUNTIME_DIR", variables);
|
|
35469
|
-
return
|
|
35531
|
+
return join13(perUser ?? tmpdir4(), "gdharness");
|
|
35470
35532
|
}
|
|
35471
35533
|
function runtimeDirectories(variables = process.env) {
|
|
35472
35534
|
const candidates = [runtimeDirectory(variables)];
|
|
35473
35535
|
const fallbacks = process.platform === "win32" ? [envValue("SystemRoot", variables) ?? envValue("windir", variables) ?? "C:\\Windows"] : ["/tmp", "/var/tmp"];
|
|
35474
35536
|
for (const base of fallbacks) {
|
|
35475
|
-
candidates.push(
|
|
35537
|
+
candidates.push(join13(base, process.platform === "win32" ? "Temp" : "", "gdharness"));
|
|
35476
35538
|
}
|
|
35477
35539
|
return [...new Set(candidates.map((path) => resolve4(path)))];
|
|
35478
35540
|
}
|
|
@@ -35535,7 +35597,7 @@ function announcedIn(directory) {
|
|
|
35535
35597
|
if (!match) {
|
|
35536
35598
|
continue;
|
|
35537
35599
|
}
|
|
35538
|
-
const file =
|
|
35600
|
+
const file = join13(directory, entry);
|
|
35539
35601
|
const pid = Number.parseInt(match[1] ?? "", 10);
|
|
35540
35602
|
const announced = processAlive(pid) ? parseAnnouncement(file, pid) : { kind: "rubbish" };
|
|
35541
35603
|
if (announced.kind === "rubbish") {
|
|
@@ -35681,9 +35743,9 @@ var init_runtime_client = __esm(() => {
|
|
|
35681
35743
|
|
|
35682
35744
|
// src/server.ts
|
|
35683
35745
|
import { execFile as execFile5, spawn } from "node:child_process";
|
|
35684
|
-
import { existsSync as existsSync11, mkdtempSync as mkdtempSync3, readdirSync as readdirSync6, readFileSync as readFileSync11, realpathSync as realpathSync2, rmSync as
|
|
35746
|
+
import { existsSync as existsSync11, mkdtempSync as mkdtempSync3, readdirSync as readdirSync6, readFileSync as readFileSync11, realpathSync as realpathSync2, rmSync as rmSync7 } from "node:fs";
|
|
35685
35747
|
import { tmpdir as tmpdir5 } from "node:os";
|
|
35686
|
-
import { basename as basename2, dirname as
|
|
35748
|
+
import { basename as basename2, dirname as dirname8, join as join14, normalize as normalize3 } from "node:path";
|
|
35687
35749
|
import { setTimeout as delay2 } from "node:timers/promises";
|
|
35688
35750
|
import { fileURLToPath as fileURLToPath4 } from "node:url";
|
|
35689
35751
|
import { promisify as promisify5 } from "node:util";
|
|
@@ -35729,7 +35791,7 @@ function camelCased(params) {
|
|
|
35729
35791
|
class GodotServer {
|
|
35730
35792
|
mcp;
|
|
35731
35793
|
locator = new GodotLocator2;
|
|
35732
|
-
operationsScript =
|
|
35794
|
+
operationsScript = join14(__dirname2, "godot", "operations", "godot_operations.gd");
|
|
35733
35795
|
godotBridge;
|
|
35734
35796
|
tools = buildToolDefinitions();
|
|
35735
35797
|
activeProcess = null;
|
|
@@ -35743,8 +35805,11 @@ class GodotServer {
|
|
|
35743
35805
|
bridgeRetry = null;
|
|
35744
35806
|
lastProjectPath = null;
|
|
35745
35807
|
shutdownInitiated = false;
|
|
35808
|
+
ownProject;
|
|
35809
|
+
announcedAt = null;
|
|
35746
35810
|
constructor() {
|
|
35747
|
-
this.
|
|
35811
|
+
this.ownProject = envValue("GDHARNESS_PROJECT") ?? null;
|
|
35812
|
+
this.godotBridge = getDefaultBridge(this.ownProject !== null);
|
|
35748
35813
|
this.mcp = new McpServer({ name: "gdharness", version: SERVER_VERSION }, { capabilities: { tools: {}, resources: {} } });
|
|
35749
35814
|
this.setupToolHandlers();
|
|
35750
35815
|
setupResourceHandlers(this.mcp, () => this.lastProjectPath);
|
|
@@ -35792,6 +35857,7 @@ class GodotServer {
|
|
|
35792
35857
|
this.bridgeStartupError = null;
|
|
35793
35858
|
const bridgeStatus = this.godotBridge.getStatus();
|
|
35794
35859
|
console.error(`[SERVER] Godot Editor Bridge started on ${bridgeStatus.host}:${bridgeStatus.port}`);
|
|
35860
|
+
this.announceTheBridge();
|
|
35795
35861
|
} catch (bridgeError) {
|
|
35796
35862
|
const code = bridgeError instanceof Error && "code" in bridgeError && typeof bridgeError.code === "string" ? bridgeError.code : null;
|
|
35797
35863
|
const reason = errorMessage(bridgeError);
|
|
@@ -35824,6 +35890,18 @@ class GodotServer {
|
|
|
35824
35890
|
this.stopTryingTheBridge();
|
|
35825
35891
|
const bridgeStatus = this.godotBridge.getStatus();
|
|
35826
35892
|
console.error(`[SERVER] Godot Editor Bridge came up on ${bridgeStatus.host}:${bridgeStatus.port}; editor tools are live.`);
|
|
35893
|
+
this.announceTheBridge();
|
|
35894
|
+
}
|
|
35895
|
+
announceTheBridge() {
|
|
35896
|
+
if (this.ownProject === null) {
|
|
35897
|
+
return;
|
|
35898
|
+
}
|
|
35899
|
+
const status = this.godotBridge.getStatus();
|
|
35900
|
+
this.announcedAt = announceBridge(this.ownProject, {
|
|
35901
|
+
host: status.host,
|
|
35902
|
+
port: status.port,
|
|
35903
|
+
version: SERVER_VERSION
|
|
35904
|
+
});
|
|
35827
35905
|
}
|
|
35828
35906
|
stopTryingTheBridge() {
|
|
35829
35907
|
if (this.bridgeRetry !== null) {
|
|
@@ -35834,6 +35912,8 @@ class GodotServer {
|
|
|
35834
35912
|
async cleanup() {
|
|
35835
35913
|
this.logDebug("Cleaning up resources");
|
|
35836
35914
|
this.stopTryingTheBridge();
|
|
35915
|
+
withdrawBridge(this.announcedAt);
|
|
35916
|
+
this.announcedAt = null;
|
|
35837
35917
|
if (this.activeProcess) {
|
|
35838
35918
|
this.activeProcess.process?.kill();
|
|
35839
35919
|
this.activeProcess = null;
|
|
@@ -36216,7 +36296,7 @@ class GodotServer {
|
|
|
36216
36296
|
if (path === undefined) {
|
|
36217
36297
|
return { ok: false, response: this.createErrorResponse("projectPath is required.") };
|
|
36218
36298
|
}
|
|
36219
|
-
const file =
|
|
36299
|
+
const file = join14(path, "project.godot");
|
|
36220
36300
|
if (!existsSync11(file)) {
|
|
36221
36301
|
return {
|
|
36222
36302
|
ok: false,
|
|
@@ -36481,7 +36561,7 @@ class GodotServer {
|
|
|
36481
36561
|
return contained.response;
|
|
36482
36562
|
}
|
|
36483
36563
|
const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
|
|
36484
|
-
if (!existsSync11(
|
|
36564
|
+
if (!existsSync11(join14(project.value.path, runner))) {
|
|
36485
36565
|
return this.createErrorResponse(`gdUnit4 is not installed in this project: no ${runner}.`, [
|
|
36486
36566
|
"Install gdUnit4 under addons/gdUnit4, from https://github.com/godot-gdunit-labs/gdUnit4"
|
|
36487
36567
|
]);
|
|
@@ -36514,7 +36594,7 @@ class GodotServer {
|
|
|
36514
36594
|
];
|
|
36515
36595
|
const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 600000;
|
|
36516
36596
|
this.logDebug(`Running tests: ${engine.value} ${cmdArgs.join(" ")}`);
|
|
36517
|
-
const userData = mkdtempSync3(
|
|
36597
|
+
const userData = mkdtempSync3(join14(tmpdir5(), "gdharness-tests-"));
|
|
36518
36598
|
const run = this.spawnGame(engine.value, cmdArgs, userDataIn(userData));
|
|
36519
36599
|
const hung = await new Promise((resolve) => {
|
|
36520
36600
|
const timer = setTimeout(() => {
|
|
@@ -36530,20 +36610,20 @@ class GodotServer {
|
|
|
36530
36610
|
resolve(false);
|
|
36531
36611
|
});
|
|
36532
36612
|
});
|
|
36533
|
-
const reportsDir =
|
|
36613
|
+
const reportsDir = join14(project.value.path, ".godot", "gdharness-reports");
|
|
36534
36614
|
let report = null;
|
|
36535
36615
|
let reportProblem = null;
|
|
36536
36616
|
try {
|
|
36537
36617
|
const written = existsSync11(reportsDir) ? readdirSync6(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
|
|
36538
36618
|
const newest = written.at(-1);
|
|
36539
36619
|
if (newest !== undefined) {
|
|
36540
|
-
report = parseJUnit(readFileSync11(
|
|
36620
|
+
report = parseJUnit(readFileSync11(join14(reportsDir, newest, "results.xml"), "utf8"));
|
|
36541
36621
|
}
|
|
36542
36622
|
} catch (error) {
|
|
36543
36623
|
reportProblem = errorMessage(error);
|
|
36544
36624
|
} finally {
|
|
36545
|
-
|
|
36546
|
-
|
|
36625
|
+
rmSync7(reportsDir, { recursive: true, force: true });
|
|
36626
|
+
rmSync7(userData, { recursive: true, force: true });
|
|
36547
36627
|
}
|
|
36548
36628
|
const engineEntries = run.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries;
|
|
36549
36629
|
const verdicts = {
|
|
@@ -36656,6 +36736,7 @@ class GodotServer {
|
|
|
36656
36736
|
startupError: this.bridgeStartupError,
|
|
36657
36737
|
staleNote: stale ? addonMismatch(status.addonVersion, SERVER_VERSION) : undefined,
|
|
36658
36738
|
retryingBridge: this.bridgeRetry === null ? undefined : true,
|
|
36739
|
+
announcedAt: this.announcedAt ?? undefined,
|
|
36659
36740
|
note: isPortConflict ? "Bridge port is already in use. Another gdharness instance owns the editor bridge, so this server cannot reach the editor. Usually the server this one replaced, still on its way out." : undefined,
|
|
36660
36741
|
suggestion: isPortConflict ? `This server is asking for the port again every ${BRIDGE_RETRY_MS / 1000}s and takes it the moment the other one lets go, so editor tools come back on their own. Ask again, or end the older gdharness process to have it now.` : undefined
|
|
36661
36742
|
};
|
|
@@ -37051,8 +37132,8 @@ class GodotServer {
|
|
|
37051
37132
|
return this.createErrorResponse(choice.problem);
|
|
37052
37133
|
}
|
|
37053
37134
|
const expectsScreenshot = command === "capture_screenshot" || command === "capture_viewport";
|
|
37054
|
-
const screenshotDir = expectsScreenshot ? mkdtempSync3(
|
|
37055
|
-
const screenshotPath = screenshotDir ?
|
|
37135
|
+
const screenshotDir = expectsScreenshot ? mkdtempSync3(join14(tmpdir5(), "gdharness-runtime-screenshot-")) : null;
|
|
37136
|
+
const screenshotPath = screenshotDir ? join14(screenshotDir, "capture.png") : null;
|
|
37056
37137
|
try {
|
|
37057
37138
|
const reply = await runtimeRequest(choice.endpoint, command, screenshotPath ? { ...params, output_path: screenshotPath } : params, timeoutMs);
|
|
37058
37139
|
if (!reply.ok) {
|
|
@@ -37078,7 +37159,7 @@ class GodotServer {
|
|
|
37078
37159
|
};
|
|
37079
37160
|
} finally {
|
|
37080
37161
|
if (screenshotDir) {
|
|
37081
|
-
|
|
37162
|
+
rmSync7(screenshotDir, { recursive: true, force: true });
|
|
37082
37163
|
}
|
|
37083
37164
|
}
|
|
37084
37165
|
}
|
|
@@ -37140,6 +37221,7 @@ var init_server3 = __esm(() => {
|
|
|
37140
37221
|
init_mcp();
|
|
37141
37222
|
init_stdio2();
|
|
37142
37223
|
init_types();
|
|
37224
|
+
init_bridge_announce();
|
|
37143
37225
|
init_class_cache();
|
|
37144
37226
|
init_dap_client();
|
|
37145
37227
|
init_errors();
|
|
@@ -37158,7 +37240,7 @@ var init_server3 = __esm(() => {
|
|
|
37158
37240
|
init_tool_definitions();
|
|
37159
37241
|
init_update_check();
|
|
37160
37242
|
run5 = promisify5(execFile5);
|
|
37161
|
-
__dirname2 =
|
|
37243
|
+
__dirname2 = dirname8(fileURLToPath4(import.meta.url));
|
|
37162
37244
|
PATH_SOLUTIONS = [
|
|
37163
37245
|
'Give the path relative to the project, such as "scenes/main.tscn" or "res://scenes/main.tscn"',
|
|
37164
37246
|
"Point projectPath at the project the file belongs to"
|
|
@@ -37216,7 +37298,7 @@ var init_server3 = __esm(() => {
|
|
|
37216
37298
|
|
|
37217
37299
|
// src/cli.ts
|
|
37218
37300
|
import { existsSync as existsSync12 } from "fs";
|
|
37219
|
-
import { join as
|
|
37301
|
+
import { join as join15, resolve as resolve5 } from "path";
|
|
37220
37302
|
|
|
37221
37303
|
// src/errors.ts
|
|
37222
37304
|
class Refusal extends Error {
|
|
@@ -37426,9 +37508,13 @@ function isEmptyCollection(node) {
|
|
|
37426
37508
|
init_errors();
|
|
37427
37509
|
init_runner();
|
|
37428
37510
|
var SERVER_KEY = "gdharness";
|
|
37429
|
-
function launchFor(version, godotPath, runner = currentRunner()) {
|
|
37511
|
+
function launchFor(version, godotPath, projectPath, runner = currentRunner()) {
|
|
37430
37512
|
const spawn = spawnFor(runner, version);
|
|
37431
|
-
return {
|
|
37513
|
+
return {
|
|
37514
|
+
command: spawn.command,
|
|
37515
|
+
args: spawn.args,
|
|
37516
|
+
env: { GODOT_PATH: godotPath, GDHARNESS_PROJECT: projectPath }
|
|
37517
|
+
};
|
|
37432
37518
|
}
|
|
37433
37519
|
function tomlArray(values) {
|
|
37434
37520
|
return `[${values.map((value) => `"${value}"`).join(", ")}]`;
|
|
@@ -38614,7 +38700,7 @@ class UsageError extends Refusal {
|
|
|
38614
38700
|
function projectArgument(at) {
|
|
38615
38701
|
const named = args.slice(at).find((value) => !value.startsWith("--"));
|
|
38616
38702
|
const projectPath = resolve5(named ?? ".");
|
|
38617
|
-
if (!existsSync12(
|
|
38703
|
+
if (!existsSync12(join15(projectPath, "project.godot"))) {
|
|
38618
38704
|
throw new UsageError(`Not a Godot project: ${projectPath} holds no project.godot.`);
|
|
38619
38705
|
}
|
|
38620
38706
|
return projectPath;
|
|
@@ -38740,7 +38826,7 @@ async function setup() {
|
|
|
38740
38826
|
console.log(`class list rebuilt: ${String(classes.payload["classes"])} classes`);
|
|
38741
38827
|
}
|
|
38742
38828
|
if (!args.includes("--no-connect")) {
|
|
38743
|
-
const launch = launchFor(getLocalVersion(), godot.godotPath);
|
|
38829
|
+
const launch = launchFor(getLocalVersion(), godot.godotPath, projectPath);
|
|
38744
38830
|
const ask = new Ask;
|
|
38745
38831
|
let chosen;
|
|
38746
38832
|
try {
|
|
@@ -38835,7 +38921,7 @@ async function upgrade() {
|
|
|
38835
38921
|
said(await setRuntime(godot, projectPath, true), "registering the runtime autoload");
|
|
38836
38922
|
}
|
|
38837
38923
|
said(await runOperation(godot, "refresh_class_cache", {}, projectPath), "rebuilding the class list");
|
|
38838
|
-
const launch = launchFor(version, godot.godotPath);
|
|
38924
|
+
const launch = launchFor(version, godot.godotPath, projectPath);
|
|
38839
38925
|
const already = HARNESSES.filter((harness) => registered(harness, projectPath));
|
|
38840
38926
|
for (const group of groupByFile(already, projectPath)) {
|
|
38841
38927
|
reportConnection(group, launch, projectPath);
|
|
@@ -11,11 +11,34 @@ signal tool_requested(request_id: String, tool_name: String, args: Dictionary)
|
|
|
11
11
|
const DEFAULT_URL: String = "ws://127.0.0.1:6505/godot"
|
|
12
12
|
## Written beside the addon by the install, so it names the version this copy came from.
|
|
13
13
|
const VERSION_MARKER: String = "res://addons/gdharness_editor/.gdharness-version"
|
|
14
|
+
## Written by the server that serves this project, saying where its bridge actually is. Kept in
|
|
15
|
+
## step with `announcementPath` in src/bridge-announce.ts.
|
|
16
|
+
const ANNOUNCEMENT: String = "res://.godot/gdharness-bridge.json"
|
|
17
|
+
const ANNOUNCE_PROTOCOL: int = 1
|
|
14
18
|
const RECONNECT_DELAY: float = 3.0
|
|
15
19
|
const MAX_RECONNECT_DELAY: float = 30.0
|
|
16
20
|
|
|
21
|
+
## How long one attempt is given before the address is called a bad one.
|
|
22
|
+
##
|
|
23
|
+
## A socket pointed at a port nothing holds gives up by itself, in thirty seconds on Windows. One
|
|
24
|
+
## pointed at something that accepts and then never speaks WebSocket does not give up at all:
|
|
25
|
+
## measured here, still connecting after forty-five seconds, because the peer has no handshake
|
|
26
|
+
## timeout of its own in 4.7. A leftover announcement can be either, and a port that has been
|
|
27
|
+
## reused by some other program is the second.
|
|
28
|
+
const CONNECT_TIMEOUT: float = 10.0
|
|
29
|
+
|
|
30
|
+
## How often the announcement is read again while connected, so a newer server is moved to
|
|
31
|
+
## rather than waited for. A harness reconnect leaves the server it replaced running and holding
|
|
32
|
+
## the old port, and an editor with no reason to look elsewhere stayed on it for the session.
|
|
33
|
+
const FOLLOW_INTERVAL: float = 5.0
|
|
34
|
+
|
|
17
35
|
var socket: WebSocketPeer = WebSocketPeer.new()
|
|
18
36
|
var server_url: String = DEFAULT_URL
|
|
37
|
+
|
|
38
|
+
## What this copy was when it loaded, which is what the editor is running until it is restarted.
|
|
39
|
+
## See [method _loaded_version] for why it is held rather than read when it is wanted.
|
|
40
|
+
var version_at_load: String = ""
|
|
41
|
+
|
|
19
42
|
var _is_connected: bool = false
|
|
20
43
|
var _reconnect_timer: Timer
|
|
21
44
|
var _current_reconnect_delay: float = RECONNECT_DELAY
|
|
@@ -24,9 +47,21 @@ var _should_reconnect: bool = false
|
|
|
24
47
|
var _project_path: String
|
|
25
48
|
var _initialized: bool = false
|
|
26
49
|
|
|
50
|
+
## Whether the address came from a caller rather than from the project, in which case it is not
|
|
51
|
+
## this node's to change.
|
|
52
|
+
var _named_by_caller: bool = false
|
|
53
|
+
var _since_looked: float = 0.0
|
|
54
|
+
|
|
55
|
+
## The announced address that did not answer, so the fallback gets a turn. Cleared the moment
|
|
56
|
+
## anything connects or the project names a different one.
|
|
57
|
+
var _refused_url: String = ""
|
|
58
|
+
var _tried_announced: bool = false
|
|
59
|
+
var _connecting_for: float = 0.0
|
|
60
|
+
|
|
27
61
|
|
|
28
62
|
func _ready() -> void:
|
|
29
63
|
_project_path = ProjectSettings.globalize_path("res://")
|
|
64
|
+
version_at_load = _loaded_version()
|
|
30
65
|
|
|
31
66
|
_reconnect_timer = Timer.new()
|
|
32
67
|
_reconnect_timer.one_shot = true
|
|
@@ -48,6 +83,8 @@ func _process(_delta: float) -> void:
|
|
|
48
83
|
# A connection refused never opened, so it reaches CLOSED without passing through
|
|
49
84
|
# _handle_disconnect and nothing would ask again. An editor opened before the server
|
|
50
85
|
# is the ordinary way that happens, and it then sat there for the rest of the day.
|
|
86
|
+
if _tried_announced:
|
|
87
|
+
_refused_url = server_url
|
|
51
88
|
_schedule_reconnect()
|
|
52
89
|
return
|
|
53
90
|
|
|
@@ -57,11 +94,17 @@ func _process(_delta: float) -> void:
|
|
|
57
94
|
WebSocketPeer.STATE_OPEN:
|
|
58
95
|
if not _is_connected:
|
|
59
96
|
_handle_connect()
|
|
97
|
+
_follow_whoever_is_newest(_delta)
|
|
60
98
|
|
|
61
99
|
while socket.get_available_packet_count() > 0:
|
|
62
100
|
var packet: PackedByteArray = socket.get_packet()
|
|
63
101
|
_handle_message(packet.get_string_from_utf8())
|
|
64
102
|
|
|
103
|
+
WebSocketPeer.STATE_CONNECTING:
|
|
104
|
+
_connecting_for += _delta
|
|
105
|
+
if _connecting_for >= CONNECT_TIMEOUT:
|
|
106
|
+
_give_up_on_this_address()
|
|
107
|
+
|
|
65
108
|
WebSocketPeer.STATE_CLOSING:
|
|
66
109
|
pass
|
|
67
110
|
|
|
@@ -70,7 +113,38 @@ func _process(_delta: float) -> void:
|
|
|
70
113
|
_handle_disconnect()
|
|
71
114
|
|
|
72
115
|
|
|
116
|
+
## Moves to the server the project now names, when that is not the one this is talking to.
|
|
117
|
+
##
|
|
118
|
+
## A harness reconnect leaves the server it replaced running, still holding the port it bound and
|
|
119
|
+
## still answering: the editor has no reason to notice, and stayed on a server nothing was
|
|
120
|
+
## speaking to for the rest of the session. The replacement announces where it landed, so the
|
|
121
|
+
## editor can go to it rather than anybody ending a process.
|
|
122
|
+
##
|
|
123
|
+
## Only while connected by a URL this worked out for itself. A caller that named one is holding
|
|
124
|
+
## this to that address, which is what every fixture does.
|
|
125
|
+
func _follow_whoever_is_newest(delta: float) -> void:
|
|
126
|
+
if _named_by_caller:
|
|
127
|
+
return
|
|
128
|
+
_since_looked += delta
|
|
129
|
+
if _since_looked < FOLLOW_INTERVAL:
|
|
130
|
+
return
|
|
131
|
+
_since_looked = 0.0
|
|
132
|
+
|
|
133
|
+
var announced: String = announced_url()
|
|
134
|
+
if announced == "" or announced == server_url or announced == _refused_url:
|
|
135
|
+
return
|
|
136
|
+
server_url = announced
|
|
137
|
+
# Put down before it is picked up again, because the arrival is what tells a server who this
|
|
138
|
+
# editor is: left standing, the open socket on the new address would never be greeted and the
|
|
139
|
+
# server would report no editor while holding one.
|
|
140
|
+
_is_connected = false
|
|
141
|
+
disconnected.emit()
|
|
142
|
+
_current_reconnect_delay = RECONNECT_DELAY
|
|
143
|
+
_attempt_connection()
|
|
144
|
+
|
|
145
|
+
|
|
73
146
|
func connect_to_server(url: String = "") -> void:
|
|
147
|
+
_named_by_caller = url != ""
|
|
74
148
|
server_url = _resolve_server_url(url)
|
|
75
149
|
_should_reconnect = true
|
|
76
150
|
_current_reconnect_delay = RECONNECT_DELAY
|
|
@@ -81,6 +155,12 @@ func _resolve_server_url(explicit_url: String) -> String:
|
|
|
81
155
|
if explicit_url != "":
|
|
82
156
|
return explicit_url
|
|
83
157
|
|
|
158
|
+
var announced: String = announced_url()
|
|
159
|
+
if announced != "" and announced != _refused_url:
|
|
160
|
+
_tried_announced = true
|
|
161
|
+
return announced
|
|
162
|
+
_tried_announced = false
|
|
163
|
+
|
|
84
164
|
# The same variable the server reads, so the two agree on the port by construction.
|
|
85
165
|
var raw: String = OS.get_environment("GDHARNESS_BRIDGE_PORT")
|
|
86
166
|
if raw != "":
|
|
@@ -91,6 +171,48 @@ func _resolve_server_url(explicit_url: String) -> String:
|
|
|
91
171
|
return DEFAULT_URL
|
|
92
172
|
|
|
93
173
|
|
|
174
|
+
## Stops waiting on an address that is not answering, and asks again elsewhere.
|
|
175
|
+
func _give_up_on_this_address() -> void:
|
|
176
|
+
if _tried_announced:
|
|
177
|
+
_refused_url = server_url
|
|
178
|
+
_connecting_for = 0.0
|
|
179
|
+
socket.close()
|
|
180
|
+
_schedule_reconnect()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
## Where the server says its bridge is, or "" when nothing has said.
|
|
184
|
+
##
|
|
185
|
+
## Written by a server that knows which project it serves, inside that project, so the two sides
|
|
186
|
+
## agree by construction rather than by deriving a temporary directory the same way: they do not
|
|
187
|
+
## share an environment, and the runtime's own announcement cost a session learning that.
|
|
188
|
+
##
|
|
189
|
+
## A leftover is found out by trying it rather than by asking whether its process is still there.
|
|
190
|
+
## `OS.is_process_running` answers that only for a child of the caller on Unix, where it prints
|
|
191
|
+
## "does not exist or is not a child of the calling process" and says no about every server there
|
|
192
|
+
## is: it works on Windows and quietly disables the whole thing everywhere else. So an
|
|
193
|
+
## announcement that does not answer is set aside, the fallback gets the next turn, and a
|
|
194
|
+
## different announcement puts it back in play.
|
|
195
|
+
func announced_url() -> String:
|
|
196
|
+
if not FileAccess.file_exists(ANNOUNCEMENT):
|
|
197
|
+
return ""
|
|
198
|
+
var file: FileAccess = FileAccess.open(ANNOUNCEMENT, FileAccess.READ)
|
|
199
|
+
if file == null:
|
|
200
|
+
return ""
|
|
201
|
+
var said: Variant = JSON.parse_string(file.get_as_text())
|
|
202
|
+
file.close()
|
|
203
|
+
if not said is Dictionary:
|
|
204
|
+
return ""
|
|
205
|
+
|
|
206
|
+
var announcement: Dictionary = said
|
|
207
|
+
if int(announcement.get("protocol", 0)) != ANNOUNCE_PROTOCOL:
|
|
208
|
+
return ""
|
|
209
|
+
var port: int = int(announcement.get("port", 0))
|
|
210
|
+
if port < 1 or port > 65535:
|
|
211
|
+
return ""
|
|
212
|
+
var host: String = str(announcement.get("host", "127.0.0.1"))
|
|
213
|
+
return "ws://%s:%d/godot" % [host, port]
|
|
214
|
+
|
|
215
|
+
|
|
94
216
|
func disconnect_from_server() -> void:
|
|
95
217
|
_should_reconnect = false
|
|
96
218
|
if _reconnect_timer:
|
|
@@ -104,6 +226,7 @@ func _attempt_connection() -> void:
|
|
|
104
226
|
if socket.get_ready_state() != WebSocketPeer.STATE_CLOSED:
|
|
105
227
|
socket.close()
|
|
106
228
|
|
|
229
|
+
_connecting_for = 0.0
|
|
107
230
|
var err: Error = socket.connect_to_url(server_url)
|
|
108
231
|
if err != OK:
|
|
109
232
|
push_error("[gdharness] Failed to connect to %s: %s" % [server_url, error_string(err)])
|
|
@@ -113,6 +236,7 @@ func _attempt_connection() -> void:
|
|
|
113
236
|
func _handle_connect() -> void:
|
|
114
237
|
_is_connected = true
|
|
115
238
|
_current_reconnect_delay = RECONNECT_DELAY
|
|
239
|
+
_refused_url = ""
|
|
116
240
|
|
|
117
241
|
# The version reported is the one this editor loaded at startup, not the one on disk: an
|
|
118
242
|
# upgrade replaces the files under a running editor, which goes on serving the old code until
|
|
@@ -123,7 +247,7 @@ func _handle_connect() -> void:
|
|
|
123
247
|
{
|
|
124
248
|
"type": "godot_ready",
|
|
125
249
|
"project_path": _project_path,
|
|
126
|
-
"addon_version":
|
|
250
|
+
"addon_version": version_at_load,
|
|
127
251
|
"editor_pid": OS.get_process_id()
|
|
128
252
|
}
|
|
129
253
|
)
|
|
@@ -132,6 +256,13 @@ func _handle_connect() -> void:
|
|
|
132
256
|
|
|
133
257
|
|
|
134
258
|
## The version marker beside this addon, or "" when the copy was not installed by gdharness.
|
|
259
|
+
##
|
|
260
|
+
## Read once, when this copy loads, and never again: an upgrade replaces the files under a
|
|
261
|
+
## running editor and rewrites the marker with them, so reading it at connect time answers with
|
|
262
|
+
## the version on disk rather than the one in memory. That is the wrong answer at the one moment
|
|
263
|
+
## it matters. An upgrade ends with the harness reconnecting, the editor reconnecting behind it,
|
|
264
|
+
## and `addonIsStale` reporting false over an editor still running the old code, which is exactly
|
|
265
|
+
## what it exists to catch.
|
|
135
266
|
func _loaded_version() -> String:
|
|
136
267
|
if not FileAccess.file_exists(VERSION_MARKER):
|
|
137
268
|
return ""
|
|
@@ -159,6 +290,10 @@ func _schedule_reconnect() -> void:
|
|
|
159
290
|
|
|
160
291
|
|
|
161
292
|
func _on_reconnect_timer() -> void:
|
|
293
|
+
# Asked again rather than remembered: the reason a connection dropped is often that its server
|
|
294
|
+
# did, and the one that replaced it has said where it is since.
|
|
295
|
+
if not _named_by_caller:
|
|
296
|
+
server_url = _resolve_server_url("")
|
|
162
297
|
_attempt_connection()
|
|
163
298
|
|
|
164
299
|
|
package/build/index.js
CHANGED
|
@@ -10126,9 +10126,9 @@ function defectReport(where, error, godotVersion) {
|
|
|
10126
10126
|
|
|
10127
10127
|
// src/server.ts
|
|
10128
10128
|
import { execFile as execFile3, spawn } from "node:child_process";
|
|
10129
|
-
import { existsSync as existsSync6, mkdtempSync as mkdtempSync2, readdirSync as readdirSync5, readFileSync as readFileSync7, realpathSync as realpathSync2, rmSync as
|
|
10129
|
+
import { existsSync as existsSync6, mkdtempSync as mkdtempSync2, readdirSync as readdirSync5, readFileSync as readFileSync7, realpathSync as realpathSync2, rmSync as rmSync3 } from "node:fs";
|
|
10130
10130
|
import { tmpdir as tmpdir4 } from "node:os";
|
|
10131
|
-
import { basename, dirname as
|
|
10131
|
+
import { basename, dirname as dirname3, join as join8, normalize as normalize2 } from "node:path";
|
|
10132
10132
|
import { setTimeout as delay2 } from "node:timers/promises";
|
|
10133
10133
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
10134
10134
|
import { promisify as promisify3 } from "node:util";
|
|
@@ -24696,9 +24696,47 @@ class StdioServerTransport {
|
|
|
24696
24696
|
}
|
|
24697
24697
|
}
|
|
24698
24698
|
|
|
24699
|
+
// src/bridge-announce.ts
|
|
24700
|
+
import { mkdirSync as mkdirSync2, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
24701
|
+
import { dirname, join as join2 } from "node:path";
|
|
24702
|
+
function announcementPath(projectPath) {
|
|
24703
|
+
return join2(projectPath, ".godot", "gdharness-bridge.json");
|
|
24704
|
+
}
|
|
24705
|
+
var BRIDGE_ANNOUNCE_PROTOCOL = 1;
|
|
24706
|
+
function announceBridge(projectPath, bridge) {
|
|
24707
|
+
const path = announcementPath(projectPath);
|
|
24708
|
+
try {
|
|
24709
|
+
mkdirSync2(dirname(path), { recursive: true });
|
|
24710
|
+
const announcement = {
|
|
24711
|
+
protocol: BRIDGE_ANNOUNCE_PROTOCOL,
|
|
24712
|
+
host: bridge.host,
|
|
24713
|
+
port: bridge.port,
|
|
24714
|
+
pid: process.pid,
|
|
24715
|
+
version: bridge.version,
|
|
24716
|
+
startedAt: new Date().toISOString()
|
|
24717
|
+
};
|
|
24718
|
+
writeFileSync2(path, `${JSON.stringify(announcement, null, 2)}
|
|
24719
|
+
`, "utf8");
|
|
24720
|
+
return path;
|
|
24721
|
+
} catch (error) {
|
|
24722
|
+
console.error(`[SERVER] Could not announce the editor bridge at ${path}: ${errorMessage(error)}`);
|
|
24723
|
+
return null;
|
|
24724
|
+
}
|
|
24725
|
+
}
|
|
24726
|
+
function withdrawBridge(path) {
|
|
24727
|
+
if (path === null) {
|
|
24728
|
+
return;
|
|
24729
|
+
}
|
|
24730
|
+
try {
|
|
24731
|
+
rmSync(path, { force: true });
|
|
24732
|
+
} catch (error) {
|
|
24733
|
+
console.error(`[SERVER] Could not withdraw the editor bridge announcement: ${errorMessage(error)}`);
|
|
24734
|
+
}
|
|
24735
|
+
}
|
|
24736
|
+
|
|
24699
24737
|
// src/class-cache.ts
|
|
24700
24738
|
import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync3 } from "node:fs";
|
|
24701
|
-
import { join as
|
|
24739
|
+
import { join as join3 } from "node:path";
|
|
24702
24740
|
function declaredClasses(projectPath) {
|
|
24703
24741
|
const declared = new Map;
|
|
24704
24742
|
const visit = (directory, prefix) => {
|
|
@@ -24706,7 +24744,7 @@ function declaredClasses(projectPath) {
|
|
|
24706
24744
|
if (entry.name.startsWith(".")) {
|
|
24707
24745
|
continue;
|
|
24708
24746
|
}
|
|
24709
|
-
const path =
|
|
24747
|
+
const path = join3(directory, entry.name);
|
|
24710
24748
|
if (entry.isDirectory()) {
|
|
24711
24749
|
visit(path, `${prefix}${entry.name}/`);
|
|
24712
24750
|
} else if (entry.isFile() && entry.name.endsWith(".gd")) {
|
|
@@ -24721,7 +24759,7 @@ function declaredClasses(projectPath) {
|
|
|
24721
24759
|
return declared;
|
|
24722
24760
|
}
|
|
24723
24761
|
function cachedClasses(projectPath) {
|
|
24724
|
-
const cache =
|
|
24762
|
+
const cache = join3(projectPath, ".godot", "global_script_class_cache.cfg");
|
|
24725
24763
|
if (!existsSync2(cache)) {
|
|
24726
24764
|
return null;
|
|
24727
24765
|
}
|
|
@@ -25426,19 +25464,37 @@ class GodotBridge extends EventEmitter {
|
|
|
25426
25464
|
connectionInfo = null;
|
|
25427
25465
|
pendingRequests = new Map;
|
|
25428
25466
|
resourceQueues = new Map;
|
|
25429
|
-
|
|
25467
|
+
wantedPort;
|
|
25468
|
+
boundPort = null;
|
|
25430
25469
|
host;
|
|
25431
25470
|
timeoutMs;
|
|
25432
|
-
|
|
25471
|
+
mayMove;
|
|
25472
|
+
constructor(port = DEFAULT_PORT, host = DEFAULT_HOST, timeoutMs = DEFAULT_TIMEOUT_MS, mayMove = false) {
|
|
25433
25473
|
super();
|
|
25434
|
-
this.
|
|
25474
|
+
this.wantedPort = port;
|
|
25435
25475
|
this.host = host;
|
|
25436
25476
|
this.timeoutMs = timeoutMs;
|
|
25477
|
+
this.mayMove = mayMove;
|
|
25478
|
+
}
|
|
25479
|
+
get port() {
|
|
25480
|
+
return this.boundPort ?? this.wantedPort;
|
|
25437
25481
|
}
|
|
25438
|
-
start() {
|
|
25482
|
+
async start() {
|
|
25439
25483
|
if (this.httpServer) {
|
|
25440
|
-
return
|
|
25484
|
+
return;
|
|
25441
25485
|
}
|
|
25486
|
+
try {
|
|
25487
|
+
await this.listenOn(this.wantedPort);
|
|
25488
|
+
} catch (error) {
|
|
25489
|
+
const held = error instanceof Error && "code" in error && error.code === "EADDRINUSE";
|
|
25490
|
+
if (!held || !this.mayMove) {
|
|
25491
|
+
throw error;
|
|
25492
|
+
}
|
|
25493
|
+
this.log("warn", `Editor bridge port ${this.wantedPort} is held; taking another one.`);
|
|
25494
|
+
await this.listenOn(0);
|
|
25495
|
+
}
|
|
25496
|
+
}
|
|
25497
|
+
listenOn(port) {
|
|
25442
25498
|
return new Promise((resolve, reject) => {
|
|
25443
25499
|
const server = http.createServer((_req, res) => {
|
|
25444
25500
|
res.writeHead(404);
|
|
@@ -25462,6 +25518,8 @@ class GodotBridge extends EventEmitter {
|
|
|
25462
25518
|
settled = true;
|
|
25463
25519
|
this.httpServer = server;
|
|
25464
25520
|
this.godotWss = godotWss;
|
|
25521
|
+
const bound = server.address();
|
|
25522
|
+
this.boundPort = typeof bound === "object" && bound !== null ? bound.port : port;
|
|
25465
25523
|
this.log("info", `Editor bridge listening on ${this.host}:${this.port}`);
|
|
25466
25524
|
resolve();
|
|
25467
25525
|
});
|
|
@@ -25478,7 +25536,7 @@ class GodotBridge extends EventEmitter {
|
|
|
25478
25536
|
godotWss.on("error", (error) => {
|
|
25479
25537
|
this.log("error", `Godot WebSocket server error: ${error.message}`);
|
|
25480
25538
|
});
|
|
25481
|
-
server.listen(
|
|
25539
|
+
server.listen(port, this.host);
|
|
25482
25540
|
});
|
|
25483
25541
|
}
|
|
25484
25542
|
async stop() {
|
|
@@ -25512,6 +25570,7 @@ class GodotBridge extends EventEmitter {
|
|
|
25512
25570
|
this.httpServer = null;
|
|
25513
25571
|
}
|
|
25514
25572
|
await Promise.all(closeTasks);
|
|
25573
|
+
this.boundPort = null;
|
|
25515
25574
|
this.connectionInfo = null;
|
|
25516
25575
|
this.log("info", "WebSocket bridge stopped");
|
|
25517
25576
|
}
|
|
@@ -25813,8 +25872,8 @@ class GodotBridge extends EventEmitter {
|
|
|
25813
25872
|
}
|
|
25814
25873
|
}
|
|
25815
25874
|
var defaultBridge = null;
|
|
25816
|
-
function getDefaultBridge() {
|
|
25817
|
-
defaultBridge ??= new GodotBridge(resolveDefaultBridgePort(), resolveDefaultBridgeHost());
|
|
25875
|
+
function getDefaultBridge(mayMove = false) {
|
|
25876
|
+
defaultBridge ??= new GodotBridge(resolveDefaultBridgePort(), resolveDefaultBridgeHost(), DEFAULT_TIMEOUT_MS, mayMove);
|
|
25818
25877
|
return defaultBridge;
|
|
25819
25878
|
}
|
|
25820
25879
|
|
|
@@ -25827,7 +25886,7 @@ import { promisify } from "node:util";
|
|
|
25827
25886
|
// src/detection.ts
|
|
25828
25887
|
import { existsSync as existsSync3, readdirSync as readdirSync2, statSync } from "node:fs";
|
|
25829
25888
|
import { homedir as homedir2 } from "node:os";
|
|
25830
|
-
import { join as
|
|
25889
|
+
import { join as join4 } from "node:path";
|
|
25831
25890
|
function resolveHomeDirectory() {
|
|
25832
25891
|
try {
|
|
25833
25892
|
return homedir2();
|
|
@@ -25851,7 +25910,7 @@ function scanDirectoryForGodotBinaries(directory, platform) {
|
|
|
25851
25910
|
if (!pattern.test(name)) {
|
|
25852
25911
|
continue;
|
|
25853
25912
|
}
|
|
25854
|
-
const fullPath =
|
|
25913
|
+
const fullPath = join4(directory, name);
|
|
25855
25914
|
try {
|
|
25856
25915
|
const stat = statSync(fullPath);
|
|
25857
25916
|
if (stat.isFile()) {
|
|
@@ -25860,7 +25919,7 @@ function scanDirectoryForGodotBinaries(directory, platform) {
|
|
|
25860
25919
|
} catch {}
|
|
25861
25920
|
}
|
|
25862
25921
|
matches.sort((a, b) => b.mtime - a.mtime);
|
|
25863
|
-
return matches.map((m) =>
|
|
25922
|
+
return matches.map((m) => join4(directory, m.name));
|
|
25864
25923
|
}
|
|
25865
25924
|
function conventionalPaths(platform, home) {
|
|
25866
25925
|
const paths = ["godot"];
|
|
@@ -26003,9 +26062,9 @@ class GodotLocator {
|
|
|
26003
26062
|
|
|
26004
26063
|
// src/headless.ts
|
|
26005
26064
|
import { execFile as execFile2 } from "node:child_process";
|
|
26006
|
-
import { mkdtempSync, rmSync, writeFileSync as
|
|
26065
|
+
import { mkdtempSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
|
|
26007
26066
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
26008
|
-
import { join as
|
|
26067
|
+
import { join as join5 } from "node:path";
|
|
26009
26068
|
import { promisify as promisify2 } from "node:util";
|
|
26010
26069
|
var run2 = promisify2(execFile2);
|
|
26011
26070
|
function snakeCased(params) {
|
|
@@ -26046,9 +26105,9 @@ function reason(stdout, stderr) {
|
|
|
26046
26105
|
return stdout.trim().split(/\r?\n/).at(-1) ?? "no output at all";
|
|
26047
26106
|
}
|
|
26048
26107
|
async function runOperation(engine, operation, params, projectPath) {
|
|
26049
|
-
const paramsDir = mkdtempSync(
|
|
26050
|
-
const paramsFile =
|
|
26051
|
-
|
|
26108
|
+
const paramsDir = mkdtempSync(join5(tmpdir2(), "gdharness-params-"));
|
|
26109
|
+
const paramsFile = join5(paramsDir, `${operation}.json`);
|
|
26110
|
+
writeFileSync3(paramsFile, JSON.stringify(snakeCased(params)), "utf8");
|
|
26052
26111
|
const args = [
|
|
26053
26112
|
"--headless",
|
|
26054
26113
|
"--path",
|
|
@@ -26078,7 +26137,7 @@ async function runOperation(engine, operation, params, projectPath) {
|
|
|
26078
26137
|
messages: []
|
|
26079
26138
|
};
|
|
26080
26139
|
} finally {
|
|
26081
|
-
|
|
26140
|
+
rmSync2(paramsDir, { recursive: true, force: true });
|
|
26082
26141
|
}
|
|
26083
26142
|
const payload = lastJsonObject(stdout);
|
|
26084
26143
|
if (payload === null) {
|
|
@@ -26373,7 +26432,7 @@ function parseJUnit(xml) {
|
|
|
26373
26432
|
import { realpathSync } from "node:fs";
|
|
26374
26433
|
import { readFile, realpath } from "node:fs/promises";
|
|
26375
26434
|
import { createConnection as createConnection2 } from "node:net";
|
|
26376
|
-
import { dirname, resolve as resolve2 } from "node:path";
|
|
26435
|
+
import { dirname as dirname2, resolve as resolve2 } from "node:path";
|
|
26377
26436
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
26378
26437
|
|
|
26379
26438
|
// src/paths.ts
|
|
@@ -26551,7 +26610,7 @@ class GodotLSPClient {
|
|
|
26551
26610
|
if (this.initialized) {
|
|
26552
26611
|
return;
|
|
26553
26612
|
}
|
|
26554
|
-
const rootPath = this.rootPath ??
|
|
26613
|
+
const rootPath = this.rootPath ?? dirname2(resolve2(filePath));
|
|
26555
26614
|
await this.initialize(rootPath);
|
|
26556
26615
|
}
|
|
26557
26616
|
async sendRequest(method, params) {
|
|
@@ -26919,7 +26978,7 @@ async function handleLSPTool(client, toolName, args) {
|
|
|
26919
26978
|
|
|
26920
26979
|
// src/project-scan.ts
|
|
26921
26980
|
import { readdirSync as readdirSync3, readFileSync as readFileSync4 } from "node:fs";
|
|
26922
|
-
import { join as
|
|
26981
|
+
import { join as join6 } from "node:path";
|
|
26923
26982
|
var SKIPPED = new Set([".git", ".godot", ".import", "node_modules"]);
|
|
26924
26983
|
var ASSET_EXTENSIONS = new Set(["png", "jpg", "jpeg", "webp", "svg", "ttf", "otf", "wav", "mp3", "ogg"]);
|
|
26925
26984
|
function projectStructure(projectPath) {
|
|
@@ -26930,7 +26989,7 @@ function projectStructure(projectPath) {
|
|
|
26930
26989
|
continue;
|
|
26931
26990
|
}
|
|
26932
26991
|
if (entry.isDirectory()) {
|
|
26933
|
-
visit(
|
|
26992
|
+
visit(join6(directory, entry.name));
|
|
26934
26993
|
} else if (entry.isFile()) {
|
|
26935
26994
|
const extension = entry.name.split(".").pop()?.toLowerCase() ?? "";
|
|
26936
26995
|
if (extension === "tscn") {
|
|
@@ -26972,7 +27031,7 @@ function searchProject(projectPath, options) {
|
|
|
26972
27031
|
if (SKIPPED.has(entry.name)) {
|
|
26973
27032
|
continue;
|
|
26974
27033
|
}
|
|
26975
|
-
const entryPath =
|
|
27034
|
+
const entryPath = join6(directory, entry.name);
|
|
26976
27035
|
if (entry.isDirectory()) {
|
|
26977
27036
|
visit(entryPath);
|
|
26978
27037
|
continue;
|
|
@@ -27226,7 +27285,7 @@ function setupResourceHandlers(mcp, getProjectPath) {
|
|
|
27226
27285
|
import { existsSync as existsSync5, readdirSync as readdirSync4, readFileSync as readFileSync6, unlinkSync } from "node:fs";
|
|
27227
27286
|
import { createConnection as createConnection3 } from "node:net";
|
|
27228
27287
|
import { tmpdir as tmpdir3 } from "node:os";
|
|
27229
|
-
import { join as
|
|
27288
|
+
import { join as join7, resolve as resolve4 } from "node:path";
|
|
27230
27289
|
|
|
27231
27290
|
// src/tool-args.ts
|
|
27232
27291
|
function asParams(value) {
|
|
@@ -27288,13 +27347,13 @@ function runtimeDirectory(variables = process.env) {
|
|
|
27288
27347
|
return explicit;
|
|
27289
27348
|
}
|
|
27290
27349
|
const perUser = envValue("XDG_RUNTIME_DIR", variables);
|
|
27291
|
-
return
|
|
27350
|
+
return join7(perUser ?? tmpdir3(), "gdharness");
|
|
27292
27351
|
}
|
|
27293
27352
|
function runtimeDirectories(variables = process.env) {
|
|
27294
27353
|
const candidates = [runtimeDirectory(variables)];
|
|
27295
27354
|
const fallbacks = process.platform === "win32" ? [envValue("SystemRoot", variables) ?? envValue("windir", variables) ?? "C:\\Windows"] : ["/tmp", "/var/tmp"];
|
|
27296
27355
|
for (const base of fallbacks) {
|
|
27297
|
-
candidates.push(
|
|
27356
|
+
candidates.push(join7(base, process.platform === "win32" ? "Temp" : "", "gdharness"));
|
|
27298
27357
|
}
|
|
27299
27358
|
return [...new Set(candidates.map((path) => resolve4(path)))];
|
|
27300
27359
|
}
|
|
@@ -27357,7 +27416,7 @@ function announcedIn(directory) {
|
|
|
27357
27416
|
if (!match) {
|
|
27358
27417
|
continue;
|
|
27359
27418
|
}
|
|
27360
|
-
const file =
|
|
27419
|
+
const file = join7(directory, entry);
|
|
27361
27420
|
const pid = Number.parseInt(match[1] ?? "", 10);
|
|
27362
27421
|
const announced = processAlive(pid) ? parseAnnouncement(file, pid) : { kind: "rubbish" };
|
|
27363
27422
|
if (announced.kind === "rubbish") {
|
|
@@ -28413,7 +28472,7 @@ function buildToolDefinitions() {
|
|
|
28413
28472
|
var UPDATE_NOTICE_EVERY = 500;
|
|
28414
28473
|
var FEEDBACK_NOTICE_EVERY = 250;
|
|
28415
28474
|
var run3 = promisify3(execFile3);
|
|
28416
|
-
var __dirname2 =
|
|
28475
|
+
var __dirname2 = dirname3(fileURLToPath2(import.meta.url));
|
|
28417
28476
|
var EDITOR_RESTART_TIMEOUT_MS = 90000;
|
|
28418
28477
|
var BRIDGE_RETRY_MS = 2000;
|
|
28419
28478
|
var PATH_SOLUTIONS = [
|
|
@@ -28511,7 +28570,7 @@ function camelCased(params) {
|
|
|
28511
28570
|
class GodotServer {
|
|
28512
28571
|
mcp;
|
|
28513
28572
|
locator = new GodotLocator;
|
|
28514
|
-
operationsScript =
|
|
28573
|
+
operationsScript = join8(__dirname2, "godot", "operations", "godot_operations.gd");
|
|
28515
28574
|
godotBridge;
|
|
28516
28575
|
tools = buildToolDefinitions();
|
|
28517
28576
|
activeProcess = null;
|
|
@@ -28525,8 +28584,11 @@ class GodotServer {
|
|
|
28525
28584
|
bridgeRetry = null;
|
|
28526
28585
|
lastProjectPath = null;
|
|
28527
28586
|
shutdownInitiated = false;
|
|
28587
|
+
ownProject;
|
|
28588
|
+
announcedAt = null;
|
|
28528
28589
|
constructor() {
|
|
28529
|
-
this.
|
|
28590
|
+
this.ownProject = envValue("GDHARNESS_PROJECT") ?? null;
|
|
28591
|
+
this.godotBridge = getDefaultBridge(this.ownProject !== null);
|
|
28530
28592
|
this.mcp = new McpServer({ name: "gdharness", version: SERVER_VERSION }, { capabilities: { tools: {}, resources: {} } });
|
|
28531
28593
|
this.setupToolHandlers();
|
|
28532
28594
|
setupResourceHandlers(this.mcp, () => this.lastProjectPath);
|
|
@@ -28574,6 +28636,7 @@ class GodotServer {
|
|
|
28574
28636
|
this.bridgeStartupError = null;
|
|
28575
28637
|
const bridgeStatus = this.godotBridge.getStatus();
|
|
28576
28638
|
console.error(`[SERVER] Godot Editor Bridge started on ${bridgeStatus.host}:${bridgeStatus.port}`);
|
|
28639
|
+
this.announceTheBridge();
|
|
28577
28640
|
} catch (bridgeError) {
|
|
28578
28641
|
const code = bridgeError instanceof Error && "code" in bridgeError && typeof bridgeError.code === "string" ? bridgeError.code : null;
|
|
28579
28642
|
const reason = errorMessage(bridgeError);
|
|
@@ -28606,6 +28669,18 @@ class GodotServer {
|
|
|
28606
28669
|
this.stopTryingTheBridge();
|
|
28607
28670
|
const bridgeStatus = this.godotBridge.getStatus();
|
|
28608
28671
|
console.error(`[SERVER] Godot Editor Bridge came up on ${bridgeStatus.host}:${bridgeStatus.port}; editor tools are live.`);
|
|
28672
|
+
this.announceTheBridge();
|
|
28673
|
+
}
|
|
28674
|
+
announceTheBridge() {
|
|
28675
|
+
if (this.ownProject === null) {
|
|
28676
|
+
return;
|
|
28677
|
+
}
|
|
28678
|
+
const status = this.godotBridge.getStatus();
|
|
28679
|
+
this.announcedAt = announceBridge(this.ownProject, {
|
|
28680
|
+
host: status.host,
|
|
28681
|
+
port: status.port,
|
|
28682
|
+
version: SERVER_VERSION
|
|
28683
|
+
});
|
|
28609
28684
|
}
|
|
28610
28685
|
stopTryingTheBridge() {
|
|
28611
28686
|
if (this.bridgeRetry !== null) {
|
|
@@ -28616,6 +28691,8 @@ class GodotServer {
|
|
|
28616
28691
|
async cleanup() {
|
|
28617
28692
|
this.logDebug("Cleaning up resources");
|
|
28618
28693
|
this.stopTryingTheBridge();
|
|
28694
|
+
withdrawBridge(this.announcedAt);
|
|
28695
|
+
this.announcedAt = null;
|
|
28619
28696
|
if (this.activeProcess) {
|
|
28620
28697
|
this.activeProcess.process?.kill();
|
|
28621
28698
|
this.activeProcess = null;
|
|
@@ -28998,7 +29075,7 @@ class GodotServer {
|
|
|
28998
29075
|
if (path === undefined) {
|
|
28999
29076
|
return { ok: false, response: this.createErrorResponse("projectPath is required.") };
|
|
29000
29077
|
}
|
|
29001
|
-
const file =
|
|
29078
|
+
const file = join8(path, "project.godot");
|
|
29002
29079
|
if (!existsSync6(file)) {
|
|
29003
29080
|
return {
|
|
29004
29081
|
ok: false,
|
|
@@ -29263,7 +29340,7 @@ class GodotServer {
|
|
|
29263
29340
|
return contained.response;
|
|
29264
29341
|
}
|
|
29265
29342
|
const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
|
|
29266
|
-
if (!existsSync6(
|
|
29343
|
+
if (!existsSync6(join8(project.value.path, runner))) {
|
|
29267
29344
|
return this.createErrorResponse(`gdUnit4 is not installed in this project: no ${runner}.`, [
|
|
29268
29345
|
"Install gdUnit4 under addons/gdUnit4, from https://github.com/godot-gdunit-labs/gdUnit4"
|
|
29269
29346
|
]);
|
|
@@ -29296,7 +29373,7 @@ class GodotServer {
|
|
|
29296
29373
|
];
|
|
29297
29374
|
const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 600000;
|
|
29298
29375
|
this.logDebug(`Running tests: ${engine.value} ${cmdArgs.join(" ")}`);
|
|
29299
|
-
const userData = mkdtempSync2(
|
|
29376
|
+
const userData = mkdtempSync2(join8(tmpdir4(), "gdharness-tests-"));
|
|
29300
29377
|
const run = this.spawnGame(engine.value, cmdArgs, userDataIn(userData));
|
|
29301
29378
|
const hung = await new Promise((resolve) => {
|
|
29302
29379
|
const timer = setTimeout(() => {
|
|
@@ -29312,20 +29389,20 @@ class GodotServer {
|
|
|
29312
29389
|
resolve(false);
|
|
29313
29390
|
});
|
|
29314
29391
|
});
|
|
29315
|
-
const reportsDir =
|
|
29392
|
+
const reportsDir = join8(project.value.path, ".godot", "gdharness-reports");
|
|
29316
29393
|
let report = null;
|
|
29317
29394
|
let reportProblem = null;
|
|
29318
29395
|
try {
|
|
29319
29396
|
const written = existsSync6(reportsDir) ? readdirSync5(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
|
|
29320
29397
|
const newest = written.at(-1);
|
|
29321
29398
|
if (newest !== undefined) {
|
|
29322
|
-
report = parseJUnit(readFileSync7(
|
|
29399
|
+
report = parseJUnit(readFileSync7(join8(reportsDir, newest, "results.xml"), "utf8"));
|
|
29323
29400
|
}
|
|
29324
29401
|
} catch (error) {
|
|
29325
29402
|
reportProblem = errorMessage(error);
|
|
29326
29403
|
} finally {
|
|
29327
|
-
|
|
29328
|
-
|
|
29404
|
+
rmSync3(reportsDir, { recursive: true, force: true });
|
|
29405
|
+
rmSync3(userData, { recursive: true, force: true });
|
|
29329
29406
|
}
|
|
29330
29407
|
const engineEntries = run.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries;
|
|
29331
29408
|
const verdicts = {
|
|
@@ -29438,6 +29515,7 @@ class GodotServer {
|
|
|
29438
29515
|
startupError: this.bridgeStartupError,
|
|
29439
29516
|
staleNote: stale ? addonMismatch(status.addonVersion, SERVER_VERSION) : undefined,
|
|
29440
29517
|
retryingBridge: this.bridgeRetry === null ? undefined : true,
|
|
29518
|
+
announcedAt: this.announcedAt ?? undefined,
|
|
29441
29519
|
note: isPortConflict ? "Bridge port is already in use. Another gdharness instance owns the editor bridge, so this server cannot reach the editor. Usually the server this one replaced, still on its way out." : undefined,
|
|
29442
29520
|
suggestion: isPortConflict ? `This server is asking for the port again every ${BRIDGE_RETRY_MS / 1000}s and takes it the moment the other one lets go, so editor tools come back on their own. Ask again, or end the older gdharness process to have it now.` : undefined
|
|
29443
29521
|
};
|
|
@@ -29833,8 +29911,8 @@ class GodotServer {
|
|
|
29833
29911
|
return this.createErrorResponse(choice.problem);
|
|
29834
29912
|
}
|
|
29835
29913
|
const expectsScreenshot = command === "capture_screenshot" || command === "capture_viewport";
|
|
29836
|
-
const screenshotDir = expectsScreenshot ? mkdtempSync2(
|
|
29837
|
-
const screenshotPath = screenshotDir ?
|
|
29914
|
+
const screenshotDir = expectsScreenshot ? mkdtempSync2(join8(tmpdir4(), "gdharness-runtime-screenshot-")) : null;
|
|
29915
|
+
const screenshotPath = screenshotDir ? join8(screenshotDir, "capture.png") : null;
|
|
29838
29916
|
try {
|
|
29839
29917
|
const reply = await runtimeRequest(choice.endpoint, command, screenshotPath ? { ...params, output_path: screenshotPath } : params, timeoutMs);
|
|
29840
29918
|
if (!reply.ok) {
|
|
@@ -29860,7 +29938,7 @@ class GodotServer {
|
|
|
29860
29938
|
};
|
|
29861
29939
|
} finally {
|
|
29862
29940
|
if (screenshotDir) {
|
|
29863
|
-
|
|
29941
|
+
rmSync3(screenshotDir, { recursive: true, force: true });
|
|
29864
29942
|
}
|
|
29865
29943
|
}
|
|
29866
29944
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gdharness",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.9",
|
|
4
4
|
"mcpName": "io.github.Aureliolo/gdharness",
|
|
5
5
|
"description": "A harness for driving a Godot 4 project from an agent: editor addons, a runtime bridge into the running game, an MCP server in front of them, and a CLI that installs and diagnoses the Godot side.",
|
|
6
6
|
"type": "module",
|