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/cli.js +396 -96
- package/build/godot/addons/gdharness_editor/tool_executor.gd +3 -0
- package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +64 -15
- package/build/godot/addons/gdharness_editor/tools/settings_tools.gd +50 -1
- package/build/godot/addons/gdharness_runtime/runtime_capture.gd +10 -3
- package/build/godot/operations/godot_operations.gd +0 -2
- package/build/godot/operations/import_pipeline.gd +250 -85
- package/build/index.js +405 -108
- package/build/keeper.js +205 -23
- package/package.json +1 -1
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
|
|
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
|
|
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,
|
|
166
|
-
detached
|
|
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
|
-
|
|
229
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|