gdharness 0.5.1 → 0.5.3

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 CHANGED
@@ -160,6 +160,19 @@ function runArguments(options) {
160
160
  function editorArguments(projectPath) {
161
161
  return ["-e", "--path", projectPath];
162
162
  }
163
+ function userDataIn(home, variables = process.env) {
164
+ const moved = ["APPDATA", "XDG_DATA_HOME"];
165
+ const carried = {};
166
+ for (const [name, value] of Object.entries(variables)) {
167
+ if (!moved.some((named) => named.toLowerCase() === name.toLowerCase())) {
168
+ carried[name] = value;
169
+ }
170
+ }
171
+ for (const named of moved) {
172
+ carried[named] = home;
173
+ }
174
+ return carried;
175
+ }
163
176
 
164
177
  // node_modules/yaml/dist/nodes/identity.js
165
178
  var require_identity = __commonJS(function(exports) {
@@ -15338,7 +15351,7 @@ var init_tool_definitions = __esm(() => {
15338
15351
  },
15339
15352
  {
15340
15353
  name: "project_test",
15341
- description: "Runs the project's gdUnit4 tests headless and answers with every case: which failed, where, and what the assertion said. The class list is rebuilt first, so a suite written a moment ago is found. Needs gdUnit4 under addons/gdUnit4.",
15354
+ description: "Runs the project's gdUnit4 tests headless and answers with every case: which failed, where, and what the assertion said. The class list is rebuilt first, so a suite written a moment ago is found. On Windows and Linux the run gets a user:// of its own, so a suite that saves a game writes nowhere near the saves of the copy somebody plays. Needs gdUnit4 under addons/gdUnit4.",
15342
15355
  parameters: {
15343
15356
  projectPath: PROJECT_PATH,
15344
15357
  path: {
@@ -35447,32 +35460,41 @@ function parseAnnouncement(file, pid) {
35447
35460
  try {
35448
35461
  fields = asParams(JSON.parse(readFileSync10(file, "utf8")));
35449
35462
  } catch {
35450
- return null;
35463
+ return { kind: "rubbish" };
35451
35464
  }
35452
35465
  const project = readParams(fields, "project");
35453
35466
  const port = readNumber(fields, "port");
35454
35467
  const address = readString(fields, "address");
35455
- if (readNumber(fields, "protocol") !== RUNTIME_PROTOCOL || readNumber(fields, "pid") !== pid || port === undefined || !Number.isInteger(port) || port < 1 || port > 65535 || address === undefined || project === undefined) {
35456
- return null;
35468
+ const protocol = readNumber(fields, "protocol");
35469
+ if (readNumber(fields, "pid") !== pid || port === undefined || !Number.isInteger(port) || port < 1 || port > 65535 || address === undefined || project === undefined) {
35470
+ return { kind: "rubbish" };
35457
35471
  }
35458
- return {
35459
- pid,
35460
- port,
35461
- address,
35462
- project: { name: readString(project, "name") ?? "", path: readString(project, "path") ?? "" },
35463
- file
35472
+ const named = {
35473
+ name: readString(project, "name") ?? "",
35474
+ path: readString(project, "path") ?? ""
35464
35475
  };
35476
+ if (protocol !== RUNTIME_PROTOCOL) {
35477
+ return { kind: "unspoken", unspoken: { pid, protocol: protocol ?? 0, project: named } };
35478
+ }
35479
+ return { kind: "runtime", endpoint: { pid, port, address, project: named, file } };
35465
35480
  }
35466
- function discoverRuntimes(directories = runtimeDirectories()) {
35467
- const found = new Map;
35481
+ function runtimesAnnounced(directories = runtimeDirectories()) {
35482
+ const running = new Map;
35483
+ const unspoken = new Map;
35468
35484
  for (const directory of directories) {
35469
- for (const endpoint of announcedIn(directory)) {
35470
- if (!found.has(endpoint.pid)) {
35471
- found.set(endpoint.pid, endpoint);
35485
+ for (const found of announcedIn(directory)) {
35486
+ if (found.kind === "runtime" && !running.has(found.endpoint.pid)) {
35487
+ running.set(found.endpoint.pid, found.endpoint);
35488
+ } else if (found.kind === "unspoken" && !unspoken.has(found.unspoken.pid)) {
35489
+ unspoken.set(found.unspoken.pid, found.unspoken);
35472
35490
  }
35473
35491
  }
35474
35492
  }
35475
- return [...found.values()].sort((a, b) => b.pid - a.pid);
35493
+ const newest = (a, b) => b.pid - a.pid;
35494
+ return { running: [...running.values()].sort(newest), unspoken: [...unspoken.values()].sort(newest) };
35495
+ }
35496
+ function discoverRuntimes(directories = runtimeDirectories()) {
35497
+ return runtimesAnnounced(directories).running;
35476
35498
  }
35477
35499
  function announcedIn(directory) {
35478
35500
  if (!existsSync9(directory)) {
@@ -35486,22 +35508,30 @@ function announcedIn(directory) {
35486
35508
  }
35487
35509
  const file = join11(directory, entry);
35488
35510
  const pid = Number.parseInt(match[1] ?? "", 10);
35489
- const endpoint = processAlive(pid) ? parseAnnouncement(file, pid) : null;
35490
- if (endpoint) {
35491
- found.push(endpoint);
35492
- } else {
35511
+ const announced = processAlive(pid) ? parseAnnouncement(file, pid) : { kind: "rubbish" };
35512
+ if (announced.kind === "rubbish") {
35493
35513
  try {
35494
35514
  unlinkSync(file);
35495
35515
  } catch {}
35516
+ continue;
35496
35517
  }
35518
+ found.push(announced);
35497
35519
  }
35498
35520
  return found;
35499
35521
  }
35500
35522
  function describe2(endpoint) {
35501
35523
  return `pid ${endpoint.pid} on ${endpoint.address}:${endpoint.port} (${endpoint.project.name || "unnamed"} at ${endpoint.project.path})`;
35502
35524
  }
35503
- function chooseRuntime(endpoints, projectPath) {
35525
+ function tooNew(unspoken) {
35526
+ const named = unspoken.map((game) => `pid ${game.pid} speaks ${game.protocol} (${game.project.name || "unnamed"} at ${game.project.path})`).join("; ");
35527
+ const half = unspoken.some((game) => game.protocol > RUNTIME_PROTOCOL) ? "this server is the older half: reconnect it so it spawns the installed version" : "the addon is the older half: reinstall it and restart the game";
35528
+ return `A game is running, in a protocol this server does not speak ${named}. This server speaks ${RUNTIME_PROTOCOL}, so ${half}.`;
35529
+ }
35530
+ function chooseRuntime(endpoints, projectPath, unspoken = []) {
35504
35531
  if (endpoints.length === 0) {
35532
+ if (unspoken.length > 0) {
35533
+ return { problem: tooNew(unspoken) };
35534
+ }
35505
35535
  return {
35506
35536
  problem: "No game with the runtime addon is running. Start one with editor_run, or play the project from the editor with the addon enabled."
35507
35537
  };
@@ -36422,7 +36452,8 @@ class GodotServer {
36422
36452
  ];
36423
36453
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 600000;
36424
36454
  this.logDebug(`Running tests: ${engine.value} ${cmdArgs.join(" ")}`);
36425
- const run = this.spawnGame(engine.value, cmdArgs);
36455
+ const userData = mkdtempSync3(join12(tmpdir5(), "gdharness-tests-"));
36456
+ const run = this.spawnGame(engine.value, cmdArgs, userDataIn(userData));
36426
36457
  const hung = await new Promise((resolve) => {
36427
36458
  const timer = setTimeout(() => {
36428
36459
  run.process.kill();
@@ -36450,6 +36481,7 @@ class GodotServer {
36450
36481
  reportProblem = errorMessage(error);
36451
36482
  } finally {
36452
36483
  rmSync6(reportsDir, { recursive: true, force: true });
36484
+ rmSync6(userData, { recursive: true, force: true });
36453
36485
  }
36454
36486
  const engineEntries = run.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries;
36455
36487
  const verdicts = {
@@ -36806,8 +36838,8 @@ class GodotServer {
36806
36838
  }
36807
36839
  running.process?.kill();
36808
36840
  }
36809
- spawnGame(godotPath, cmdArgs) {
36810
- const child = spawn(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"] });
36841
+ spawnGame(godotPath, cmdArgs, env) {
36842
+ const child = spawn(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
36811
36843
  const log = new GameLog;
36812
36844
  const started = {
36813
36845
  process: child,
@@ -36950,7 +36982,8 @@ class GodotServer {
36950
36982
  }
36951
36983
  async handleRuntimeCommand(command, args, timeoutMs = this.runtimeTimeoutMs()) {
36952
36984
  const { op: _op, projectPath, ...params } = asParams(args);
36953
- const choice = chooseRuntime(discoverRuntimes(), typeof projectPath === "string" ? projectPath : undefined);
36985
+ const announced = runtimesAnnounced();
36986
+ const choice = chooseRuntime(announced.running, typeof projectPath === "string" ? projectPath : undefined, announced.unspoken);
36954
36987
  if ("problem" in choice) {
36955
36988
  return this.createErrorResponse(choice.problem);
36956
36989
  }
package/build/index.js CHANGED
@@ -25933,6 +25933,19 @@ function runArguments(options) {
25933
25933
  function editorArguments(projectPath) {
25934
25934
  return ["-e", "--path", projectPath];
25935
25935
  }
25936
+ function userDataIn(home, variables = process.env) {
25937
+ const moved = ["APPDATA", "XDG_DATA_HOME"];
25938
+ const carried = {};
25939
+ for (const [name, value] of Object.entries(variables)) {
25940
+ if (!moved.some((named) => named.toLowerCase() === name.toLowerCase())) {
25941
+ carried[name] = value;
25942
+ }
25943
+ }
25944
+ for (const named of moved) {
25945
+ carried[named] = home;
25946
+ }
25947
+ return carried;
25948
+ }
25936
25949
 
25937
25950
  // src/godot-path.ts
25938
25951
  var run = promisify(execFile);
@@ -27295,32 +27308,41 @@ function parseAnnouncement(file, pid) {
27295
27308
  try {
27296
27309
  fields = asParams(JSON.parse(readFileSync6(file, "utf8")));
27297
27310
  } catch {
27298
- return null;
27311
+ return { kind: "rubbish" };
27299
27312
  }
27300
27313
  const project = readParams(fields, "project");
27301
27314
  const port = readNumber(fields, "port");
27302
27315
  const address = readString(fields, "address");
27303
- if (readNumber(fields, "protocol") !== RUNTIME_PROTOCOL || readNumber(fields, "pid") !== pid || port === undefined || !Number.isInteger(port) || port < 1 || port > 65535 || address === undefined || project === undefined) {
27304
- return null;
27316
+ const protocol = readNumber(fields, "protocol");
27317
+ if (readNumber(fields, "pid") !== pid || port === undefined || !Number.isInteger(port) || port < 1 || port > 65535 || address === undefined || project === undefined) {
27318
+ return { kind: "rubbish" };
27305
27319
  }
27306
- return {
27307
- pid,
27308
- port,
27309
- address,
27310
- project: { name: readString(project, "name") ?? "", path: readString(project, "path") ?? "" },
27311
- file
27320
+ const named = {
27321
+ name: readString(project, "name") ?? "",
27322
+ path: readString(project, "path") ?? ""
27312
27323
  };
27324
+ if (protocol !== RUNTIME_PROTOCOL) {
27325
+ return { kind: "unspoken", unspoken: { pid, protocol: protocol ?? 0, project: named } };
27326
+ }
27327
+ return { kind: "runtime", endpoint: { pid, port, address, project: named, file } };
27313
27328
  }
27314
- function discoverRuntimes(directories = runtimeDirectories()) {
27315
- const found = new Map;
27329
+ function runtimesAnnounced(directories = runtimeDirectories()) {
27330
+ const running = new Map;
27331
+ const unspoken = new Map;
27316
27332
  for (const directory of directories) {
27317
- for (const endpoint of announcedIn(directory)) {
27318
- if (!found.has(endpoint.pid)) {
27319
- found.set(endpoint.pid, endpoint);
27333
+ for (const found of announcedIn(directory)) {
27334
+ if (found.kind === "runtime" && !running.has(found.endpoint.pid)) {
27335
+ running.set(found.endpoint.pid, found.endpoint);
27336
+ } else if (found.kind === "unspoken" && !unspoken.has(found.unspoken.pid)) {
27337
+ unspoken.set(found.unspoken.pid, found.unspoken);
27320
27338
  }
27321
27339
  }
27322
27340
  }
27323
- return [...found.values()].sort((a, b) => b.pid - a.pid);
27341
+ const newest = (a, b) => b.pid - a.pid;
27342
+ return { running: [...running.values()].sort(newest), unspoken: [...unspoken.values()].sort(newest) };
27343
+ }
27344
+ function discoverRuntimes(directories = runtimeDirectories()) {
27345
+ return runtimesAnnounced(directories).running;
27324
27346
  }
27325
27347
  function announcedIn(directory) {
27326
27348
  if (!existsSync5(directory)) {
@@ -27334,22 +27356,30 @@ function announcedIn(directory) {
27334
27356
  }
27335
27357
  const file = join6(directory, entry);
27336
27358
  const pid = Number.parseInt(match[1] ?? "", 10);
27337
- const endpoint = processAlive(pid) ? parseAnnouncement(file, pid) : null;
27338
- if (endpoint) {
27339
- found.push(endpoint);
27340
- } else {
27359
+ const announced = processAlive(pid) ? parseAnnouncement(file, pid) : { kind: "rubbish" };
27360
+ if (announced.kind === "rubbish") {
27341
27361
  try {
27342
27362
  unlinkSync(file);
27343
27363
  } catch {}
27364
+ continue;
27344
27365
  }
27366
+ found.push(announced);
27345
27367
  }
27346
27368
  return found;
27347
27369
  }
27348
27370
  function describe2(endpoint) {
27349
27371
  return `pid ${endpoint.pid} on ${endpoint.address}:${endpoint.port} (${endpoint.project.name || "unnamed"} at ${endpoint.project.path})`;
27350
27372
  }
27351
- function chooseRuntime(endpoints, projectPath) {
27373
+ function tooNew(unspoken) {
27374
+ const named = unspoken.map((game) => `pid ${game.pid} speaks ${game.protocol} (${game.project.name || "unnamed"} at ${game.project.path})`).join("; ");
27375
+ const half = unspoken.some((game) => game.protocol > RUNTIME_PROTOCOL) ? "this server is the older half: reconnect it so it spawns the installed version" : "the addon is the older half: reinstall it and restart the game";
27376
+ return `A game is running, in a protocol this server does not speak ${named}. This server speaks ${RUNTIME_PROTOCOL}, so ${half}.`;
27377
+ }
27378
+ function chooseRuntime(endpoints, projectPath, unspoken = []) {
27352
27379
  if (endpoints.length === 0) {
27380
+ if (unspoken.length > 0) {
27381
+ return { problem: tooNew(unspoken) };
27382
+ }
27353
27383
  return {
27354
27384
  problem: "No game with the runtime addon is running. Start one with editor_run, or play the project from the editor with the addon enabled."
27355
27385
  };
@@ -27767,7 +27797,7 @@ var TOOL_SPECS = [
27767
27797
  },
27768
27798
  {
27769
27799
  name: "project_test",
27770
- description: "Runs the project's gdUnit4 tests headless and answers with every case: which failed, where, and what the assertion said. The class list is rebuilt first, so a suite written a moment ago is found. Needs gdUnit4 under addons/gdUnit4.",
27800
+ description: "Runs the project's gdUnit4 tests headless and answers with every case: which failed, where, and what the assertion said. The class list is rebuilt first, so a suite written a moment ago is found. On Windows and Linux the run gets a user:// of its own, so a suite that saves a game writes nowhere near the saves of the copy somebody plays. Needs gdUnit4 under addons/gdUnit4.",
27771
27801
  parameters: {
27772
27802
  projectPath: PROJECT_PATH,
27773
27803
  path: {
@@ -29221,7 +29251,8 @@ class GodotServer {
29221
29251
  ];
29222
29252
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 600000;
29223
29253
  this.logDebug(`Running tests: ${engine.value} ${cmdArgs.join(" ")}`);
29224
- const run = this.spawnGame(engine.value, cmdArgs);
29254
+ const userData = mkdtempSync2(join7(tmpdir4(), "gdharness-tests-"));
29255
+ const run = this.spawnGame(engine.value, cmdArgs, userDataIn(userData));
29225
29256
  const hung = await new Promise((resolve) => {
29226
29257
  const timer = setTimeout(() => {
29227
29258
  run.process.kill();
@@ -29249,6 +29280,7 @@ class GodotServer {
29249
29280
  reportProblem = errorMessage(error);
29250
29281
  } finally {
29251
29282
  rmSync2(reportsDir, { recursive: true, force: true });
29283
+ rmSync2(userData, { recursive: true, force: true });
29252
29284
  }
29253
29285
  const engineEntries = run.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries;
29254
29286
  const verdicts = {
@@ -29605,8 +29637,8 @@ class GodotServer {
29605
29637
  }
29606
29638
  running.process?.kill();
29607
29639
  }
29608
- spawnGame(godotPath, cmdArgs) {
29609
- const child = spawn(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"] });
29640
+ spawnGame(godotPath, cmdArgs, env) {
29641
+ const child = spawn(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
29610
29642
  const log = new GameLog;
29611
29643
  const started = {
29612
29644
  process: child,
@@ -29749,7 +29781,8 @@ class GodotServer {
29749
29781
  }
29750
29782
  async handleRuntimeCommand(command, args, timeoutMs = this.runtimeTimeoutMs()) {
29751
29783
  const { op: _op, projectPath, ...params } = asParams(args);
29752
- const choice = chooseRuntime(discoverRuntimes(), typeof projectPath === "string" ? projectPath : undefined);
29784
+ const announced = runtimesAnnounced();
29785
+ const choice = chooseRuntime(announced.running, typeof projectPath === "string" ? projectPath : undefined, announced.unspoken);
29753
29786
  if ("problem" in choice) {
29754
29787
  return this.createErrorResponse(choice.problem);
29755
29788
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
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",