gdharness 1.2.34 → 1.2.35

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
@@ -8141,9 +8141,14 @@ class GameLog {
8141
8141
  const owner = this.lastHeadline[source];
8142
8142
  if (owner !== null && /^\s/.test(line)) {
8143
8143
  owner.detail.push(line.trim());
8144
+ owner.continues = false;
8144
8145
  return;
8145
8146
  }
8146
8147
  const headline = announced(line);
8148
+ if (owner?.continues === true && headline === null) {
8149
+ owner.detail.push(line.trim());
8150
+ return;
8151
+ }
8147
8152
  const detail = [];
8148
8153
  const entry = {
8149
8154
  index: this.entries.length,
@@ -8156,7 +8161,7 @@ class GameLog {
8156
8161
  if (headline?.byScript === true) {
8157
8162
  this.byScript.add(entry.index);
8158
8163
  }
8159
- this.lastHeadline[source] = headline ? { detail } : null;
8164
+ this.lastHeadline[source] = headline ? { detail, continues: source === "stderr" && headline.text.endsWith(":") } : null;
8160
8165
  }
8161
8166
  raisedByScript(entry) {
8162
8167
  return this.byScript.has(entry.index);
@@ -11558,20 +11563,36 @@ var init_tool_definitions = __esm(() => {
11558
11563
  {
11559
11564
  name: "project_export",
11560
11565
  title: "Export",
11561
- description: "Export presets and exports.",
11566
+ description: "Export presets and exports. add_preset writes a preset to export_presets.cfg with the keys the editor's Project > Export writes for a new one, every platform option left at its default, and makes it the platform's runnable preset when the platform has none. It answers where this engine's export templates belong and whether the preset's platform has them, since an export needs them; list says the same for every preset. An editor open on the project does not see a preset added this way until it restarts. run names, as alsoWritten, what else the export wrote beside its output, such as the .pck a Windows or Linux build cannot start without.",
11562
11567
  parameters: {
11563
11568
  projectPath: PROJECT_PATH,
11564
- preset: { type: "string", description: "Preset name from export_presets.cfg." },
11569
+ preset: {
11570
+ type: "string",
11571
+ ops: ["run", "add_preset"],
11572
+ description: "Preset name from export_presets.cfg. add_preset: the name of the new one."
11573
+ },
11574
+ platform: {
11575
+ type: "string",
11576
+ ops: ["add_preset"],
11577
+ enum: ["Windows Desktop", "Linux", "macOS", "Android", "iOS", "Web"],
11578
+ description: "add_preset: the platform the preset exports for."
11579
+ },
11565
11580
  outputPath: {
11566
11581
  type: "string",
11567
- description: `Where the export is written, inside the project. The directory is created if it is not there: Godot's command-line exporter refuses a missing one with "The given export path doesn't exist", which reads as a wrong path in the preset.`
11582
+ ops: ["run", "add_preset"],
11583
+ description: `Where the export is written, inside the project. The directory is created if it is not there: Godot's command-line exporter refuses a missing one with "The given export path doesn't exist", which reads as a wrong path in the preset. add_preset: the path the preset exports to from the editor, optional.`
11568
11584
  },
11569
- debug: { type: "boolean", description: "run: a debug export. Default false." }
11585
+ debug: { type: "boolean", ops: ["run"], description: "run: a debug export. Default false." }
11570
11586
  },
11571
11587
  requires: ["projectPath"],
11572
11588
  operations: {
11573
11589
  list: { effect: "read", summary: "the presets in export_presets.cfg", requires: [] },
11574
- run: { effect: "destructive", summary: "export with a preset", requires: ["preset", "outputPath"] }
11590
+ run: { effect: "destructive", summary: "export with a preset", requires: ["preset", "outputPath"] },
11591
+ add_preset: {
11592
+ effect: "additive",
11593
+ summary: "add an export preset to export_presets.cfg",
11594
+ requires: ["preset", "platform"]
11595
+ }
11575
11596
  }
11576
11597
  },
11577
11598
  {
@@ -12946,7 +12967,7 @@ var init_headless_operations = __esm(() => {
12946
12967
  uid: "get_uid",
12947
12968
  refresh_classes: "refresh_class_cache"
12948
12969
  }),
12949
- project_export: dictionary({ list: "list_export_presets" }),
12970
+ project_export: dictionary({ list: "list_export_presets", add_preset: "add_export_preset" }),
12950
12971
  script_edit: dictionary({ create: "create_script", modify: "modify_script" }),
12951
12972
  script_info: dictionary({ structure: "get_script_info" }),
12952
12973
  editor_classes: dictionary({
@@ -44892,7 +44913,7 @@ function exportPresetRefusal(projectPath, preset) {
44892
44913
  try {
44893
44914
  text = readFileSync26(join30(projectPath, "export_presets.cfg"), "utf8");
44894
44915
  } catch {
44895
- return `This project has no export_presets.cfg, so it has no preset '${preset}' to export with. The editor writes the file when a preset is added in Project > Export.`;
44916
+ return `This project has no export_presets.cfg, so it has no preset '${preset}' to export with. project_export add_preset writes one, as the editor's Project > Export does.`;
44896
44917
  }
44897
44918
  const names = [];
44898
44919
  let inPreset = false;
@@ -44910,9 +44931,9 @@ function exportPresetRefusal(projectPath, preset) {
44910
44931
  if (names.includes(preset)) {
44911
44932
  return null;
44912
44933
  }
44913
- return names.length === 0 ? `export_presets.cfg names no preset, so there is no '${preset}' to export with. The editor adds one in Project > Export.` : `This project has no export preset named '${preset}'. Its presets are ${names.map((name) => `'${name}'`).join(", ")}.`;
44934
+ return names.length === 0 ? `export_presets.cfg names no preset, so there is no '${preset}' to export with. project_export add_preset adds one.` : `This project has no export preset named '${preset}'. Its presets are ${names.map((name) => `'${name}'`).join(", ")}.`;
44914
44935
  }
44915
- function exportAnswer(ending, asked, before, after) {
44936
+ function exportAnswer(ending, asked, before, after, beside = []) {
44916
44937
  const { log } = ending;
44917
44938
  const written = after !== null && after !== before;
44918
44939
  const notCopied = librariesNotCopied(log.all);
@@ -44924,6 +44945,7 @@ function exportAnswer(ending, asked, before, after) {
44924
44945
  failure: ending.failure ?? undefined,
44925
44946
  errors: log.count("error"),
44926
44947
  warnings: log.count("warning"),
44948
+ ...beside.length === 0 ? {} : { alsoWritten: beside },
44927
44949
  ...notCopied.length === 0 ? {} : {
44928
44950
  librariesNotLoaded: notCopied,
44929
44951
  extensionNote: `The export could not load ${notCopied.join(" and ")}: on Windows an engine running as the editor, which an export is, loads a GDExtension from a ~ copy beside the library, and another engine on this project held that copy: it could not be moved aside before the export, or another engine made a new one in between. Export again once that engine has loaded or exited.`
@@ -44946,6 +44968,20 @@ function exportAnswer(ending, asked, before, after) {
44946
44968
  isError: true
44947
44969
  };
44948
44970
  }
44971
+ function writtenBeside(before, after, output, within) {
44972
+ return [...after].filter(([name, at]) => name !== output && before.get(name) !== at).map(([name]) => within === "." ? name : `${within}/${name}`).sort();
44973
+ }
44974
+ function filesIn(folder) {
44975
+ const found = new Map;
44976
+ try {
44977
+ for (const entry of readdirSync13(folder, { withFileTypes: true })) {
44978
+ if (entry.isFile()) {
44979
+ found.set(entry.name, statSync13(join30(folder, entry.name)).mtimeMs);
44980
+ }
44981
+ }
44982
+ } catch {}
44983
+ return found;
44984
+ }
44949
44985
  function scanWaitAnswer(scanned, started = "game") {
44950
44986
  if (scanned.waitedMs === 0) {
44951
44987
  return {};
@@ -46180,6 +46216,9 @@ class GodotServer {
46180
46216
  if (tool === "project_import" && op === "set_options") {
46181
46217
  return await this.handleSetImportOptions(args);
46182
46218
  }
46219
+ if (tool === "project_export" && op === "add_preset") {
46220
+ return await this.handleAddExportPreset(args);
46221
+ }
46183
46222
  const headless = HEADLESS_OPERATIONS[tool]?.[op];
46184
46223
  if (headless !== undefined) {
46185
46224
  const { from: asked, ...answerable } = args;
@@ -47243,6 +47282,30 @@ class GodotServer {
47243
47282
  return this.createErrorResponse(`Search failed: ${errorMessage(error)}`);
47244
47283
  }
47245
47284
  }
47285
+ async handleAddExportPreset(args) {
47286
+ const project = this.project(args);
47287
+ if (!project.ok) {
47288
+ return project.response;
47289
+ }
47290
+ const given = readNonEmptyString(args, "outputPath");
47291
+ const output = given === undefined ? null : resolveWithinProject(project.value.path, given);
47292
+ if (output !== null && !output.ok) {
47293
+ return this.createErrorResponse(output.reason, OUTPUT_PATH_SOLUTIONS);
47294
+ }
47295
+ const outcome = await this.operation("add_export_preset", {
47296
+ preset: readString(args, "preset") ?? "",
47297
+ platform: readString(args, "platform") ?? "",
47298
+ outputPath: output?.relativePath ?? ""
47299
+ }, project.value.path);
47300
+ if (!outcome.ok || !this.editorHasOpen(project.value.path)) {
47301
+ return this.answer(outcome);
47302
+ }
47303
+ return this.jsonTextResponse({
47304
+ ...outcome.payload,
47305
+ editorNote: "The editor open on this project read export_presets.cfg when it started and does not list this preset, and saving presets in its Export dialog writes its own list over the file. editor_launch restart loads the file; an export through project_export run reads the file and needs no restart.",
47306
+ ...engineExtras(outcome)
47307
+ });
47308
+ }
47246
47309
  async handleExportProject(args) {
47247
47310
  const project = this.project(args);
47248
47311
  if (!project.ok) {
@@ -47251,11 +47314,14 @@ class GodotServer {
47251
47314
  const preset = readNonEmptyString(args, "preset") ?? "";
47252
47315
  const output = resolveWithinProject(project.value.path, readString(args, "outputPath") ?? "");
47253
47316
  if (!output.ok) {
47254
- return this.createErrorResponse(output.reason, PATH_SOLUTIONS);
47317
+ return this.createErrorResponse(output.reason, OUTPUT_PATH_SOLUTIONS);
47255
47318
  }
47256
47319
  const noPreset = exportPresetRefusal(project.value.path, preset);
47257
47320
  if (noPreset !== null) {
47258
- return this.createErrorResponse(noPreset, ["project_export list reads the presets the project has"]);
47321
+ return this.createErrorResponse(noPreset, [
47322
+ "project_export list reads the presets the project has",
47323
+ "project_export add_preset adds one"
47324
+ ]);
47259
47325
  }
47260
47326
  const engine = await this.engine();
47261
47327
  if (!engine.ok) {
@@ -47282,6 +47348,7 @@ class GodotServer {
47282
47348
  this.logDebug(`Exporting: ${engine.value} ${exportArgs.join(" ")}`);
47283
47349
  const writtenAt = () => existsSync23(output.absolutePath) ? statSync13(output.absolutePath).mtimeMs : null;
47284
47350
  const before = writtenAt();
47351
+ const listedBefore = filesIn(folder);
47285
47352
  const freed = freeLibraryCopies(project.value.path);
47286
47353
  if (freed.setAside.length > 0 || freed.swept.length > 0) {
47287
47354
  this.logDebug(`Before the export: set aside ${freed.setAside.join(", ")}; swept ${freed.swept.join(", ")}`);
@@ -47295,7 +47362,8 @@ class GodotServer {
47295
47362
  if (ending.exitSignal === null && ending.exitCode === null) {
47296
47363
  return this.createErrorResponse(`Export could not be run: ${ending.failure ?? "the engine gave no exit status"}`);
47297
47364
  }
47298
- return exportAnswer(ending, { preset, outputPath: output.relativePath, debug }, before, writtenAt());
47365
+ const beside = writtenBeside(listedBefore, filesIn(folder), basename9(output.absolutePath), dirname22(output.relativePath).replaceAll("\\", "/"));
47366
+ return exportAnswer(ending, { preset, outputPath: output.relativePath, debug }, before, writtenAt(), beside);
47299
47367
  }
47300
47368
  reportTestProgress(log) {
47301
47369
  const progress = callProgress();
@@ -51385,7 +51453,7 @@ async function runGodotServer() {
51385
51453
  const server = new GodotServer;
51386
51454
  await server.run();
51387
51455
  }
51388
- var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, __dirname2, PARENT_ASKED_AFTER_MS = 5000, EDITOR_SERVERS_START_MS = 20000, HOLD_PING_MS = 2000, SLOWEST_FRAME_RATE = 20, ALONGSIDE_NOTE = "Another call on this game was running while this one was: calls sent together run together and are answered as each finishes, so this answer is not from after the other. Send a call meant to come after another once that one has answered.", OWN_DEBUGGER = " Its debugger is the keeper holding it rather than the editor, so the debug_* tools answer for it all the same: breakpoints stop it, and debug_control pause breaks into it wherever it is, which the editor offers no way to do.", PLAY_STARTS_WITHIN_MS = 30000, KEEPER_ANSWER_MS = 15000, KEEPER_DEBUG_MS, EXIT_REPORTED_WITHIN_MS = 2000, CLOSE_ASK_MS = 3000, CONSOLE_WRAPPER, LONGEST_BOOT_NOTED_MS, PROFILE_SEND_MS = 5000, MOST_GROUPS = 20, RUNTIME_LISTENING_LINE = "[gdharness] runtime listening on ", VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, CONSOLE_WATCH_MS = 250, BOUNDARY_WAIT_MS = 2000, HANDOVER_MS, HANDOVER_POLL_MS = 250, CACHE_WRITE_MS = 2000, STREAMS_GRACE_MS = 2000, PLAYING_STATUS_MS = 1000, SCAN_START_MS = 2000, SCAN_SETTLED_MS = 500, REIMPORT_TIMEOUT_MS = 300000, OPS_ELSEWHERE, AUDIO_BUS_OPS, STALLED_AFTER_MS = 30000, TEST_HEARTBEAT_MS = 20000, DEPENDED_FAILED = "Failed to compile depended scripts", WAIT_FOR_RUN_MS = 600000, RUN_POLL_MS = 250, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, PROJECT_INFO_SECTIONS2, RUNNER_DIRECTORY = "addons/gdUnit4/", PROFILE_FUNCTIONS = 30, PROFILER_ON_MS = 3000, PROPERTY_PLACES = "It is a property, so each place says whether it writes it (access write), which runs its set block, or reads it (access read), which runs its get block: withinCalls on a read says nothing of the set block, nor on a write of the get block unless the write is a compound one such as +=. Inside the property's own blocks its name is the value stored, which the engine reads and writes without running them, so those lines are not listed.", CALLERS_FOUND_BY_NAME = "Found by name in the project's scripts, since the engine's profiler counts calls and time per function and records nothing about who made them: a method of the same name on another class is listed too, unless one class was asked about and it is called through another class by name or through a variable declared as one. Each place carries the profile's numbers for the function it is written in.", MOST_ENGINE_ENTRIES = 200, ERROR_ASSERT = "GdUnitGodotErrorAssertImpl.gd", EXTERNAL_MEMBER = 'Could not resolve external class member "', STATIC_LIST_HINT;
51456
+ var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, __dirname2, PARENT_ASKED_AFTER_MS = 5000, EDITOR_SERVERS_START_MS = 20000, HOLD_PING_MS = 2000, SLOWEST_FRAME_RATE = 20, ALONGSIDE_NOTE = "Another call on this game was running while this one was: calls sent together run together and are answered as each finishes, so this answer is not from after the other. Send a call meant to come after another once that one has answered.", OWN_DEBUGGER = " Its debugger is the keeper holding it rather than the editor, so the debug_* tools answer for it all the same: breakpoints stop it, and debug_control pause breaks into it wherever it is, which the editor offers no way to do.", PLAY_STARTS_WITHIN_MS = 30000, KEEPER_ANSWER_MS = 15000, KEEPER_DEBUG_MS, EXIT_REPORTED_WITHIN_MS = 2000, CLOSE_ASK_MS = 3000, CONSOLE_WRAPPER, LONGEST_BOOT_NOTED_MS, PROFILE_SEND_MS = 5000, MOST_GROUPS = 20, RUNTIME_LISTENING_LINE = "[gdharness] runtime listening on ", VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, CONSOLE_WATCH_MS = 250, BOUNDARY_WAIT_MS = 2000, HANDOVER_MS, HANDOVER_POLL_MS = 250, CACHE_WRITE_MS = 2000, STREAMS_GRACE_MS = 2000, PLAYING_STATUS_MS = 1000, SCAN_START_MS = 2000, SCAN_SETTLED_MS = 500, REIMPORT_TIMEOUT_MS = 300000, OPS_ELSEWHERE, AUDIO_BUS_OPS, STALLED_AFTER_MS = 30000, TEST_HEARTBEAT_MS = 20000, DEPENDED_FAILED = "Failed to compile depended scripts", WAIT_FOR_RUN_MS = 600000, RUN_POLL_MS = 250, PATH_SOLUTIONS, OUTPUT_PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, PROJECT_INFO_SECTIONS2, RUNNER_DIRECTORY = "addons/gdUnit4/", PROFILE_FUNCTIONS = 30, PROFILER_ON_MS = 3000, PROPERTY_PLACES = "It is a property, so each place says whether it writes it (access write), which runs its set block, or reads it (access read), which runs its get block: withinCalls on a read says nothing of the set block, nor on a write of the get block unless the write is a compound one such as +=. Inside the property's own blocks its name is the value stored, which the engine reads and writes without running them, so those lines are not listed.", CALLERS_FOUND_BY_NAME = "Found by name in the project's scripts, since the engine's profiler counts calls and time per function and records nothing about who made them: a method of the same name on another class is listed too, unless one class was asked about and it is called through another class by name or through a variable declared as one. Each place carries the profile's numbers for the function it is written in.", MOST_ENGINE_ENTRIES = 200, ERROR_ASSERT = "GdUnitGodotErrorAssertImpl.gd", EXTERNAL_MEMBER = 'Could not resolve external class member "', STATIC_LIST_HINT;
51389
51457
  var init_server = __esm(() => {
51390
51458
  init_dist();
51391
51459
  init_stdio();
@@ -51467,6 +51535,9 @@ var init_server = __esm(() => {
51467
51535
  'Give the path relative to the project, such as "scenes/main.tscn" or "res://scenes/main.tscn"',
51468
51536
  "Point projectPath at the project the file belongs to"
51469
51537
  ];
51538
+ OUTPUT_PATH_SOLUTIONS = [
51539
+ 'Give outputPath inside the project, relative to it, such as "build/game.exe" or "res://build/game.exe"; copy the build elsewhere once it is exported'
51540
+ ];
51470
51541
  PROJECT_FILE_ARGUMENTS = [
51471
51542
  "scenePath",
51472
51543
  "instancePath",
@@ -0,0 +1,232 @@
1
+ extends RefCounted
2
+
3
+ ## export_presets.cfg, read and written in the format the editor's Project > Export saves it in.
4
+
5
+ const Read = preload("reading.gd")
6
+ const Log = preload("logger.gd")
7
+
8
+ const PRESETS_FILE: String = "res://export_presets.cfg"
9
+ ## Where the editor names the one preset of each platform it runs on a device.
10
+ const RUNNABLE_SECTION: String = "runnable_presets"
11
+ ## The platforms the editor's exporter offers, by the name a preset gives its platform, each with
12
+ ## how the names of its files in the export templates directory begin.
13
+ const PLATFORMS: Dictionary[String, String] = {
14
+ "Windows Desktop": "windows_",
15
+ "Linux": "linux_",
16
+ "macOS": "macos",
17
+ "Android": "android_",
18
+ "iOS": "ios",
19
+ "Web": "web_",
20
+ }
21
+
22
+ var _log: Log
23
+
24
+
25
+ func _init(p_log: Log) -> void:
26
+ _log = p_log
27
+
28
+
29
+ ## The names of the project's export presets, in the order export_presets.cfg holds them.
30
+ static func names() -> Array[String]:
31
+ var named: Array[String] = []
32
+ var config: ConfigFile = ConfigFile.new()
33
+ if config.load(PRESETS_FILE) != OK:
34
+ return named
35
+ var index: int = 0
36
+ while config.has_section("preset." + str(index)):
37
+ named.append(str(config.get_value("preset." + str(index), "name", "")))
38
+ index += 1
39
+ return named
40
+
41
+
42
+ func list_export_presets(_params: Dictionary) -> Dictionary:
43
+ if not FileAccess.file_exists(PRESETS_FILE):
44
+ return {
45
+ "presets": [],
46
+ "presets_file_exists": false,
47
+ "note":
48
+ (
49
+ "No export_presets.cfg found. project_export add_preset adds a preset, as the editor's "
50
+ + "Project > Export does."
51
+ )
52
+ }
53
+ var config: ConfigFile = ConfigFile.new()
54
+ var err: Error = config.load(PRESETS_FILE)
55
+ if err != OK:
56
+ return _log.failure("Failed to parse export_presets.cfg: " + error_string(err))
57
+
58
+ var templates: String = _templates_dir()
59
+ var presets: Array[Dictionary] = []
60
+ var index: int = 0
61
+ while config.has_section("preset." + str(index)):
62
+ var section: String = "preset." + str(index)
63
+ var platform: String = str(config.get_value(section, "platform", "Unknown"))
64
+ var preset: Dictionary = {
65
+ "index": index,
66
+ "name": config.get_value(section, "name", "Unknown"),
67
+ "platform": platform,
68
+ "runnable": _runnable(config, section),
69
+ "export_path": config.get_value(section, "export_path", ""),
70
+ "export_filter": config.get_value(section, "export_filter", "all_resources"),
71
+ "include_filter": config.get_value(section, "include_filter", ""),
72
+ "exclude_filter": config.get_value(section, "exclude_filter", "")
73
+ }
74
+ if config.has_section_key(section, "custom_features"):
75
+ preset["custom_features"] = config.get_value(section, "custom_features", "")
76
+ # Left out for a platform this file names and the exporter does not know, which has no
77
+ # templates to look for.
78
+ if platform in PLATFORMS:
79
+ preset["templates_installed"] = _installed(templates, platform)
80
+ presets.append(preset)
81
+ index += 1
82
+ return {"presets": presets, "presets_file_exists": true, "total_presets": index, "templates": templates}
83
+
84
+
85
+ ## Whether the preset in [param section] is the one its platform runs. The editor names that preset
86
+ ## in a section of its own, and kept a key in the preset's section only in files saved before that.
87
+ static func _runnable(config: ConfigFile, section: String) -> bool:
88
+ var platform: String = str(config.get_value(section, "platform", ""))
89
+ if config.has_section_key(RUNNABLE_SECTION, platform):
90
+ return str(config.get_value(RUNNABLE_SECTION, platform)) == str(config.get_value(section, "name", ""))
91
+ return Read.as_bool(config.get_value(section, "runnable", false))
92
+
93
+
94
+ ## Adds an export preset to export_presets.cfg, written with the keys the editor's Project > Export
95
+ ## writes for a new one. The editor's exporter fills every platform option the file leaves out with
96
+ ## its default when it loads the file, which is what a preset fresh from that dialog holds too.
97
+ func add_export_preset(params: Dictionary) -> Dictionary:
98
+ var preset_name: String = str(params.get("preset", "")).strip_edges()
99
+ var platform: String = str(params.get("platform", ""))
100
+ var export_path: String = str(params.get("output_path", "")).trim_prefix("res://")
101
+ if preset_name.is_empty():
102
+ return _log.failure("preset is required: the name the preset is exported by")
103
+ if platform not in PLATFORMS:
104
+ return _log.failure(
105
+ "Unknown platform '%s'. The platforms are %s." % [platform, ", ".join(PLATFORMS.keys())]
106
+ )
107
+ var config: ConfigFile = ConfigFile.new()
108
+ if FileAccess.file_exists(PRESETS_FILE):
109
+ var loading: Error = config.load(PRESETS_FILE)
110
+ if loading != OK:
111
+ return _log.failure("Failed to parse export_presets.cfg: " + error_string(loading))
112
+ var index: int = 0
113
+ while config.has_section("preset." + str(index)):
114
+ var held: String = "preset." + str(index)
115
+ if str(config.get_value(held, "name", "")) == preset_name:
116
+ return _log.failure(
117
+ (
118
+ (
119
+ "There is already an export preset named '%s', for %s, exporting to '%s'. "
120
+ + "project_export run exports with it; give another name to add a second."
121
+ )
122
+ % [
123
+ preset_name,
124
+ config.get_value(held, "platform", ""),
125
+ config.get_value(held, "export_path", "")
126
+ ]
127
+ )
128
+ )
129
+ index += 1
130
+ # A platform with no preset to run takes the new one, as the editor's dialog does.
131
+ var runnable: bool = not config.has_section_key(RUNNABLE_SECTION, platform)
132
+ if runnable:
133
+ config.set_value(RUNNABLE_SECTION, platform, preset_name)
134
+
135
+ var section: String = "preset." + str(index)
136
+ var values: Dictionary = {
137
+ "name": preset_name,
138
+ "platform": platform,
139
+ "dedicated_server": false,
140
+ "custom_features": "",
141
+ "export_filter": "all_resources",
142
+ "include_filter": "",
143
+ "exclude_filter": "",
144
+ "export_path": export_path,
145
+ "patches": PackedStringArray(),
146
+ "encryption_include_filters": "",
147
+ "encryption_exclude_filters": "",
148
+ "seed": 0,
149
+ "encrypt_pck": false,
150
+ "encrypt_directory": false,
151
+ "script_export_mode": 2,
152
+ }
153
+ for key: String in values:
154
+ config.set_value(section, key, values[key])
155
+ # A section with nothing in it is not written, and the editor's loader reports a missing options
156
+ # section as an error; these two are options of every platform, empty in a new preset.
157
+ config.set_value(section + ".options", "custom_template/debug", "")
158
+ config.set_value(section + ".options", "custom_template/release", "")
159
+ var saving: Error = config.save(PRESETS_FILE)
160
+ if saving != OK:
161
+ return _log.failure("Failed to save export_presets.cfg: " + error_string(saving))
162
+
163
+ var templates: String = _templates_dir()
164
+ var installed: bool = _installed(templates, platform)
165
+ var answer: Dictionary = {
166
+ "preset": preset_name,
167
+ "platform": platform,
168
+ "index": index,
169
+ "runnable": runnable,
170
+ "export_path": export_path,
171
+ "file": PRESETS_FILE,
172
+ "templates": templates,
173
+ "templates_installed": installed,
174
+ }
175
+ if not installed:
176
+ answer["templates_note"] = (
177
+ (
178
+ "No %s export templates for this engine are at %s, and an export for %s needs them. The "
179
+ + "editor installs them in Editor > Manage Export Templates, or they can be unpacked "
180
+ + "there from the export templates archive of this engine's release."
181
+ )
182
+ % [platform, templates, platform]
183
+ )
184
+ return answer
185
+
186
+
187
+ ## Where the editor of this engine keeps its export templates: beside the executable for a
188
+ ## self-contained one, otherwise in the user's data directory, as the editor's own paths have it.
189
+ static func _templates_dir() -> String:
190
+ var version: Dictionary = Engine.get_version_info()
191
+ var named: String = "%d.%d" % [Read.as_int(version.get("major", 0)), Read.as_int(version.get("minor", 0))]
192
+ if Read.as_int(version.get("patch", 0)) != 0:
193
+ named += "." + str(Read.as_int(version.get("patch", 0)))
194
+ named += "." + str(version.get("status", ""))
195
+ if ClassDB.class_exists("CSharpScript"):
196
+ named += ".mono"
197
+ var beside: String = OS.get_executable_path().get_base_dir()
198
+ var contained: bool = _marks_self_contained(beside)
199
+ # A macOS bundle is read-only, so its editor looks for the marker beside the bundle too, and keeps
200
+ # its data there whichever of the two places had it.
201
+ if (
202
+ OS.has_feature("macos")
203
+ and beside.ends_with("MacOS")
204
+ and beside.path_join("..").simplify_path().ends_with("Contents")
205
+ ):
206
+ beside = beside.path_join("../../..").simplify_path()
207
+ contained = contained or _marks_self_contained(beside)
208
+ if contained:
209
+ return beside.path_join("editor_data").path_join("export_templates").path_join(named)
210
+ var home: String = (
211
+ "godot" if OS.get_name() in ["Linux", "FreeBSD", "NetBSD", "OpenBSD", "BSD"] else "Godot"
212
+ )
213
+ return OS.get_data_dir().path_join(home).path_join("export_templates").path_join(named)
214
+
215
+
216
+ ## Whether [param templates] holds any of [param platform]'s templates. The directory is one per
217
+ ## engine version and holds whichever platforms were installed into it, so its being there says
218
+ ## nothing about this platform.
219
+ static func _installed(templates: String, platform: String) -> bool:
220
+ if not DirAccess.dir_exists_absolute(templates):
221
+ return false
222
+ for file: String in DirAccess.get_files_at(templates):
223
+ if file.begins_with(PLATFORMS[platform]):
224
+ return true
225
+ return false
226
+
227
+
228
+ static func _marks_self_contained(directory: String) -> bool:
229
+ return (
230
+ FileAccess.file_exists(directory.path_join("._sc_"))
231
+ or FileAccess.file_exists(directory.path_join("_sc_"))
232
+ )
@@ -19,6 +19,7 @@ const AudioBuses = preload("audio_buses.gd")
19
19
  const ClassCache = preload("class_cache.gd")
20
20
  const ClassDbQueries = preload("classdb_queries.gd")
21
21
  const Dependencies = preload("dependencies.gd")
22
+ const ExportPresets = preload("export_presets.gd")
22
23
  const FileReads = preload("file_reads.gd")
23
24
  const GdscriptAnalysis = preload("gdscript_analysis.gd")
24
25
  const GdscriptAuthoring = preload("gdscript_authoring.gd")
@@ -179,7 +180,9 @@ func _run(operation: String, params: Dictionary) -> Dictionary:
179
180
  "set_import_options":
180
181
  payload = ImportPipeline.new(_log).set_import_options(params)
181
182
  "list_export_presets":
182
- payload = ImportPipeline.new(_log).list_export_presets(params)
183
+ payload = ExportPresets.new(_log).list_export_presets(params)
184
+ "add_export_preset":
185
+ payload = ExportPresets.new(_log).add_export_preset(params)
183
186
  "validate_project":
184
187
  payload = ImportPipeline.new(_log).validate_project(params)
185
188
 
@@ -1,5 +1,6 @@
1
1
  extends RefCounted
2
2
 
3
+ const ExportPresets = preload("export_presets.gd")
3
4
  const Read = preload("reading.gd")
4
5
  const Serialisation = preload("serialisation.gd")
5
6
  const FileWalk = preload("file_walk.gd")
@@ -319,63 +320,6 @@ static func _all_ints(held: Variant, keys: bool) -> bool:
319
320
  return true
320
321
 
321
322
 
322
- # The names of the project's export presets, in the order export_presets.cfg holds them.
323
- static func _preset_names() -> Array[String]:
324
- var names: Array[String] = []
325
- var config: ConfigFile = ConfigFile.new()
326
- if config.load("res://export_presets.cfg") != OK:
327
- return names
328
- var index: int = 0
329
- while config.has_section("preset." + str(index)):
330
- names.append(str(config.get_value("preset." + str(index), "name", "")))
331
- index += 1
332
- return names
333
-
334
-
335
- # List export presets
336
- func list_export_presets(_params: Dictionary) -> Dictionary:
337
- _log.info("Listing export presets")
338
-
339
- var presets_file: String = "res://export_presets.cfg"
340
-
341
- if not FileAccess.file_exists(presets_file):
342
- return {
343
- "presets": [],
344
- "presets_file_exists": false,
345
- "note": "No export_presets.cfg found. Configure export presets in the Godot editor."
346
- }
347
-
348
- var config: ConfigFile = ConfigFile.new()
349
- var err: Error = config.load(presets_file)
350
-
351
- if err != OK:
352
- return _log.failure("Failed to parse export_presets.cfg: " + str(err))
353
-
354
- # Export presets are stored as [preset.0], [preset.1], etc.
355
- var presets: Array[Dictionary] = []
356
- var preset_idx: int = 0
357
- while config.has_section("preset." + str(preset_idx)):
358
- var section: String = "preset." + str(preset_idx)
359
- var preset: Dictionary = {
360
- "index": preset_idx,
361
- "name": config.get_value(section, "name", "Unknown"),
362
- "platform": config.get_value(section, "platform", "Unknown"),
363
- "runnable": config.get_value(section, "runnable", false),
364
- "export_path": config.get_value(section, "export_path", ""),
365
- "export_filter": config.get_value(section, "export_filter", "all_resources"),
366
- "include_filter": config.get_value(section, "include_filter", ""),
367
- "exclude_filter": config.get_value(section, "exclude_filter", "")
368
- }
369
-
370
- if config.has_section_key(section, "custom_features"):
371
- preset["custom_features"] = config.get_value(section, "custom_features", "")
372
-
373
- presets.append(preset)
374
- preset_idx += 1
375
-
376
- return {"presets": presets, "presets_file_exists": true, "total_presets": preset_idx}
377
-
378
-
379
323
  # Validate project for export
380
324
  func validate_project(params: Dictionary) -> Dictionary:
381
325
  var preset_name: String = str(params.get("preset", ""))
@@ -429,7 +373,7 @@ func validate_project(params: Dictionary) -> Dictionary:
429
373
  "warning",
430
374
  "export_presets",
431
375
  "No export presets configured",
432
- "Configure export presets in Godot editor: Project > Export",
376
+ "Add a preset with project_export add_preset, or in the editor: Project > Export",
433
377
  include_suggestions
434
378
  )
435
379
  )
@@ -438,7 +382,7 @@ func validate_project(params: Dictionary) -> Dictionary:
438
382
  # does not exist answered valid, as though it had been checked.
439
383
  if not preset_name.is_empty():
440
384
  checks_performed.append("export_preset")
441
- var named: Array[String] = _preset_names()
385
+ var named: Array[String] = ExportPresets.names()
442
386
  if preset_name not in named:
443
387
  issues.append(
444
388
  _finding(
@@ -453,7 +397,7 @@ func validate_project(params: Dictionary) -> Dictionary:
453
397
  else ": the presets are " + ", ".join(named)
454
398
  )
455
399
  ),
456
- "Pass one of the preset names, or add the preset in the editor: Project > Export",
400
+ "Pass one of the preset names, or add the preset with project_export add_preset",
457
401
  include_suggestions
458
402
  )
459
403
  )
@@ -148,7 +148,9 @@ func get_project_health(params: Dictionary) -> Dictionary:
148
148
  recommendations.append("Address critical issues to improve project stability")
149
149
  # A project still being made has no presets yet, which is no fault in it.
150
150
  if "config" in check_categories and not FileAccess.file_exists("res://export_presets.cfg"):
151
- recommendations.append("No export presets are configured: add them before exporting")
151
+ recommendations.append(
152
+ "No export presets are configured: project_export add_preset adds one before exporting"
153
+ )
152
154
 
153
155
  # Every detail costs points, so each is an issue; `issues` was always empty beside them, and a
154
156
  # category said passed next to the failure it had just found.
package/build/index.js CHANGED
@@ -28717,9 +28717,14 @@ class GameLog {
28717
28717
  const owner = this.lastHeadline[source];
28718
28718
  if (owner !== null && /^\s/.test(line)) {
28719
28719
  owner.detail.push(line.trim());
28720
+ owner.continues = false;
28720
28721
  return;
28721
28722
  }
28722
28723
  const headline = announced(line);
28724
+ if (owner?.continues === true && headline === null) {
28725
+ owner.detail.push(line.trim());
28726
+ return;
28727
+ }
28723
28728
  const detail = [];
28724
28729
  const entry = {
28725
28730
  index: this.entries.length,
@@ -28732,7 +28737,7 @@ class GameLog {
28732
28737
  if (headline?.byScript === true) {
28733
28738
  this.byScript.add(entry.index);
28734
28739
  }
28735
- this.lastHeadline[source] = headline ? { detail } : null;
28740
+ this.lastHeadline[source] = headline ? { detail, continues: source === "stderr" && headline.text.endsWith(":") } : null;
28736
28741
  }
28737
28742
  raisedByScript(entry) {
28738
28743
  return this.byScript.has(entry.index);
@@ -33314,7 +33319,7 @@ var HEADLESS_OPERATIONS = dictionary({
33314
33319
  uid: "get_uid",
33315
33320
  refresh_classes: "refresh_class_cache"
33316
33321
  }),
33317
- project_export: dictionary({ list: "list_export_presets" }),
33322
+ project_export: dictionary({ list: "list_export_presets", add_preset: "add_export_preset" }),
33318
33323
  script_edit: dictionary({ create: "create_script", modify: "modify_script" }),
33319
33324
  script_info: dictionary({ structure: "get_script_info" }),
33320
33325
  editor_classes: dictionary({
@@ -35290,20 +35295,36 @@ var TOOL_SPECS = [
35290
35295
  {
35291
35296
  name: "project_export",
35292
35297
  title: "Export",
35293
- description: "Export presets and exports.",
35298
+ description: "Export presets and exports. add_preset writes a preset to export_presets.cfg with the keys the editor's Project > Export writes for a new one, every platform option left at its default, and makes it the platform's runnable preset when the platform has none. It answers where this engine's export templates belong and whether the preset's platform has them, since an export needs them; list says the same for every preset. An editor open on the project does not see a preset added this way until it restarts. run names, as alsoWritten, what else the export wrote beside its output, such as the .pck a Windows or Linux build cannot start without.",
35294
35299
  parameters: {
35295
35300
  projectPath: PROJECT_PATH2,
35296
- preset: { type: "string", description: "Preset name from export_presets.cfg." },
35301
+ preset: {
35302
+ type: "string",
35303
+ ops: ["run", "add_preset"],
35304
+ description: "Preset name from export_presets.cfg. add_preset: the name of the new one."
35305
+ },
35306
+ platform: {
35307
+ type: "string",
35308
+ ops: ["add_preset"],
35309
+ enum: ["Windows Desktop", "Linux", "macOS", "Android", "iOS", "Web"],
35310
+ description: "add_preset: the platform the preset exports for."
35311
+ },
35297
35312
  outputPath: {
35298
35313
  type: "string",
35299
- description: `Where the export is written, inside the project. The directory is created if it is not there: Godot's command-line exporter refuses a missing one with "The given export path doesn't exist", which reads as a wrong path in the preset.`
35314
+ ops: ["run", "add_preset"],
35315
+ description: `Where the export is written, inside the project. The directory is created if it is not there: Godot's command-line exporter refuses a missing one with "The given export path doesn't exist", which reads as a wrong path in the preset. add_preset: the path the preset exports to from the editor, optional.`
35300
35316
  },
35301
- debug: { type: "boolean", description: "run: a debug export. Default false." }
35317
+ debug: { type: "boolean", ops: ["run"], description: "run: a debug export. Default false." }
35302
35318
  },
35303
35319
  requires: ["projectPath"],
35304
35320
  operations: {
35305
35321
  list: { effect: "read", summary: "the presets in export_presets.cfg", requires: [] },
35306
- run: { effect: "destructive", summary: "export with a preset", requires: ["preset", "outputPath"] }
35322
+ run: { effect: "destructive", summary: "export with a preset", requires: ["preset", "outputPath"] },
35323
+ add_preset: {
35324
+ effect: "additive",
35325
+ summary: "add an export preset to export_presets.cfg",
35326
+ requires: ["preset", "platform"]
35327
+ }
35307
35328
  }
35308
35329
  },
35309
35330
  {
@@ -37565,7 +37586,7 @@ function exportPresetRefusal(projectPath, preset) {
37565
37586
  try {
37566
37587
  text = readFileSync24(join26(projectPath, "export_presets.cfg"), "utf8");
37567
37588
  } catch {
37568
- return `This project has no export_presets.cfg, so it has no preset '${preset}' to export with. The editor writes the file when a preset is added in Project > Export.`;
37589
+ return `This project has no export_presets.cfg, so it has no preset '${preset}' to export with. project_export add_preset writes one, as the editor's Project > Export does.`;
37569
37590
  }
37570
37591
  const names = [];
37571
37592
  let inPreset = false;
@@ -37583,9 +37604,9 @@ function exportPresetRefusal(projectPath, preset) {
37583
37604
  if (names.includes(preset)) {
37584
37605
  return null;
37585
37606
  }
37586
- return names.length === 0 ? `export_presets.cfg names no preset, so there is no '${preset}' to export with. The editor adds one in Project > Export.` : `This project has no export preset named '${preset}'. Its presets are ${names.map((name) => `'${name}'`).join(", ")}.`;
37607
+ return names.length === 0 ? `export_presets.cfg names no preset, so there is no '${preset}' to export with. project_export add_preset adds one.` : `This project has no export preset named '${preset}'. Its presets are ${names.map((name) => `'${name}'`).join(", ")}.`;
37587
37608
  }
37588
- function exportAnswer(ending, asked, before, after) {
37609
+ function exportAnswer(ending, asked, before, after, beside = []) {
37589
37610
  const { log } = ending;
37590
37611
  const written = after !== null && after !== before;
37591
37612
  const notCopied = librariesNotCopied(log.all);
@@ -37597,6 +37618,7 @@ function exportAnswer(ending, asked, before, after) {
37597
37618
  failure: ending.failure ?? undefined,
37598
37619
  errors: log.count("error"),
37599
37620
  warnings: log.count("warning"),
37621
+ ...beside.length === 0 ? {} : { alsoWritten: beside },
37600
37622
  ...notCopied.length === 0 ? {} : {
37601
37623
  librariesNotLoaded: notCopied,
37602
37624
  extensionNote: `The export could not load ${notCopied.join(" and ")}: on Windows an engine running as the editor, which an export is, loads a GDExtension from a ~ copy beside the library, and another engine on this project held that copy: it could not be moved aside before the export, or another engine made a new one in between. Export again once that engine has loaded or exited.`
@@ -37619,6 +37641,20 @@ function exportAnswer(ending, asked, before, after) {
37619
37641
  isError: true
37620
37642
  };
37621
37643
  }
37644
+ function writtenBeside(before, after, output, within) {
37645
+ return [...after].filter(([name, at]) => name !== output && before.get(name) !== at).map(([name]) => within === "." ? name : `${within}/${name}`).sort();
37646
+ }
37647
+ function filesIn(folder) {
37648
+ const found = new Map;
37649
+ try {
37650
+ for (const entry of readdirSync11(folder, { withFileTypes: true })) {
37651
+ if (entry.isFile()) {
37652
+ found.set(entry.name, statSync12(join26(folder, entry.name)).mtimeMs);
37653
+ }
37654
+ }
37655
+ } catch {}
37656
+ return found;
37657
+ }
37622
37658
  function scanWaitAnswer(scanned, started = "game") {
37623
37659
  if (scanned.waitedMs === 0) {
37624
37660
  return {};
@@ -37856,6 +37892,9 @@ var PATH_SOLUTIONS = [
37856
37892
  'Give the path relative to the project, such as "scenes/main.tscn" or "res://scenes/main.tscn"',
37857
37893
  "Point projectPath at the project the file belongs to"
37858
37894
  ];
37895
+ var OUTPUT_PATH_SOLUTIONS = [
37896
+ 'Give outputPath inside the project, relative to it, such as "build/game.exe" or "res://build/game.exe"; copy the build elsewhere once it is exported'
37897
+ ];
37859
37898
  var PROJECT_FILE_ARGUMENTS = [
37860
37899
  "scenePath",
37861
37900
  "instancePath",
@@ -38910,6 +38949,9 @@ class GodotServer {
38910
38949
  if (tool === "project_import" && op === "set_options") {
38911
38950
  return await this.handleSetImportOptions(args);
38912
38951
  }
38952
+ if (tool === "project_export" && op === "add_preset") {
38953
+ return await this.handleAddExportPreset(args);
38954
+ }
38913
38955
  const headless = HEADLESS_OPERATIONS[tool]?.[op];
38914
38956
  if (headless !== undefined) {
38915
38957
  const { from: asked, ...answerable } = args;
@@ -39973,6 +40015,30 @@ class GodotServer {
39973
40015
  return this.createErrorResponse(`Search failed: ${errorMessage(error)}`);
39974
40016
  }
39975
40017
  }
40018
+ async handleAddExportPreset(args) {
40019
+ const project = this.project(args);
40020
+ if (!project.ok) {
40021
+ return project.response;
40022
+ }
40023
+ const given = readNonEmptyString(args, "outputPath");
40024
+ const output = given === undefined ? null : resolveWithinProject(project.value.path, given);
40025
+ if (output !== null && !output.ok) {
40026
+ return this.createErrorResponse(output.reason, OUTPUT_PATH_SOLUTIONS);
40027
+ }
40028
+ const outcome = await this.operation("add_export_preset", {
40029
+ preset: readString(args, "preset") ?? "",
40030
+ platform: readString(args, "platform") ?? "",
40031
+ outputPath: output?.relativePath ?? ""
40032
+ }, project.value.path);
40033
+ if (!outcome.ok || !this.editorHasOpen(project.value.path)) {
40034
+ return this.answer(outcome);
40035
+ }
40036
+ return this.jsonTextResponse({
40037
+ ...outcome.payload,
40038
+ editorNote: "The editor open on this project read export_presets.cfg when it started and does not list this preset, and saving presets in its Export dialog writes its own list over the file. editor_launch restart loads the file; an export through project_export run reads the file and needs no restart.",
40039
+ ...engineExtras(outcome)
40040
+ });
40041
+ }
39976
40042
  async handleExportProject(args) {
39977
40043
  const project = this.project(args);
39978
40044
  if (!project.ok) {
@@ -39981,11 +40047,14 @@ class GodotServer {
39981
40047
  const preset = readNonEmptyString(args, "preset") ?? "";
39982
40048
  const output = resolveWithinProject(project.value.path, readString(args, "outputPath") ?? "");
39983
40049
  if (!output.ok) {
39984
- return this.createErrorResponse(output.reason, PATH_SOLUTIONS);
40050
+ return this.createErrorResponse(output.reason, OUTPUT_PATH_SOLUTIONS);
39985
40051
  }
39986
40052
  const noPreset = exportPresetRefusal(project.value.path, preset);
39987
40053
  if (noPreset !== null) {
39988
- return this.createErrorResponse(noPreset, ["project_export list reads the presets the project has"]);
40054
+ return this.createErrorResponse(noPreset, [
40055
+ "project_export list reads the presets the project has",
40056
+ "project_export add_preset adds one"
40057
+ ]);
39989
40058
  }
39990
40059
  const engine = await this.engine();
39991
40060
  if (!engine.ok) {
@@ -40012,6 +40081,7 @@ class GodotServer {
40012
40081
  this.logDebug(`Exporting: ${engine.value} ${exportArgs.join(" ")}`);
40013
40082
  const writtenAt = () => existsSync20(output.absolutePath) ? statSync12(output.absolutePath).mtimeMs : null;
40014
40083
  const before = writtenAt();
40084
+ const listedBefore = filesIn(folder);
40015
40085
  const freed = freeLibraryCopies(project.value.path);
40016
40086
  if (freed.setAside.length > 0 || freed.swept.length > 0) {
40017
40087
  this.logDebug(`Before the export: set aside ${freed.setAside.join(", ")}; swept ${freed.swept.join(", ")}`);
@@ -40025,7 +40095,8 @@ class GodotServer {
40025
40095
  if (ending.exitSignal === null && ending.exitCode === null) {
40026
40096
  return this.createErrorResponse(`Export could not be run: ${ending.failure ?? "the engine gave no exit status"}`);
40027
40097
  }
40028
- return exportAnswer(ending, { preset, outputPath: output.relativePath, debug }, before, writtenAt());
40098
+ const beside = writtenBeside(listedBefore, filesIn(folder), basename7(output.absolutePath), dirname18(output.relativePath).replaceAll("\\", "/"));
40099
+ return exportAnswer(ending, { preset, outputPath: output.relativePath, debug }, before, writtenAt(), beside);
40029
40100
  }
40030
40101
  reportTestProgress(log) {
40031
40102
  const progress = callProgress();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.2.34",
3
+ "version": "1.2.35",
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",