gdharness 1.1.4 → 1.1.6

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/keeper.js CHANGED
@@ -2,7 +2,143 @@
2
2
 
3
3
  // src/keeper.ts
4
4
  import { spawn } from "node:child_process";
5
- import { closeSync, openSync as openSync2 } from "node:fs";
5
+ import { closeSync, existsSync, mkdtempSync, openSync as openSync2, readFileSync as readFileSync2, rmSync as rmSync2 } from "node:fs";
6
+ import { tmpdir as tmpdir2 } from "node:os";
7
+ import { join as join3 } from "node:path";
8
+ import { setTimeout as delay } from "node:timers/promises";
9
+
10
+ // src/desktop.ts
11
+ import { createHash } from "node:crypto";
12
+ var SOURCE = `using System;
13
+ using System.ComponentModel;
14
+ using System.IO;
15
+ using System.Runtime.InteropServices;
16
+ using System.Text;
17
+
18
+ public static class GdharnessDesktop {
19
+ const uint GenericAll = 0x10000000;
20
+ const int UseStdHandles = 0x100;
21
+ // No console at all, rather than a hidden one: a console's host would sit on the desktop too.
22
+ const uint DetachedProcess = 0x00000008;
23
+ const uint Infinite = 0xFFFFFFFF;
24
+
25
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
26
+ struct StartupInfo {
27
+ public int cb;
28
+ public string reserved;
29
+ public string desktop;
30
+ public string title;
31
+ public int x, y, xSize, ySize, xCount, yCount, fill, flags;
32
+ public short show, reservedSize;
33
+ public IntPtr reservedBytes, input, output, error;
34
+ }
35
+
36
+ [StructLayout(LayoutKind.Sequential)]
37
+ struct Started {
38
+ public IntPtr process, thread;
39
+ public int processId, threadId;
40
+ }
41
+
42
+ [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
43
+ static extern IntPtr CreateDesktopW(string name, IntPtr device, IntPtr mode, uint flags, uint access, IntPtr attributes);
44
+
45
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
46
+ static extern bool CreateProcessW(string application, StringBuilder commandLine, IntPtr processAttributes, IntPtr threadAttributes, bool inherit, uint flags, IntPtr environment, string directory, ref StartupInfo startup, out Started started);
47
+
48
+ [DllImport("kernel32.dll")] static extern IntPtr GetStdHandle(int which);
49
+ [DllImport("kernel32.dll")] static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds);
50
+ [DllImport("kernel32.dll")] static extern bool GetExitCodeProcess(IntPtr handle, out uint code);
51
+ [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle);
52
+
53
+ // Starts the command line on the named desktop, both of its streams on this process's output,
54
+ // writes its pid, and waits for it: the answer is its exit code. The desktop's handle is held until then, since a
55
+ // desktop goes when the last handle to it does.
56
+ public static int Run(string desktop, string commandLine, string pidFile) {
57
+ IntPtr held = CreateDesktopW(desktop, IntPtr.Zero, IntPtr.Zero, 0, GenericAll, IntPtr.Zero);
58
+ if (held == IntPtr.Zero) {
59
+ throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateDesktopW " + desktop);
60
+ }
61
+ StartupInfo startup = new StartupInfo();
62
+ startup.cb = Marshal.SizeOf(typeof(StartupInfo));
63
+ startup.desktop = desktop;
64
+ startup.flags = UseStdHandles;
65
+ startup.input = GetStdHandle(-10);
66
+ startup.output = GetStdHandle(-11);
67
+ startup.error = startup.output;
68
+ Started started;
69
+ if (!CreateProcessW(null, new StringBuilder(commandLine), IntPtr.Zero, IntPtr.Zero, true, DetachedProcess, IntPtr.Zero, null, ref startup, out started)) {
70
+ throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateProcessW " + commandLine);
71
+ }
72
+ File.WriteAllText(pidFile, started.processId.ToString());
73
+ WaitForSingleObject(started.process, Infinite);
74
+ uint code;
75
+ GetExitCodeProcess(started.process, out code);
76
+ CloseHandle(started.thread);
77
+ CloseHandle(started.process);
78
+ return unchecked((int)code);
79
+ }
80
+ }
81
+ `;
82
+ function quoted(argument) {
83
+ if (argument !== "" && !/[\s"]/.test(argument)) {
84
+ return argument;
85
+ }
86
+ let text = '"';
87
+ let backslashes = 0;
88
+ for (const character of argument) {
89
+ if (character === "\\") {
90
+ backslashes += 1;
91
+ continue;
92
+ }
93
+ text += character === '"' ? `${"\\".repeat(backslashes * 2 + 1)}"` : `${"\\".repeat(backslashes)}${character}`;
94
+ backslashes = 0;
95
+ }
96
+ return `${text}${"\\".repeat(backslashes * 2)}"`;
97
+ }
98
+ function windowsCommandLine(command, args) {
99
+ return [command, ...args].map(quoted).join(" ");
100
+ }
101
+ function onDesktop(desktop, command, args, pidFile, errorFile) {
102
+ const spec = {
103
+ desktop,
104
+ commandLine: windowsCommandLine(command, args),
105
+ pidFile,
106
+ errorFile,
107
+ source: SOURCE,
108
+ digest: createHash("sha256").update(SOURCE).digest("hex").slice(0, 16)
109
+ };
110
+ const encoded = Buffer.from(JSON.stringify(spec), "utf8").toString("base64");
111
+ const script = [
112
+ "$ErrorActionPreference = 'Stop'",
113
+ `$spec = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}')) | ConvertFrom-Json`,
114
+ "try {",
115
+ " $assembly = Join-Path ([IO.Path]::GetTempPath()) ('gdharness-desktop-' + $spec.digest + '.dll')",
116
+ " if (-not (Test-Path -LiteralPath $assembly)) {",
117
+ ' $partial = "$assembly.$PID"',
118
+ " Add-Type -TypeDefinition $spec.source -OutputAssembly $partial",
119
+ " try { Move-Item -LiteralPath $partial -Destination $assembly } catch { Remove-Item -LiteralPath $partial -ErrorAction SilentlyContinue }",
120
+ " }",
121
+ " Add-Type -LiteralPath $assembly",
122
+ " $code = [GdharnessDesktop]::Run($spec.desktop, $spec.commandLine, $spec.pidFile)",
123
+ "} catch {",
124
+ " [IO.File]::WriteAllText($spec.errorFile, $_.Exception.Message)",
125
+ " exit 1",
126
+ "}",
127
+ "exit $code"
128
+ ].join(`
129
+ `);
130
+ return {
131
+ command: "powershell.exe",
132
+ args: [
133
+ "-NoProfile",
134
+ "-NonInteractive",
135
+ "-ExecutionPolicy",
136
+ "Bypass",
137
+ "-EncodedCommand",
138
+ Buffer.from(script, "utf16le").toString("base64")
139
+ ]
140
+ };
141
+ }
6
142
 
7
143
  // src/outside.ts
8
144
  import { fileURLToPath } from "node:url";
@@ -39,7 +175,7 @@ function readLaunched(printed) {
39
175
  }
40
176
 
41
177
  // src/run-record.ts
42
- import { createHash } from "node:crypto";
178
+ import { createHash as createHash2 } from "node:crypto";
43
179
  import {
44
180
  mkdirSync,
45
181
  openSync,
@@ -104,7 +240,7 @@ function realAsFarAsItExists(path) {
104
240
  function projectKey(projectPath) {
105
241
  const real = realAsFarAsItExists(resolve2(projectPath)).replace(/[\\/]+$/, "");
106
242
  const folded = process.platform === "win32" || process.platform === "darwin" ? real.toLowerCase() : real;
107
- return createHash("sha256").update(folded.replaceAll("\\", "/")).digest("hex").slice(0, 16);
243
+ return createHash2("sha256").update(folded.replaceAll("\\", "/")).digest("hex").slice(0, 16);
108
244
  }
109
245
  function runRecordPath(projectPath, directory = runtimeDirectory()) {
110
246
  return join2(directory, "runs", `note-${projectKey(projectPath)}.json`);
@@ -159,11 +295,12 @@ function recordAt(path) {
159
295
  }
160
296
 
161
297
  // src/keeper.ts
162
- async function startDetached(spec, output) {
298
+ async function startDirectly(spec, output, hidden, detached = true, errors = output) {
163
299
  return await new Promise((resolve) => {
164
300
  const child = spawn(spec.command, [...spec.args], {
165
- stdio: ["ignore", output, output],
166
- detached: true,
301
+ stdio: ["ignore", output, errors],
302
+ detached,
303
+ windowsHide: hidden,
167
304
  env: withChanges(process.env, spec.envChanges)
168
305
  });
169
306
  child.once("error", (error) => {
@@ -174,10 +311,49 @@ async function startDetached(spec, output) {
174
311
  });
175
312
  });
176
313
  }
314
+ async function startOnDesktop(spec, output) {
315
+ const scratch = mkdtempSync(join3(tmpdir2(), "gdharness-desktop-"));
316
+ const pidFile = join3(scratch, "pid");
317
+ const errorFile = join3(scratch, "error");
318
+ try {
319
+ const wrapper = onDesktop(spec.desktop, spec.command, spec.args, pidFile, errorFile);
320
+ const started = await startDirectly({ ...spec, command: wrapper.command, args: wrapper.args }, output, true, false, "ignore");
321
+ if ("error" in started) {
322
+ return started;
323
+ }
324
+ const exited = { now: false };
325
+ started.child.once("exit", () => {
326
+ exited.now = true;
327
+ });
328
+ for (let waited = 0;waited < DESKTOP_START_MS; waited += 50) {
329
+ const pid = existsSync(pidFile) ? Number(readFileSync2(pidFile, "utf8").trim()) : Number.NaN;
330
+ if (Number.isInteger(pid) && pid > 0) {
331
+ return { child: started.child, pid };
332
+ }
333
+ if (exited.now) {
334
+ const why = existsSync(errorFile) ? readFileSync2(errorFile, "utf8").trim() : "it exited saying nothing";
335
+ return { error: `it could not be started on the ${spec.desktop} desktop: ${why}` };
336
+ }
337
+ await delay(50);
338
+ }
339
+ started.child.kill();
340
+ return { error: `it was not started on the ${spec.desktop} desktop within ${DESKTOP_START_MS} ms` };
341
+ } finally {
342
+ rmSync2(scratch, { recursive: true, force: true });
343
+ }
344
+ }
345
+ var DESKTOP_START_MS = 60000;
346
+ async function startDetached(spec, output, hidden) {
347
+ const desktop = spec.desktop;
348
+ return desktop !== undefined && process.platform === "win32" ? await startOnDesktop({ ...spec, desktop }, output) : await startDirectly(spec, output, hidden);
349
+ }
350
+ function onItsOwnDesktop(spec) {
351
+ return spec.desktop !== undefined && process.platform === "win32";
352
+ }
177
353
  async function launch() {
178
354
  const spec = await receivedSpec();
179
- if (spec.run === undefined) {
180
- const started = await startDetached(spec, "ignore");
355
+ if (spec.run === undefined && !onItsOwnDesktop(spec)) {
356
+ const started = await startDetached(spec, "ignore", true);
181
357
  if ("error" in started) {
182
358
  process.stdout.write(`error ${started.error}
183
359
  `);
@@ -225,18 +401,20 @@ async function launch() {
225
401
  }
226
402
  async function keep() {
227
403
  const spec = await receivedSpec();
228
- if (spec.run === undefined) {
229
- process.stdout.write(`error a keeper was started without a run
404
+ const run = spec.run;
405
+ if (run === undefined && !onItsOwnDesktop(spec)) {
406
+ process.stdout.write(`error a keeper was started without a run or a desktop
230
407
  `);
231
408
  process.exit(1);
232
409
  }
233
- const run = spec.run;
234
- const transcript = openSync2(run.transcript, "a");
410
+ const transcript = run === undefined ? "ignore" : openSync2(run.transcript, "a");
235
411
  let started;
236
412
  try {
237
- started = await startDetached(spec, transcript);
413
+ started = await startDetached(spec, transcript, run === undefined);
238
414
  } finally {
239
- closeSync(transcript);
415
+ if (transcript !== "ignore") {
416
+ closeSync(transcript);
417
+ }
240
418
  }
241
419
  if ("error" in started) {
242
420
  process.stdout.write(`error ${started.error}
@@ -244,16 +422,20 @@ async function keep() {
244
422
  process.exit(1);
245
423
  }
246
424
  const { child, pid } = started;
247
- writeRunRecord({
248
- pid,
249
- transcript: run.transcript,
250
- startedAt: run.startedAt,
251
- projectPath: run.projectPath,
252
- arguments: spec.args,
253
- command: spec.command
254
- });
425
+ if (run !== undefined) {
426
+ writeRunRecord({
427
+ pid,
428
+ transcript: run.transcript,
429
+ startedAt: run.startedAt,
430
+ projectPath: run.projectPath,
431
+ arguments: spec.args,
432
+ command: spec.command
433
+ });
434
+ }
255
435
  child.once("exit", (code, signal) => {
256
- recordRunEnded(run.projectPath, pid, { exitCode: code, exitSignal: code === null ? signal : null });
436
+ if (run !== undefined) {
437
+ recordRunEnded(run.projectPath, pid, { exitCode: code, exitSignal: code === null ? signal : null });
438
+ }
257
439
  process.exit(0);
258
440
  });
259
441
  process.stdout.write(`pid ${pid}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.1.4",
3
+ "version": "1.1.6",
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",