gdharness 1.0.12 → 1.0.14
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 +250 -57
- package/build/godot/addons/gdharness_editor/bridge_client.gd +18 -2
- package/build/index.js +238 -96
- package/package.json +1 -1
|
@@ -13,6 +13,8 @@ const Read = preload("reading.gd")
|
|
|
13
13
|
const DEFAULT_URL: String = "ws://127.0.0.1:6505/godot"
|
|
14
14
|
## Written beside the addon by the install, so it names the version this copy came from.
|
|
15
15
|
const VERSION_MARKER: String = "res://addons/gdharness_editor/.gdharness-version"
|
|
16
|
+
## Written beside it, kept in step with `DIGEST_MARKER` in src/setup.ts.
|
|
17
|
+
const DIGEST_MARKER: String = "res://addons/gdharness_editor/.gdharness-digest"
|
|
16
18
|
## Written by the server that serves this project, saying where its bridge actually is. Kept in
|
|
17
19
|
## step with `announcementPath` in src/bridge-announce.ts.
|
|
18
20
|
const ANNOUNCEMENT: String = "res://.godot/gdharness-bridge.json"
|
|
@@ -70,6 +72,8 @@ var server_url: String = DEFAULT_URL
|
|
|
70
72
|
## What this copy was when it loaded, which is what the editor is running until it is restarted.
|
|
71
73
|
## See [method _loaded_version] for why it is held rather than read when it is wanted.
|
|
72
74
|
var version_at_load: String = ""
|
|
75
|
+
## The digest of the editor addon code this copy loaded, held for the same reason.
|
|
76
|
+
var digest_at_load: String = ""
|
|
73
77
|
|
|
74
78
|
var _is_connected: bool = false
|
|
75
79
|
var _reconnect_timer: Timer
|
|
@@ -98,6 +102,7 @@ var _connecting_for: float = 0.0
|
|
|
98
102
|
func _ready() -> void:
|
|
99
103
|
_project_path = ProjectSettings.globalize_path("res://")
|
|
100
104
|
version_at_load = _loaded_version()
|
|
105
|
+
digest_at_load = _loaded_digest()
|
|
101
106
|
_keep_breakpoints_through_sessions()
|
|
102
107
|
OS.set_environment(EDITOR_PID_VARIABLE, str(OS.get_process_id()))
|
|
103
108
|
|
|
@@ -290,6 +295,7 @@ func _handle_connect() -> void:
|
|
|
290
295
|
"type": "godot_ready",
|
|
291
296
|
"project_path": _project_path,
|
|
292
297
|
"addon_version": version_at_load,
|
|
298
|
+
"addon_digest": digest_at_load,
|
|
293
299
|
"editor_pid": OS.get_process_id(),
|
|
294
300
|
"lsp_port": _serves(LSP_ASKED, LSP_SETTING),
|
|
295
301
|
"dap_port": _serves(DAP_ASKED, DAP_SETTING),
|
|
@@ -408,9 +414,19 @@ func _serving(setting: String) -> int:
|
|
|
408
414
|
## and `addonIsStale` reporting false over an editor still running the old code, which is exactly
|
|
409
415
|
## what it exists to catch.
|
|
410
416
|
func _loaded_version() -> String:
|
|
411
|
-
|
|
417
|
+
return _read_marker(VERSION_MARKER)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
## The same for the digest of the editor addon code, which an upgrade that leaves the code alone
|
|
421
|
+
## rewrites unchanged, so the server can tell an editor that needs a restart from one that does not.
|
|
422
|
+
func _loaded_digest() -> String:
|
|
423
|
+
return _read_marker(DIGEST_MARKER)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
func _read_marker(path: String) -> String:
|
|
427
|
+
if not FileAccess.file_exists(path):
|
|
412
428
|
return ""
|
|
413
|
-
var file: FileAccess = FileAccess.open(
|
|
429
|
+
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
|
|
414
430
|
if file == null:
|
|
415
431
|
return ""
|
|
416
432
|
var text: String = file.get_as_text().strip_edges()
|
package/build/index.js
CHANGED
|
@@ -10047,11 +10047,20 @@ var SERVER_VERSION = (() => {
|
|
|
10047
10047
|
}
|
|
10048
10048
|
})();
|
|
10049
10049
|
var UNVERSIONED = "an addon from before versions were reported";
|
|
10050
|
-
function
|
|
10051
|
-
if (
|
|
10050
|
+
function editorIsStale(addonVersion, serverVersion, addonDigest, shippedDigest) {
|
|
10051
|
+
if (addonDigest !== undefined && addonDigest !== "" && shippedDigest !== undefined) {
|
|
10052
|
+
return addonDigest !== shippedDigest;
|
|
10053
|
+
}
|
|
10054
|
+
return addonVersion !== serverVersion;
|
|
10055
|
+
}
|
|
10056
|
+
function addonMismatch(addonVersion, serverVersion, addonDigest, shippedDigest) {
|
|
10057
|
+
if (!editorIsStale(addonVersion, serverVersion, addonDigest, shippedDigest)) {
|
|
10052
10058
|
return;
|
|
10053
10059
|
}
|
|
10054
10060
|
const reported = addonVersion ?? "";
|
|
10061
|
+
if (reported === serverVersion) {
|
|
10062
|
+
return `The editor is running a different build of the ${reported} addon from the one this server ships. Restart it with editor_launch restart to pick this one up.`;
|
|
10063
|
+
}
|
|
10055
10064
|
const editor = reported === "" ? UNVERSIONED : `the ${reported} addon`;
|
|
10056
10065
|
const both = `The editor is running ${editor} while this server ships ${serverVersion}.`;
|
|
10057
10066
|
if (reported !== "" && isNewer(reported, serverVersion)) {
|
|
@@ -10059,8 +10068,8 @@ function addonMismatch(addonVersion, serverVersion) {
|
|
|
10059
10068
|
}
|
|
10060
10069
|
return `${both} Restart it with editor_launch restart to pick the new one up.`;
|
|
10061
10070
|
}
|
|
10062
|
-
function markIfStale(answer, addonVersion, serverVersion) {
|
|
10063
|
-
const staleNote = addonMismatch(addonVersion, serverVersion);
|
|
10071
|
+
function markIfStale(answer, addonVersion, serverVersion, addonDigest, shippedDigest) {
|
|
10072
|
+
const staleNote = addonMismatch(addonVersion, serverVersion, addonDigest, shippedDigest);
|
|
10064
10073
|
if (staleNote === undefined || typeof answer !== "object" || answer === null || Array.isArray(answer)) {
|
|
10065
10074
|
return answer;
|
|
10066
10075
|
}
|
|
@@ -10178,16 +10187,16 @@ import {
|
|
|
10178
10187
|
mkdirSync as mkdirSync10,
|
|
10179
10188
|
mkdtempSync as mkdtempSync2,
|
|
10180
10189
|
openSync as openSync3,
|
|
10181
|
-
readdirSync as
|
|
10190
|
+
readdirSync as readdirSync7,
|
|
10182
10191
|
readFileSync as readFileSync16,
|
|
10183
10192
|
readSync as readSync2,
|
|
10184
10193
|
rmdirSync,
|
|
10185
|
-
statSync as
|
|
10194
|
+
statSync as statSync7
|
|
10186
10195
|
} from "node:fs";
|
|
10187
10196
|
import { tmpdir as tmpdir4 } from "node:os";
|
|
10188
10197
|
import { basename as basename3, dirname as dirname10, isAbsolute as isAbsolute3, join as join16, normalize as normalize2, relative as relative3 } from "node:path";
|
|
10189
10198
|
import { setTimeout as delay3 } from "node:timers/promises";
|
|
10190
|
-
import { fileURLToPath as
|
|
10199
|
+
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
10191
10200
|
import { promisify as promisify5 } from "node:util";
|
|
10192
10201
|
|
|
10193
10202
|
// node_modules/zod/v3/helpers/util.js
|
|
@@ -24761,7 +24770,7 @@ import { dirname as dirname2, join as join3 } from "node:path";
|
|
|
24761
24770
|
import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync3, statSync, unlinkSync } from "node:fs";
|
|
24762
24771
|
import { createConnection } from "node:net";
|
|
24763
24772
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
24764
|
-
import { dirname, join as join2, resolve } from "node:path";
|
|
24773
|
+
import { dirname, join as join2, resolve as resolve2 } from "node:path";
|
|
24765
24774
|
import { setTimeout as delay } from "node:timers/promises";
|
|
24766
24775
|
|
|
24767
24776
|
// src/launch.ts
|
|
@@ -24828,6 +24837,65 @@ function environmentFor(options, variables = process.env) {
|
|
|
24828
24837
|
}
|
|
24829
24838
|
var SAVES_NOT_MOVED_NOTE = "On macOS the engine reads user:// off HOME and ignores the variables that move it elsewhere, measured on Godot 4.7.2, so this run wrote its saves where the player keeps theirs. A suite that saves a game has written into the same folder as the copy being played, and one that tidies up after itself may have removed real saves.";
|
|
24830
24839
|
|
|
24840
|
+
// src/paths.ts
|
|
24841
|
+
import { realpathSync } from "node:fs";
|
|
24842
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
24843
|
+
var RESOURCE_SCHEME = "res://";
|
|
24844
|
+
var OTHER_SCHEME = /^[a-zA-Z][a-zA-Z\d+.-]+:\/\//;
|
|
24845
|
+
function realPathOr(path) {
|
|
24846
|
+
try {
|
|
24847
|
+
return realpathSync.native(path);
|
|
24848
|
+
} catch {
|
|
24849
|
+
return path;
|
|
24850
|
+
}
|
|
24851
|
+
}
|
|
24852
|
+
function isSameDirectory(onePath, otherPath) {
|
|
24853
|
+
return relative(realPathOr(resolve(onePath)), realPathOr(resolve(otherPath))) === "";
|
|
24854
|
+
}
|
|
24855
|
+
function isWithinRoot(rootPath, candidatePath) {
|
|
24856
|
+
const step = relative(resolve(rootPath), resolve(candidatePath));
|
|
24857
|
+
return step !== ".." && !step.startsWith(`..${sep}`) && !isAbsolute(step);
|
|
24858
|
+
}
|
|
24859
|
+
function resolveWithinProject(projectPath, candidatePath) {
|
|
24860
|
+
const trimmed = candidatePath.trim();
|
|
24861
|
+
if (trimmed === "") {
|
|
24862
|
+
return { ok: false, reason: "Path is empty." };
|
|
24863
|
+
}
|
|
24864
|
+
if (trimmed.includes("\x00")) {
|
|
24865
|
+
return { ok: false, reason: "Path contains a null byte." };
|
|
24866
|
+
}
|
|
24867
|
+
const withoutScheme = trimmed.startsWith(RESOURCE_SCHEME) ? trimmed.slice(RESOURCE_SCHEME.length) : trimmed;
|
|
24868
|
+
const scheme = OTHER_SCHEME.exec(withoutScheme);
|
|
24869
|
+
if (scheme) {
|
|
24870
|
+
return {
|
|
24871
|
+
ok: false,
|
|
24872
|
+
reason: `Path '${trimmed}' uses the '${scheme[0]}' scheme, which names somewhere other than this project.`
|
|
24873
|
+
};
|
|
24874
|
+
}
|
|
24875
|
+
if (withoutScheme.startsWith("/") || withoutScheme.startsWith("\\") || isAbsolute(withoutScheme)) {
|
|
24876
|
+
return {
|
|
24877
|
+
ok: false,
|
|
24878
|
+
reason: `Path '${trimmed}' is absolute; give a path inside the project, such as 'scenes/main.tscn'.`
|
|
24879
|
+
};
|
|
24880
|
+
}
|
|
24881
|
+
if (withoutScheme.includes(":")) {
|
|
24882
|
+
return {
|
|
24883
|
+
ok: false,
|
|
24884
|
+
reason: `Path '${trimmed}' contains ':', which no file inside a project is named with.`
|
|
24885
|
+
};
|
|
24886
|
+
}
|
|
24887
|
+
const root = resolve(projectPath);
|
|
24888
|
+
const absolutePath = resolve(root, withoutScheme);
|
|
24889
|
+
if (!isWithinRoot(root, absolutePath)) {
|
|
24890
|
+
return { ok: false, reason: `Path '${trimmed}' resolves outside the project directory.` };
|
|
24891
|
+
}
|
|
24892
|
+
const relativePath = relative(root, absolutePath).split(sep).join("/");
|
|
24893
|
+
if (relativePath === "") {
|
|
24894
|
+
return { ok: false, reason: `Path '${trimmed}' is the project directory itself, not a file in it.` };
|
|
24895
|
+
}
|
|
24896
|
+
return { ok: true, absolutePath, relativePath };
|
|
24897
|
+
}
|
|
24898
|
+
|
|
24831
24899
|
// src/process-children.ts
|
|
24832
24900
|
import { execFile, execFileSync, spawnSync } from "node:child_process";
|
|
24833
24901
|
import { basename } from "node:path";
|
|
@@ -25101,7 +25169,7 @@ function runtimeDirectories(variables = process.env) {
|
|
|
25101
25169
|
for (const base of fallbacks) {
|
|
25102
25170
|
candidates.push(join2(base, process.platform === "win32" ? "Temp" : "", "gdharness"));
|
|
25103
25171
|
}
|
|
25104
|
-
return [...new Set(candidates.map((path) =>
|
|
25172
|
+
return [...new Set(candidates.map((path) => resolve2(path)))];
|
|
25105
25173
|
}
|
|
25106
25174
|
var ERROR_REPORT_PATTERN = /^runtime-(\d+)\.log$/;
|
|
25107
25175
|
function errorReportOf(pid, directories = runtimeDirectories()) {
|
|
@@ -25196,8 +25264,7 @@ function announcedAndGone(projectPath) {
|
|
|
25196
25264
|
return [];
|
|
25197
25265
|
}
|
|
25198
25266
|
const since = Date.now() - GONE_WINDOW_MS;
|
|
25199
|
-
|
|
25200
|
-
return gone.filter((one) => one.noticedAt >= since && resolve(one.project) === wanted);
|
|
25267
|
+
return gone.filter((one) => one.noticedAt >= since && isSameDirectory(one.project, projectPath));
|
|
25201
25268
|
}
|
|
25202
25269
|
var STARTED_AFTER_ANNOUNCING_MS = 2000;
|
|
25203
25270
|
var CONFIRMED_FOR_MS = 60000;
|
|
@@ -25310,11 +25377,10 @@ function sweepErrorReport(path, pid, now = Date.now()) {
|
|
|
25310
25377
|
var LOOK_EVERY_MS = 50;
|
|
25311
25378
|
var ANNOUNCE_BUDGET_MS = 5000;
|
|
25312
25379
|
async function announcedSince(projectPath, before, waiting = {}) {
|
|
25313
|
-
const wanted = resolve(projectPath);
|
|
25314
25380
|
const directories = waiting.directories ?? runtimeDirectories();
|
|
25315
25381
|
const until = Date.now() + Math.max(waiting.budgetMs ?? ANNOUNCE_BUDGET_MS, 0);
|
|
25316
25382
|
for (;; ) {
|
|
25317
|
-
const fresh = discoverRuntimes(directories).find((endpoint) => !before.has(endpoint.pid) &&
|
|
25383
|
+
const fresh = discoverRuntimes(directories).find((endpoint) => !before.has(endpoint.pid) && isSameDirectory(endpoint.project.path, projectPath) && (waiting.accept?.(endpoint) ?? true));
|
|
25318
25384
|
if (fresh !== undefined) {
|
|
25319
25385
|
return fresh;
|
|
25320
25386
|
}
|
|
@@ -25335,7 +25401,7 @@ function theOneProject(endpoints) {
|
|
|
25335
25401
|
if (endpoints.some((endpoint) => endpoint.project.path === "")) {
|
|
25336
25402
|
return;
|
|
25337
25403
|
}
|
|
25338
|
-
const paths = new Set(endpoints.map((endpoint) =>
|
|
25404
|
+
const paths = new Set(endpoints.map((endpoint) => realPathOr(resolve2(endpoint.project.path))));
|
|
25339
25405
|
const only = [...paths][0];
|
|
25340
25406
|
return paths.size === 1 ? only : undefined;
|
|
25341
25407
|
}
|
|
@@ -25364,9 +25430,9 @@ function chooseRuntime(endpoints, projectPath, unspoken = [], pid) {
|
|
|
25364
25430
|
if (pid !== undefined) {
|
|
25365
25431
|
const named = endpoints.find((endpoint) => endpoint.pid === pid);
|
|
25366
25432
|
if (named !== undefined) {
|
|
25367
|
-
if (projectPath !== undefined &&
|
|
25433
|
+
if (projectPath !== undefined && !isSameDirectory(named.project.path, projectPath)) {
|
|
25368
25434
|
return {
|
|
25369
|
-
problem: `The game with pid ${pid} is running ${named.project.path}, not ${
|
|
25435
|
+
problem: `The game with pid ${pid} is running ${named.project.path}, not ${resolve2(projectPath)}. Running: ${endpoints.map(describe2).join("; ")}.`
|
|
25370
25436
|
};
|
|
25371
25437
|
}
|
|
25372
25438
|
return { endpoint: named };
|
|
@@ -25406,8 +25472,8 @@ function chooseRuntime(endpoints, projectPath, unspoken = [], pid) {
|
|
|
25406
25472
|
};
|
|
25407
25473
|
}
|
|
25408
25474
|
if (projectPath !== undefined) {
|
|
25409
|
-
const wanted =
|
|
25410
|
-
const matching = endpoints.filter((endpoint) =>
|
|
25475
|
+
const wanted = resolve2(projectPath);
|
|
25476
|
+
const matching = endpoints.filter((endpoint) => isSameDirectory(endpoint.project.path, wanted));
|
|
25411
25477
|
if (matching.length === 0) {
|
|
25412
25478
|
return {
|
|
25413
25479
|
problem: `No running game is from ${wanted}. Running: ${endpoints.map(describe2).join("; ")}.`
|
|
@@ -25551,67 +25617,6 @@ function waitSizedTo(lastBootMs, usual = ANNOUNCE_BUDGET_MS) {
|
|
|
25551
25617
|
// src/breakpoint-note.ts
|
|
25552
25618
|
import { mkdirSync as mkdirSync3, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
25553
25619
|
import { dirname as dirname3, isAbsolute as isAbsolute2, join as join4, relative as relative2 } from "node:path";
|
|
25554
|
-
|
|
25555
|
-
// src/paths.ts
|
|
25556
|
-
import { realpathSync } from "node:fs";
|
|
25557
|
-
import { isAbsolute, relative, resolve as resolve2, sep } from "node:path";
|
|
25558
|
-
var RESOURCE_SCHEME = "res://";
|
|
25559
|
-
var OTHER_SCHEME = /^[a-zA-Z][a-zA-Z\d+.-]+:\/\//;
|
|
25560
|
-
function realPathOr(path) {
|
|
25561
|
-
try {
|
|
25562
|
-
return realpathSync.native(path);
|
|
25563
|
-
} catch {
|
|
25564
|
-
return path;
|
|
25565
|
-
}
|
|
25566
|
-
}
|
|
25567
|
-
function isSameDirectory(onePath, otherPath) {
|
|
25568
|
-
return relative(realPathOr(resolve2(onePath)), realPathOr(resolve2(otherPath))) === "";
|
|
25569
|
-
}
|
|
25570
|
-
function isWithinRoot(rootPath, candidatePath) {
|
|
25571
|
-
const step = relative(resolve2(rootPath), resolve2(candidatePath));
|
|
25572
|
-
return step !== ".." && !step.startsWith(`..${sep}`) && !isAbsolute(step);
|
|
25573
|
-
}
|
|
25574
|
-
function resolveWithinProject(projectPath, candidatePath) {
|
|
25575
|
-
const trimmed = candidatePath.trim();
|
|
25576
|
-
if (trimmed === "") {
|
|
25577
|
-
return { ok: false, reason: "Path is empty." };
|
|
25578
|
-
}
|
|
25579
|
-
if (trimmed.includes("\x00")) {
|
|
25580
|
-
return { ok: false, reason: "Path contains a null byte." };
|
|
25581
|
-
}
|
|
25582
|
-
const withoutScheme = trimmed.startsWith(RESOURCE_SCHEME) ? trimmed.slice(RESOURCE_SCHEME.length) : trimmed;
|
|
25583
|
-
const scheme = OTHER_SCHEME.exec(withoutScheme);
|
|
25584
|
-
if (scheme) {
|
|
25585
|
-
return {
|
|
25586
|
-
ok: false,
|
|
25587
|
-
reason: `Path '${trimmed}' uses the '${scheme[0]}' scheme, which names somewhere other than this project.`
|
|
25588
|
-
};
|
|
25589
|
-
}
|
|
25590
|
-
if (withoutScheme.startsWith("/") || withoutScheme.startsWith("\\") || isAbsolute(withoutScheme)) {
|
|
25591
|
-
return {
|
|
25592
|
-
ok: false,
|
|
25593
|
-
reason: `Path '${trimmed}' is absolute; give a path inside the project, such as 'scenes/main.tscn'.`
|
|
25594
|
-
};
|
|
25595
|
-
}
|
|
25596
|
-
if (withoutScheme.includes(":")) {
|
|
25597
|
-
return {
|
|
25598
|
-
ok: false,
|
|
25599
|
-
reason: `Path '${trimmed}' contains ':', which no file inside a project is named with.`
|
|
25600
|
-
};
|
|
25601
|
-
}
|
|
25602
|
-
const root = resolve2(projectPath);
|
|
25603
|
-
const absolutePath = resolve2(root, withoutScheme);
|
|
25604
|
-
if (!isWithinRoot(root, absolutePath)) {
|
|
25605
|
-
return { ok: false, reason: `Path '${trimmed}' resolves outside the project directory.` };
|
|
25606
|
-
}
|
|
25607
|
-
const relativePath = relative(root, absolutePath).split(sep).join("/");
|
|
25608
|
-
if (relativePath === "") {
|
|
25609
|
-
return { ok: false, reason: `Path '${trimmed}' is the project directory itself, not a file in it.` };
|
|
25610
|
-
}
|
|
25611
|
-
return { ok: true, absolutePath, relativePath };
|
|
25612
|
-
}
|
|
25613
|
-
|
|
25614
|
-
// src/breakpoint-note.ts
|
|
25615
25620
|
function breakpointNotePath(projectPath) {
|
|
25616
25621
|
return join4(projectPath, ".godot", "gdharness-breakpoints.json");
|
|
25617
25622
|
}
|
|
@@ -25849,6 +25854,19 @@ function cacheWrittenAt(projectPath) {
|
|
|
25849
25854
|
return null;
|
|
25850
25855
|
}
|
|
25851
25856
|
}
|
|
25857
|
+
function declaredSince(projectPath, paths, names, since) {
|
|
25858
|
+
return names.filter((name) => {
|
|
25859
|
+
const path = paths.get(name);
|
|
25860
|
+
if (path === undefined || !path.startsWith("res://")) {
|
|
25861
|
+
return false;
|
|
25862
|
+
}
|
|
25863
|
+
try {
|
|
25864
|
+
return statSync2(join6(projectPath, path.slice("res://".length))).mtimeMs > since;
|
|
25865
|
+
} catch {
|
|
25866
|
+
return false;
|
|
25867
|
+
}
|
|
25868
|
+
});
|
|
25869
|
+
}
|
|
25852
25870
|
function cachedAtMissingPaths(cached, projectPath) {
|
|
25853
25871
|
return [...cached].filter(([, path]) => path.startsWith("res://") && !existsSync3(join6(projectPath, path.slice("res://".length)))).map(([name]) => name).sort();
|
|
25854
25872
|
}
|
|
@@ -27179,6 +27197,7 @@ class GodotBridge extends EventEmitter {
|
|
|
27179
27197
|
connectedAt: this.connectionInfo?.connectedAt,
|
|
27180
27198
|
lastPongAt: this.connectionInfo?.lastPongAt,
|
|
27181
27199
|
addonVersion: this.connectionInfo?.addonVersion,
|
|
27200
|
+
addonDigest: this.connectionInfo?.addonDigest,
|
|
27182
27201
|
editorPid: this.connectionInfo?.editorPid,
|
|
27183
27202
|
lspPort: this.connectionInfo?.lspPort,
|
|
27184
27203
|
dapPort: this.connectionInfo?.dapPort,
|
|
@@ -27322,6 +27341,7 @@ class GodotBridge extends EventEmitter {
|
|
|
27322
27341
|
}
|
|
27323
27342
|
this.connectionInfo.projectPath = message.project_path;
|
|
27324
27343
|
this.connectionInfo.addonVersion = message.addon_version ?? "";
|
|
27344
|
+
this.connectionInfo.addonDigest = message.addon_digest;
|
|
27325
27345
|
this.connectionInfo.editorPid = message.editor_pid;
|
|
27326
27346
|
this.connectionInfo.lspPort = servedPort(message.lsp_port);
|
|
27327
27347
|
this.connectionInfo.dapPort = servedPort(message.dap_port);
|
|
@@ -29456,14 +29476,66 @@ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
|
|
|
29456
29476
|
}
|
|
29457
29477
|
|
|
29458
29478
|
// src/setup.ts
|
|
29459
|
-
import {
|
|
29479
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
29480
|
+
import {
|
|
29481
|
+
cpSync,
|
|
29482
|
+
existsSync as existsSync9,
|
|
29483
|
+
mkdirSync as mkdirSync9,
|
|
29484
|
+
readdirSync as readdirSync6,
|
|
29485
|
+
readFileSync as readFileSync15,
|
|
29486
|
+
rmSync as rmSync7,
|
|
29487
|
+
statSync as statSync6,
|
|
29488
|
+
writeFileSync as writeFileSync10
|
|
29489
|
+
} from "node:fs";
|
|
29460
29490
|
import { dirname as dirname9, join as join15 } from "node:path";
|
|
29491
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
29461
29492
|
var ADDONS = ["gdharness_editor", "gdharness_runtime", "auto_reload"];
|
|
29493
|
+
var EDITOR_PLUGINS = ["gdharness_editor", "auto_reload"];
|
|
29462
29494
|
var RUNTIME_AUTOLOAD = {
|
|
29463
29495
|
name: "GdharnessRuntime",
|
|
29464
29496
|
path: "addons/gdharness_runtime/runtime_autoload.gd"
|
|
29465
29497
|
};
|
|
29466
29498
|
var VERSION_MARKER = ".gdharness-version";
|
|
29499
|
+
var DIGEST_MARKER = ".gdharness-digest";
|
|
29500
|
+
function editorAddonDigest(from = shippedAddonsDirectory()) {
|
|
29501
|
+
const hash = createHash3("sha256");
|
|
29502
|
+
const walk = (directory, relative) => {
|
|
29503
|
+
for (const name of readdirSync6(directory).sort()) {
|
|
29504
|
+
if (name === VERSION_MARKER || name === DIGEST_MARKER) {
|
|
29505
|
+
continue;
|
|
29506
|
+
}
|
|
29507
|
+
const path = join15(directory, name);
|
|
29508
|
+
const inside = `${relative}/${name}`;
|
|
29509
|
+
if (statSync6(path).isDirectory()) {
|
|
29510
|
+
walk(path, inside);
|
|
29511
|
+
} else {
|
|
29512
|
+
hash.update(`${inside}
|
|
29513
|
+
`);
|
|
29514
|
+
hash.update(readFileSync15(path));
|
|
29515
|
+
hash.update(`
|
|
29516
|
+
`);
|
|
29517
|
+
}
|
|
29518
|
+
}
|
|
29519
|
+
};
|
|
29520
|
+
for (const name of EDITOR_PLUGINS) {
|
|
29521
|
+
walk(join15(from, name), name);
|
|
29522
|
+
}
|
|
29523
|
+
return hash.digest("hex");
|
|
29524
|
+
}
|
|
29525
|
+
var shippedDigest;
|
|
29526
|
+
function shippedEditorDigest() {
|
|
29527
|
+
if (shippedDigest === undefined) {
|
|
29528
|
+
try {
|
|
29529
|
+
shippedDigest = editorAddonDigest();
|
|
29530
|
+
} catch {
|
|
29531
|
+
shippedDigest = null;
|
|
29532
|
+
}
|
|
29533
|
+
}
|
|
29534
|
+
return shippedDigest ?? undefined;
|
|
29535
|
+
}
|
|
29536
|
+
function shippedAddonsDirectory() {
|
|
29537
|
+
return join15(dirname9(fileURLToPath2(import.meta.url)), "godot", "addons");
|
|
29538
|
+
}
|
|
29467
29539
|
function installedAddonVersion(projectPath) {
|
|
29468
29540
|
const marker = join15(projectPath, "addons", ADDONS[0], VERSION_MARKER);
|
|
29469
29541
|
if (!existsSync9(marker)) {
|
|
@@ -30657,7 +30729,7 @@ function buildToolDefinitions() {
|
|
|
30657
30729
|
var UPDATE_NOTICE_EVERY = 500;
|
|
30658
30730
|
var FEEDBACK_NOTICE_EVERY = 250;
|
|
30659
30731
|
var run5 = promisify5(execFile5);
|
|
30660
|
-
var __dirname2 = dirname10(
|
|
30732
|
+
var __dirname2 = dirname10(fileURLToPath3(import.meta.url));
|
|
30661
30733
|
var EDITOR_RESTART_TIMEOUT_MS = 90000;
|
|
30662
30734
|
var HOLD_PING_MS = 2000;
|
|
30663
30735
|
var SLOWEST_FRAME_RATE = 20;
|
|
@@ -30951,7 +31023,7 @@ function elsewhereIn(projectPath, asked) {
|
|
|
30951
31023
|
const root = (wanted.split("/")[0] ?? "").toLowerCase();
|
|
30952
31024
|
let near = [];
|
|
30953
31025
|
try {
|
|
30954
|
-
near =
|
|
31026
|
+
near = readdirSync7(projectPath, { withFileTypes: true }).filter((entry) => entry.isDirectory() && entry.name.toLowerCase() !== root).map((entry) => entry.name).filter((name) => name.toLowerCase().startsWith("test") || root.startsWith(name.toLowerCase()));
|
|
30955
31027
|
} catch {
|
|
30956
31028
|
return "";
|
|
30957
31029
|
}
|
|
@@ -32111,7 +32183,7 @@ class GodotServer {
|
|
|
32111
32183
|
let report = null;
|
|
32112
32184
|
let reportProblem = null;
|
|
32113
32185
|
try {
|
|
32114
|
-
const written = existsSync10(reportsDir) ?
|
|
32186
|
+
const written = existsSync10(reportsDir) ? readdirSync7(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
|
|
32115
32187
|
const newest = written.at(-1);
|
|
32116
32188
|
if (newest !== undefined) {
|
|
32117
32189
|
report = parseJUnit(readFileSync16(join16(reportsDir, newest, "results.xml"), "utf8"));
|
|
@@ -32240,7 +32312,7 @@ class GodotServer {
|
|
|
32240
32312
|
warnings: diagnostics.length - errors,
|
|
32241
32313
|
diagnostics,
|
|
32242
32314
|
...disagrees
|
|
32243
|
-
}, this.godotBridge.getStatus().addonVersion, SERVER_VERSION));
|
|
32315
|
+
}, this.godotBridge.getStatus().addonVersion, SERVER_VERSION, this.godotBridge.getStatus().addonDigest, shippedEditorDigest()));
|
|
32244
32316
|
}
|
|
32245
32317
|
whereTheFileDisagrees(diagnostics, args) {
|
|
32246
32318
|
const messages = diagnostics.map((entry) => asParams(entry)["message"]).filter((message) => typeof message === "string");
|
|
@@ -32427,7 +32499,7 @@ class GodotServer {
|
|
|
32427
32499
|
getEditorStatusPayload() {
|
|
32428
32500
|
const status = this.godotBridge.getStatus();
|
|
32429
32501
|
const isPortConflict = this.bridgeStartupError?.includes("EADDRINUSE") ?? false;
|
|
32430
|
-
const stale = status.connected && status.addonVersion
|
|
32502
|
+
const stale = status.connected && editorIsStale(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest());
|
|
32431
32503
|
const unfinished = this.restartLeftUnfinished(status.connected);
|
|
32432
32504
|
return {
|
|
32433
32505
|
...status,
|
|
@@ -32444,7 +32516,7 @@ class GodotServer {
|
|
|
32444
32516
|
awaitingLaunchedEditor: status.connected || !this.launchedEditorIsUp() ? undefined : this.launchedEditor?.pid ?? undefined,
|
|
32445
32517
|
...status.connected ? this.whatTheLaunchedEditorDropped() : {},
|
|
32446
32518
|
startupError: this.bridgeStartupError,
|
|
32447
|
-
staleNote: stale ? addonMismatch(status.addonVersion, SERVER_VERSION) : undefined,
|
|
32519
|
+
staleNote: stale ? addonMismatch(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest()) : undefined,
|
|
32448
32520
|
...this.breakpointsAtRisk(),
|
|
32449
32521
|
retryingBridge: this.bridgeRetry === null ? undefined : true,
|
|
32450
32522
|
announcedAt: this.announcedAt ?? undefined,
|
|
@@ -32576,8 +32648,8 @@ class GodotServer {
|
|
|
32576
32648
|
editorPid: now.editorPid,
|
|
32577
32649
|
addonVersion: now.addonVersion,
|
|
32578
32650
|
serverVersion: SERVER_VERSION,
|
|
32579
|
-
addonIsStale: now.addonVersion
|
|
32580
|
-
staleNote: addonMismatch(now.addonVersion, SERVER_VERSION),
|
|
32651
|
+
addonIsStale: editorIsStale(now.addonVersion, SERVER_VERSION, now.addonDigest, shippedEditorDigest()),
|
|
32652
|
+
staleNote: addonMismatch(now.addonVersion, SERVER_VERSION, now.addonDigest, shippedEditorDigest()),
|
|
32581
32653
|
...this.whatTheEditorDropped(settingsBefore, after),
|
|
32582
32654
|
tookMs: Date.now() - began
|
|
32583
32655
|
});
|
|
@@ -32854,7 +32926,7 @@ class GodotServer {
|
|
|
32854
32926
|
}
|
|
32855
32927
|
let announcedAt;
|
|
32856
32928
|
try {
|
|
32857
|
-
announcedAt =
|
|
32929
|
+
announcedAt = statSync7(endpoint.file).mtimeMs;
|
|
32858
32930
|
} catch {
|
|
32859
32931
|
return;
|
|
32860
32932
|
}
|
|
@@ -33464,7 +33536,15 @@ class GodotServer {
|
|
|
33464
33536
|
"editor_launch opens one, and an editor opened here has its console captured"
|
|
33465
33537
|
]);
|
|
33466
33538
|
}
|
|
33467
|
-
const
|
|
33539
|
+
const launched = status.connected ? null : this.launchedEditor?.pid ?? null;
|
|
33540
|
+
if (!status.connected && launched === null) {
|
|
33541
|
+
return this.createErrorResponse("No editor is connected, so there is no console to read.", [
|
|
33542
|
+
"editor_status says whether one is connected and whether it may yet be",
|
|
33543
|
+
"editor_launch opens one, and an editor opened here has its console captured"
|
|
33544
|
+
]);
|
|
33545
|
+
}
|
|
33546
|
+
const editorPid = status.connected ? status.editorPid ?? null : launched;
|
|
33547
|
+
const console2 = editorConsole(projectPath, editorPid, status.connected ? status.openedByAServer === true : true);
|
|
33468
33548
|
if ("kind" in console2) {
|
|
33469
33549
|
return this.createErrorResponse(theConsoleWasNotCaptured(console2, projectPath), [
|
|
33470
33550
|
"editor_launch restart replaces this editor with one whose console is captured",
|
|
@@ -33482,8 +33562,9 @@ class GodotServer {
|
|
|
33482
33562
|
const grouped = bursts(everything, readPositiveNumber(args, "before") ?? 3);
|
|
33483
33563
|
const shown = grouped.slice(0, MOST_GROUPS);
|
|
33484
33564
|
return this.jsonTextResponse({
|
|
33485
|
-
editorPid:
|
|
33565
|
+
editorPid: editorPid ?? undefined,
|
|
33486
33566
|
projectPath,
|
|
33567
|
+
...status.connected ? {} : this.launchedEditorIsUp() ? { stillConnecting: true } : { exitedBeforeConnecting: true },
|
|
33487
33568
|
capturedIn: console2.path,
|
|
33488
33569
|
...console2.of === undefined ? {} : {
|
|
33489
33570
|
readFromTheStart: console2.readBytes,
|
|
@@ -33782,6 +33863,7 @@ class GodotServer {
|
|
|
33782
33863
|
}
|
|
33783
33864
|
async rebuildClassCache(projectPath) {
|
|
33784
33865
|
const before = cachedClasses(projectPath);
|
|
33866
|
+
const writtenBefore = cacheWrittenAt(projectPath);
|
|
33785
33867
|
const noted = readClassNote(projectPath);
|
|
33786
33868
|
const rebuilt = await this.operation("refresh_class_cache", {}, projectPath);
|
|
33787
33869
|
if (!rebuilt.ok) {
|
|
@@ -33792,21 +33874,30 @@ class GodotServer {
|
|
|
33792
33874
|
return rebuilt;
|
|
33793
33875
|
}
|
|
33794
33876
|
const lost = noted === null || before === null ? [] : noted.rebuiltWith.filter((name) => !before.has(name) && after.has(name)).sort();
|
|
33795
|
-
const
|
|
33877
|
+
const cameBack = writtenBefore === null ? [] : declaredSince(projectPath, after, lost, writtenBefore);
|
|
33878
|
+
const droppedByTheEditor = lost.filter((name) => !cameBack.includes(name));
|
|
33879
|
+
const seenLosing = droppedByTheEditor.length > 0 && this.godotBridge.isConnected();
|
|
33796
33880
|
writeClassNote(projectPath, {
|
|
33797
33881
|
rebuiltWith: [...after.keys()].sort(),
|
|
33798
33882
|
...seenLosing ? { shortListEditorPid: this.godotBridge.getStatus().editorPid ?? null } : noted?.shortListEditorPid === undefined ? {} : { shortListEditorPid: noted.shortListEditorPid }
|
|
33799
33883
|
});
|
|
33800
|
-
|
|
33884
|
+
const notes = [];
|
|
33885
|
+
if (seenLosing) {
|
|
33886
|
+
const them = droppedByTheEditor.length === 1 ? "it" : "them";
|
|
33887
|
+
notes.push(`The cache held ${droppedByTheEditor.join(", ")} after the last rebuild and not when this one began, so the editor holding this project wrote it without ${them} in between: it holds a list shorter than the files and writes that list on every save and scan, and each rebuild puts ${them} back. editor_launch restart is what ends it.`);
|
|
33888
|
+
}
|
|
33889
|
+
if (cameBack.length > 0) {
|
|
33890
|
+
notes.push(`${cameBack.join(", ")} left the cache while ${cameBack.length === 1 ? "its script was" : "their scripts were"} gone: the script${cameBack.length === 1 ? " is" : "s are"} newer than the cache the editor last wrote, so it listed what was on disk then. editor_rescan picks ${cameBack.length === 1 ? "it" : "them"} up.`);
|
|
33891
|
+
}
|
|
33892
|
+
if (notes.length === 0) {
|
|
33801
33893
|
return rebuilt;
|
|
33802
33894
|
}
|
|
33803
|
-
const them = lost.length === 1 ? "it" : "them";
|
|
33804
33895
|
return {
|
|
33805
33896
|
...rebuilt,
|
|
33806
33897
|
payload: {
|
|
33807
33898
|
...rebuilt.payload,
|
|
33808
33899
|
lostSinceLastRebuild: lost,
|
|
33809
|
-
note:
|
|
33900
|
+
note: notes.join(" ")
|
|
33810
33901
|
}
|
|
33811
33902
|
};
|
|
33812
33903
|
}
|
|
@@ -33882,7 +33973,7 @@ class GodotServer {
|
|
|
33882
33973
|
]);
|
|
33883
33974
|
}
|
|
33884
33975
|
try {
|
|
33885
|
-
return this.jsonTextResponse(markIfStale(await this.godotBridge.invokeTool(toolName, args), this.godotBridge.getStatus().addonVersion, SERVER_VERSION));
|
|
33976
|
+
return this.jsonTextResponse(markIfStale(await this.godotBridge.invokeTool(toolName, args), this.godotBridge.getStatus().addonVersion, SERVER_VERSION, this.godotBridge.getStatus().addonDigest, shippedEditorDigest()));
|
|
33886
33977
|
} catch (error) {
|
|
33887
33978
|
return this.createErrorResponse(`The editor answered ${toolName} with an error: ${errorMessage(error)}`);
|
|
33888
33979
|
}
|
|
@@ -33935,8 +34026,59 @@ class GodotServer {
|
|
|
33935
34026
|
}
|
|
33936
34027
|
return { heldAt: undefined, heldUnknown: true, heldNote: reply.message };
|
|
33937
34028
|
}
|
|
34029
|
+
async ownRunOnItsWay(projectPath, budgetMs) {
|
|
34030
|
+
const run = this.currentRun();
|
|
34031
|
+
const project = projectPath ?? run?.projectPath ?? null;
|
|
34032
|
+
if (run === null || project === null || run.announcedPid !== undefined || run.projectPath !== null && !isSameDirectory(run.projectPath, project) || !existsSync10(join16(project, RUNTIME_AUTOLOAD.path)) || !await this.runStillGoing(run)) {
|
|
34033
|
+
return null;
|
|
34034
|
+
}
|
|
34035
|
+
const already = runtimesAnnounced().running;
|
|
34036
|
+
const announcedSinceTheStart = (one) => {
|
|
34037
|
+
if (run.announcedBefore !== undefined) {
|
|
34038
|
+
return !run.announcedBefore.has(one.pid);
|
|
34039
|
+
}
|
|
34040
|
+
try {
|
|
34041
|
+
return statSync7(one.file).mtimeMs >= run.startedAt;
|
|
34042
|
+
} catch {
|
|
34043
|
+
return false;
|
|
34044
|
+
}
|
|
34045
|
+
};
|
|
34046
|
+
if (already.some((one) => announcedSinceTheStart(one) && this.isTheGameOf(run, one, project))) {
|
|
34047
|
+
return null;
|
|
34048
|
+
}
|
|
34049
|
+
const which = run.throughEditor ? "the game the editor is playing for it" : `pid ${run.pid ?? "unknown"}`;
|
|
34050
|
+
const comingUpUntil = run.startedAt + waitSizedTo(readBootNote(project)) + budgetMs;
|
|
34051
|
+
const remaining = comingUpUntil - Date.now();
|
|
34052
|
+
if (remaining <= 0) {
|
|
34053
|
+
const seconds = Math.round((Date.now() - run.startedAt) / 1000);
|
|
34054
|
+
return `The game this server started (${which}) has been running for ${seconds}s and has not announced its runtime, so there is nothing to talk to. A game that keeps the runtime off, as a project's own loader can for some scenes, never announces; editor_output says what it has printed.`;
|
|
34055
|
+
}
|
|
34056
|
+
const endpoint = await announcedSince(project, new Set(already.map((one) => one.pid)), {
|
|
34057
|
+
budgetMs: Math.min(budgetMs, remaining),
|
|
34058
|
+
accept: (one) => this.isTheGameOf(this.currentRun(), one, project),
|
|
34059
|
+
giveUp: () => this.currentRun() !== run || this.dapClient?.isStopped() === true || !stillRunning(run)
|
|
34060
|
+
});
|
|
34061
|
+
if (endpoint !== null) {
|
|
34062
|
+
const tiedMeanwhile = () => run.announcedPid !== undefined;
|
|
34063
|
+
if (!tiedMeanwhile()) {
|
|
34064
|
+
this.tie(run, endpoint);
|
|
34065
|
+
}
|
|
34066
|
+
return null;
|
|
34067
|
+
}
|
|
34068
|
+
if (this.currentRun() !== run || !await this.runStillGoing(run)) {
|
|
34069
|
+
return null;
|
|
34070
|
+
}
|
|
34071
|
+
return `The game this server started (${which}) is still coming up and has not announced its runtime after a wait of ${Math.min(budgetMs, remaining)}ms, so there is nothing to talk to yet. It is still running: ask again in a moment rather than starting another.`;
|
|
34072
|
+
}
|
|
33938
34073
|
async handleRuntimeCommand(command, args, timeoutMs = this.runtimeTimeoutMs()) {
|
|
33939
34074
|
const { op: _op, projectPath, pid, ...params } = asParams(args);
|
|
34075
|
+
const waited = typeof pid === "number" ? null : await this.ownRunOnItsWay(typeof projectPath === "string" ? projectPath : this.ownProject, timeoutMs);
|
|
34076
|
+
if (waited !== null) {
|
|
34077
|
+
return this.createErrorResponse(waited, [
|
|
34078
|
+
"editor_status lists it under runtimes once it has announced",
|
|
34079
|
+
"editor_output says what it has printed so far"
|
|
34080
|
+
]);
|
|
34081
|
+
}
|
|
33940
34082
|
const announced = runtimesAnnounced();
|
|
33941
34083
|
const wanted = typeof projectPath === "string" ? projectPath : this.ownProject ?? undefined;
|
|
33942
34084
|
const own = typeof pid === "number" ? undefined : this.ownGameAmong(announced.running, wanted) ?? await this.ownGameThroughTheTree(announced.running, wanted);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gdharness",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.14",
|
|
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",
|