@beremaran/godot-agent-loop 1.0.0
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/LICENSE +23 -0
- package/README.md +925 -0
- package/addons/godot_agent_loop/LICENSE +23 -0
- package/addons/godot_agent_loop/README.md +31 -0
- package/addons/godot_agent_loop/plugin.cfg +8 -0
- package/addons/godot_agent_loop/plugin.gd +417 -0
- package/agent-plugin/.claude-plugin/plugin.json +19 -0
- package/agent-plugin/.codex-plugin/plugin.json +37 -0
- package/agent-plugin/.mcp.json +14 -0
- package/agent-plugin/adapter-manifest.json +45 -0
- package/agent-plugin/pi/extension.ts +136 -0
- package/agent-plugin/skills/build-godot-game/SKILL.md +41 -0
- package/agent-plugin/skills/debug-godot-game/SKILL.md +37 -0
- package/agent-plugin/skills/ship-godot-game/SKILL.md +46 -0
- package/agent-plugin/skills/ship-godot-game/agents/openai.yaml +4 -0
- package/agent-plugin/skills/verify-godot-change/SKILL.md +35 -0
- package/build/authoring-session-manager.js +237 -0
- package/build/domain-tool-registries.js +207 -0
- package/build/editor-bridge-protocol.js +1 -0
- package/build/editor-connection.js +112 -0
- package/build/editor-mutation-guard.js +30 -0
- package/build/editor-plugin-installer.js +220 -0
- package/build/engine-api/extension_api-4.7.stable.official.5b4e0cb0f.json +356830 -0
- package/build/game-command-service.js +49 -0
- package/build/game-connection.js +337 -0
- package/build/godot-executable.js +156 -0
- package/build/godot-process-manager.js +112 -0
- package/build/godot-subprocess.js +23 -0
- package/build/headless-operation-runner.js +68 -0
- package/build/headless-operation-service.js +65 -0
- package/build/index.js +478 -0
- package/build/interaction-server-installer.js +234 -0
- package/build/opencode-setup.js +199 -0
- package/build/project-support.js +219 -0
- package/build/runtime-protocol.js +202 -0
- package/build/scripts/godot_operations.gd +1534 -0
- package/build/scripts/mcp_editor_plugin.gd +417 -0
- package/build/scripts/mcp_interaction_server.gd +1255 -0
- package/build/scripts/mcp_runtime/audio_animation_domain.gd +568 -0
- package/build/scripts/mcp_runtime/command_params.gd +339 -0
- package/build/scripts/mcp_runtime/core_domain.gd +591 -0
- package/build/scripts/mcp_runtime/input_domain.gd +695 -0
- package/build/scripts/mcp_runtime/networking_domain.gd +356 -0
- package/build/scripts/mcp_runtime/physics_domain.gd +653 -0
- package/build/scripts/mcp_runtime/privileged_command_policy.gd +81 -0
- package/build/scripts/mcp_runtime/rendering_domain.gd +807 -0
- package/build/scripts/mcp_runtime/runtime_domain.gd +108 -0
- package/build/scripts/mcp_runtime/scene_2d_domain.gd +527 -0
- package/build/scripts/mcp_runtime/scene_3d_domain.gd +573 -0
- package/build/scripts/mcp_runtime/system_domain.gd +277 -0
- package/build/scripts/mcp_runtime/ui_domain.gd +619 -0
- package/build/scripts/mcp_runtime/variant_codec.gd +335 -0
- package/build/scripts/validate_script.gd +28 -0
- package/build/server-instructions.js +11 -0
- package/build/session-timing.js +20 -0
- package/build/tool-argument-validation.js +120 -0
- package/build/tool-definitions.js +2727 -0
- package/build/tool-handlers/game-tool-handlers.js +1345 -0
- package/build/tool-handlers/lifecycle-tool-handlers.js +346 -0
- package/build/tool-handlers/project-handler-services.js +1458 -0
- package/build/tool-handlers/project-tool-handlers.js +1506 -0
- package/build/tool-handlers/visual-regression-service.js +139 -0
- package/build/tool-manifest.js +1193 -0
- package/build/tool-mutation-policy.js +122 -0
- package/build/tool-registry.js +34 -0
- package/build/tool-surface.js +70 -0
- package/build/utils.js +273 -0
- package/package.json +110 -0
- package/product.json +52 -0
- package/scripts/prepare-package.js +42 -0
|
@@ -0,0 +1,1458 @@
|
|
|
1
|
+
import { dirname, extname, isAbsolute, join, relative, resolve } from 'path';
|
|
2
|
+
import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'fs';
|
|
3
|
+
import { createHash, randomUUID } from 'crypto';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import { createErrorResponse, errorMessage, normalizeParameters, validatePath } from '../utils.js';
|
|
6
|
+
import { execFile } from 'child_process';
|
|
7
|
+
import { promisify } from 'util';
|
|
8
|
+
import { GODOT_EXPORT_OPTIONS } from '../godot-subprocess.js';
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
function projectFile(projectPath) {
|
|
11
|
+
return join(projectPath, 'project.godot');
|
|
12
|
+
}
|
|
13
|
+
function projectRelativePath(context, projectPath, relativePath) {
|
|
14
|
+
const resolved = context.pathSecurity.resolveProjectPath(projectPath, relativePath);
|
|
15
|
+
if (!resolved)
|
|
16
|
+
throw new Error(`Path is outside the project: ${relativePath}`);
|
|
17
|
+
return resolved;
|
|
18
|
+
}
|
|
19
|
+
function validProject(context, projectPath) {
|
|
20
|
+
return typeof projectPath === 'string'
|
|
21
|
+
&& context.pathSecurity.isProjectPathAllowed(projectPath)
|
|
22
|
+
&& existsSync(projectFile(projectPath));
|
|
23
|
+
}
|
|
24
|
+
/** Owns project-relative file operations and their security checks. */
|
|
25
|
+
export class ProjectFileIOService {
|
|
26
|
+
context;
|
|
27
|
+
constructor(context) {
|
|
28
|
+
this.context = context;
|
|
29
|
+
}
|
|
30
|
+
async read(args) {
|
|
31
|
+
args = normalizeParameters(args || {});
|
|
32
|
+
if (!args.projectPath || !args.filePath)
|
|
33
|
+
return createErrorResponse('projectPath and filePath are required.');
|
|
34
|
+
if (!validProject(this.context, args.projectPath) || !validatePath(args.filePath))
|
|
35
|
+
return createErrorResponse('Invalid path.');
|
|
36
|
+
try {
|
|
37
|
+
const fullPath = projectRelativePath(this.context, args.projectPath, args.filePath);
|
|
38
|
+
if (!existsSync(fullPath))
|
|
39
|
+
return createErrorResponse(`File does not exist: ${args.filePath}`);
|
|
40
|
+
return { content: [{ type: 'text', text: readFileSync(fullPath, 'utf8') }] };
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
return createErrorResponse(`Failed to read file: ${errorMessage(error)}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async write(args) {
|
|
47
|
+
args = normalizeParameters(args || {});
|
|
48
|
+
if (!args.projectPath || !args.filePath || args.content === undefined)
|
|
49
|
+
return createErrorResponse('projectPath, filePath, and content are required.');
|
|
50
|
+
if (!validProject(this.context, args.projectPath) || !validatePath(args.filePath))
|
|
51
|
+
return createErrorResponse('Invalid path.');
|
|
52
|
+
try {
|
|
53
|
+
const fullPath = projectRelativePath(this.context, args.projectPath, args.filePath);
|
|
54
|
+
mkdirSync(dirname(fullPath), { recursive: true });
|
|
55
|
+
writeFileSync(fullPath, args.content, 'utf8');
|
|
56
|
+
return { content: [{ type: 'text', text: `File written: ${args.filePath}` }] };
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
return createErrorResponse(`Failed to write file: ${errorMessage(error)}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async delete(args) {
|
|
63
|
+
args = normalizeParameters(args || {});
|
|
64
|
+
if (!args.projectPath || !args.filePath)
|
|
65
|
+
return createErrorResponse('projectPath and filePath are required.');
|
|
66
|
+
if (!validProject(this.context, args.projectPath) || !validatePath(args.filePath))
|
|
67
|
+
return createErrorResponse('Invalid path.');
|
|
68
|
+
try {
|
|
69
|
+
const fullPath = projectRelativePath(this.context, args.projectPath, args.filePath);
|
|
70
|
+
if (!existsSync(fullPath))
|
|
71
|
+
return createErrorResponse(`File does not exist: ${args.filePath}`);
|
|
72
|
+
unlinkSync(fullPath);
|
|
73
|
+
return { content: [{ type: 'text', text: `File deleted: ${args.filePath}` }] };
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
return createErrorResponse(`Failed to delete file: ${errorMessage(error)}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async createDirectory(args) {
|
|
80
|
+
args = normalizeParameters(args || {});
|
|
81
|
+
if (!args.projectPath || !args.directoryPath)
|
|
82
|
+
return createErrorResponse('projectPath and directoryPath are required.');
|
|
83
|
+
if (!validProject(this.context, args.projectPath) || !validatePath(args.directoryPath))
|
|
84
|
+
return createErrorResponse('Invalid path.');
|
|
85
|
+
try {
|
|
86
|
+
mkdirSync(projectRelativePath(this.context, args.projectPath, args.directoryPath), { recursive: true });
|
|
87
|
+
return { content: [{ type: 'text', text: `Directory created: ${args.directoryPath}` }] };
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
return createErrorResponse(`Failed to create directory: ${errorMessage(error)}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async rename(args) {
|
|
94
|
+
args = normalizeParameters(args || {});
|
|
95
|
+
if (!args.projectPath || !args.filePath || !args.newPath)
|
|
96
|
+
return createErrorResponse('projectPath, filePath, and newPath are required.');
|
|
97
|
+
if (!validProject(this.context, args.projectPath) || !validatePath(args.filePath) || !validatePath(args.newPath))
|
|
98
|
+
return createErrorResponse('Invalid path.');
|
|
99
|
+
try {
|
|
100
|
+
const source = projectRelativePath(this.context, args.projectPath, args.filePath);
|
|
101
|
+
if (!existsSync(source))
|
|
102
|
+
return createErrorResponse(`File not found: ${args.filePath}`);
|
|
103
|
+
const destination = projectRelativePath(this.context, args.projectPath, args.newPath);
|
|
104
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
105
|
+
renameSync(source, destination);
|
|
106
|
+
return { content: [{ type: 'text', text: `Renamed ${args.filePath} → ${args.newPath}` }] };
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
return createErrorResponse(`rename_file failed: ${errorMessage(error)}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Owns direct reads and writes to project.godot settings. */
|
|
114
|
+
export class ProjectConfigurationService {
|
|
115
|
+
context;
|
|
116
|
+
constructor(context) {
|
|
117
|
+
this.context = context;
|
|
118
|
+
}
|
|
119
|
+
async read(args) {
|
|
120
|
+
args = normalizeParameters(args || {});
|
|
121
|
+
if (!args.projectPath)
|
|
122
|
+
return createErrorResponse('projectPath is required.');
|
|
123
|
+
if (!validProject(this.context, args.projectPath))
|
|
124
|
+
return createErrorResponse('Invalid path.');
|
|
125
|
+
try {
|
|
126
|
+
const sections = {};
|
|
127
|
+
let currentSection = '';
|
|
128
|
+
for (const line of readFileSync(projectFile(args.projectPath), 'utf8').split('\n')) {
|
|
129
|
+
const trimmed = line.trim();
|
|
130
|
+
if (!trimmed || trimmed.startsWith(';'))
|
|
131
|
+
continue;
|
|
132
|
+
const section = /^\[(.+)\]$/.exec(trimmed);
|
|
133
|
+
if (section) {
|
|
134
|
+
currentSection = section[1];
|
|
135
|
+
sections[currentSection] ??= {};
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const setting = /^([^=]+)=(.*)$/.exec(trimmed);
|
|
139
|
+
if (setting && currentSection)
|
|
140
|
+
sections[currentSection][setting[1].trim()] = setting[2].trim();
|
|
141
|
+
}
|
|
142
|
+
return { content: [{ type: 'text', text: JSON.stringify(sections, null, 2) }] };
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
return createErrorResponse(`Failed to read project settings: ${errorMessage(error)}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async modify(args) {
|
|
149
|
+
args = normalizeParameters(args || {});
|
|
150
|
+
if (!args.projectPath || !args.section || !args.key || args.value === undefined)
|
|
151
|
+
return createErrorResponse('projectPath, section, key, and value are required.');
|
|
152
|
+
if (!validProject(this.context, args.projectPath))
|
|
153
|
+
return createErrorResponse('Invalid path.');
|
|
154
|
+
try {
|
|
155
|
+
let content = readFileSync(projectFile(args.projectPath), 'utf8');
|
|
156
|
+
const header = `[${args.section}]`;
|
|
157
|
+
const setting = `${args.key}=${args.value}`;
|
|
158
|
+
const index = content.indexOf(header);
|
|
159
|
+
if (index === -1)
|
|
160
|
+
content += `\n\n${header}\n\n${setting}\n`;
|
|
161
|
+
else {
|
|
162
|
+
const end = content.indexOf('\n[', index + header.length);
|
|
163
|
+
const section = content.slice(index, end === -1 ? undefined : end);
|
|
164
|
+
const keyPattern = new RegExp(`^${args.key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=.*$`, 'm');
|
|
165
|
+
const updated = keyPattern.test(section) ? section.replace(keyPattern, setting) : `${section}\n${setting}`;
|
|
166
|
+
content = content.slice(0, index) + updated + (end === -1 ? '' : content.slice(end));
|
|
167
|
+
}
|
|
168
|
+
writeFileSync(projectFile(args.projectPath), content, 'utf8');
|
|
169
|
+
return { content: [{ type: 'text', text: `Setting updated: [${args.section}] ${args.key}=${args.value}` }] };
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
return createErrorResponse(`Failed to modify project settings: ${errorMessage(error)}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/** Owns GDScript validation and keeps batch limits in one place. */
|
|
177
|
+
export class ScriptValidationService {
|
|
178
|
+
context;
|
|
179
|
+
constructor(context) {
|
|
180
|
+
this.context = context;
|
|
181
|
+
}
|
|
182
|
+
async validate(args) {
|
|
183
|
+
args = normalizeParameters(args || {});
|
|
184
|
+
if (!args.projectPath || !args.scriptPath)
|
|
185
|
+
return createErrorResponse('projectPath and scriptPath are required.');
|
|
186
|
+
if (!validProject(this.context, args.projectPath) || !validatePath(args.scriptPath))
|
|
187
|
+
return createErrorResponse('Invalid path.');
|
|
188
|
+
if (!/\.gd$/i.test(args.scriptPath))
|
|
189
|
+
return createErrorResponse('validate_script only checks GDScript (.gd) files.');
|
|
190
|
+
const scriptPath = projectRelativePath(this.context, args.projectPath, args.scriptPath);
|
|
191
|
+
if (!existsSync(scriptPath))
|
|
192
|
+
return createErrorResponse(`Script does not exist: ${args.scriptPath}`);
|
|
193
|
+
if (!this.context.executable.path)
|
|
194
|
+
await this.context.executable.detect();
|
|
195
|
+
if (!this.context.executable.path)
|
|
196
|
+
return createErrorResponse('Could not find a valid Godot executable path');
|
|
197
|
+
const check = await this.context.projectSupport.runGdScriptCheck(args.projectPath, scriptPath);
|
|
198
|
+
if (!check.completed)
|
|
199
|
+
return createErrorResponse(`validate_script could not check the script; ${check.error}`);
|
|
200
|
+
return { content: [{ type: 'text', text: JSON.stringify({ valid: check.errors.length === 0, scriptPath: args.scriptPath, errorCount: check.errors.length, errors: check.errors }, null, 2) }] };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/** Owns invocation of Godot's export command. */
|
|
204
|
+
export class ProjectExportService {
|
|
205
|
+
context;
|
|
206
|
+
constructor(context) {
|
|
207
|
+
this.context = context;
|
|
208
|
+
}
|
|
209
|
+
async export(args) {
|
|
210
|
+
args = normalizeParameters(args || {});
|
|
211
|
+
if (!args.projectPath || !args.presetName || !args.outputPath)
|
|
212
|
+
return createErrorResponse('projectPath, presetName, and outputPath are required.');
|
|
213
|
+
if (!validProject(this.context, args.projectPath))
|
|
214
|
+
return createErrorResponse('Invalid project path.');
|
|
215
|
+
const outputPath = isAbsolute(args.outputPath)
|
|
216
|
+
? (this.context.pathSecurity.isProjectPathAllowed(args.outputPath, true) ? resolve(args.outputPath) : null)
|
|
217
|
+
: this.context.pathSecurity.resolveProjectPath(args.projectPath, args.outputPath);
|
|
218
|
+
if (!outputPath)
|
|
219
|
+
return createErrorResponse('Invalid output path.');
|
|
220
|
+
if (!this.context.executable.path)
|
|
221
|
+
await this.context.executable.detect();
|
|
222
|
+
if (!this.context.executable.path)
|
|
223
|
+
return createErrorResponse('Could not find Godot executable.');
|
|
224
|
+
try {
|
|
225
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
226
|
+
const flag = args.debug ? '--export-debug' : '--export-release';
|
|
227
|
+
const { stdout } = await execFileAsync(this.context.executable.path, ['--headless', '--path', args.projectPath, flag, args.presetName, outputPath], GODOT_EXPORT_OPTIONS);
|
|
228
|
+
return { content: [{ type: 'text', text: `Export succeeded.\n\nOutput: ${stdout || outputPath}` }] };
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
if (error instanceof Error && 'code' in error) {
|
|
232
|
+
const processError = error;
|
|
233
|
+
if (typeof processError.code !== 'number' && !processError.signal) {
|
|
234
|
+
return createErrorResponse(`Export failed: ${errorMessage(error)}`);
|
|
235
|
+
}
|
|
236
|
+
const status = processError.signal
|
|
237
|
+
? `terminated by signal ${processError.signal}`
|
|
238
|
+
: `exited with code ${processError.code}`;
|
|
239
|
+
const output = processError.stderr || processError.stdout;
|
|
240
|
+
return createErrorResponse(`Export failed (${status})${output ? `: ${output}` : '.'}`);
|
|
241
|
+
}
|
|
242
|
+
return createErrorResponse(`Export failed: ${errorMessage(error)}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/** Discovers and runs bounded project-native, GUT, and GdUnit4 test workflows. */
|
|
247
|
+
export class ProjectTestService {
|
|
248
|
+
context;
|
|
249
|
+
constructor(context) {
|
|
250
|
+
this.context = context;
|
|
251
|
+
}
|
|
252
|
+
async execute(args) {
|
|
253
|
+
args = normalizeParameters(args || {});
|
|
254
|
+
if (!args.projectPath || !args.action)
|
|
255
|
+
return createErrorResponse('projectPath and action are required.');
|
|
256
|
+
if (!validProject(this.context, args.projectPath))
|
|
257
|
+
return createErrorResponse('Invalid project path.');
|
|
258
|
+
const discovery = this.discover(args.projectPath);
|
|
259
|
+
if (args.action === 'discover') {
|
|
260
|
+
return { content: [{ type: 'text', text: JSON.stringify(discovery, null, 2) }] };
|
|
261
|
+
}
|
|
262
|
+
if (!this.context.executable.path)
|
|
263
|
+
await this.context.executable.detect();
|
|
264
|
+
const godot = this.context.executable.path;
|
|
265
|
+
if (!godot)
|
|
266
|
+
return createErrorResponse('Could not find Godot executable.');
|
|
267
|
+
const requestedFramework = args.framework ?? 'auto';
|
|
268
|
+
const framework = requestedFramework === 'auto'
|
|
269
|
+
? (discovery.frameworks.gut ? 'gut' : discovery.frameworks.gdunit4 ? 'gdunit4' : 'native')
|
|
270
|
+
: requestedFramework;
|
|
271
|
+
const paths = this.resolveTestPaths(args.projectPath, args.testPaths, discovery.native_tests);
|
|
272
|
+
if ('error' in paths)
|
|
273
|
+
return createErrorResponse(paths.error);
|
|
274
|
+
const artifacts = this.resolveArtifactPaths(args.projectPath, args.artifactPaths);
|
|
275
|
+
if ('error' in artifacts)
|
|
276
|
+
return createErrorResponse(artifacts.error);
|
|
277
|
+
const timeoutMs = Math.round((args.timeoutSeconds ?? 60) * 1000);
|
|
278
|
+
if (framework === 'gut') {
|
|
279
|
+
if (!discovery.frameworks.gut)
|
|
280
|
+
return createErrorResponse('GUT is not installed at addons/gut/gut_cmdln.gd.');
|
|
281
|
+
return this.runGut(godot, args.projectPath, paths.paths, artifacts.paths, timeoutMs);
|
|
282
|
+
}
|
|
283
|
+
if (framework === 'gdunit4') {
|
|
284
|
+
if (!discovery.frameworks.gdunit4 || !discovery.gdunit_runner) {
|
|
285
|
+
return createErrorResponse('GdUnit4 runner is not installed at addons/gdUnit4/runtest(.sh/.cmd).');
|
|
286
|
+
}
|
|
287
|
+
return this.runGdUnit(godot, args.projectPath, discovery.gdunit_runner, paths.paths, artifacts.paths, timeoutMs);
|
|
288
|
+
}
|
|
289
|
+
return this.runNative(godot, args.projectPath, paths.paths, artifacts.paths, timeoutMs, args.failFast === true);
|
|
290
|
+
}
|
|
291
|
+
discover(projectPath) {
|
|
292
|
+
const nativeTests = [];
|
|
293
|
+
const walk = (directory) => {
|
|
294
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
295
|
+
if (entry.name === '.godot' || entry.name === '.git' || entry.name === 'node_modules')
|
|
296
|
+
continue;
|
|
297
|
+
const full = join(directory, entry.name);
|
|
298
|
+
if (entry.isDirectory())
|
|
299
|
+
walk(full);
|
|
300
|
+
else if (entry.isFile() && /^(test_.*|.*_test)\.gd$/i.test(entry.name)) {
|
|
301
|
+
const relativePath = full.slice(projectPath.length + 1).replaceAll('\\', '/');
|
|
302
|
+
if (!relativePath.startsWith('addons/'))
|
|
303
|
+
nativeTests.push(relativePath);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
walk(projectPath);
|
|
308
|
+
nativeTests.sort();
|
|
309
|
+
const gut = existsSync(join(projectPath, 'addons', 'gut', 'gut_cmdln.gd'));
|
|
310
|
+
const runnerCandidates = process.platform === 'win32'
|
|
311
|
+
? ['addons/gdUnit4/runtest.cmd', 'addons/gdUnit4/runtest.bat', 'addons/gdUnit4/runtest']
|
|
312
|
+
: ['addons/gdUnit4/runtest', 'addons/gdUnit4/runtest.sh'];
|
|
313
|
+
const gdunitRunner = runnerCandidates.find(candidate => existsSync(join(projectPath, candidate))) ?? null;
|
|
314
|
+
return {
|
|
315
|
+
frameworks: { native: true, gut, gdunit4: gdunitRunner !== null },
|
|
316
|
+
native_tests: nativeTests,
|
|
317
|
+
gdunit_runner: gdunitRunner,
|
|
318
|
+
count: nativeTests.length,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
resolveTestPaths(projectPath, supplied, discovered) {
|
|
322
|
+
const candidates = Array.isArray(supplied) && supplied.length > 0 ? supplied.map(String) : discovered;
|
|
323
|
+
if (candidates.length === 0)
|
|
324
|
+
return { error: 'No tests discovered; pass testPaths or add test_*.gd/*_test.gd files.' };
|
|
325
|
+
const paths = [];
|
|
326
|
+
for (const candidate of candidates) {
|
|
327
|
+
if (!validatePath(candidate))
|
|
328
|
+
return { error: `Invalid test path: ${candidate}` };
|
|
329
|
+
const resolved = this.context.pathSecurity.resolveProjectPath(projectPath, candidate);
|
|
330
|
+
if (!resolved || !existsSync(resolved))
|
|
331
|
+
return { error: `Test path does not exist: ${candidate}` };
|
|
332
|
+
paths.push(candidate.replaceAll('\\', '/'));
|
|
333
|
+
}
|
|
334
|
+
return { paths };
|
|
335
|
+
}
|
|
336
|
+
resolveArtifactPaths(projectPath, supplied) {
|
|
337
|
+
if (!Array.isArray(supplied))
|
|
338
|
+
return { paths: [] };
|
|
339
|
+
const paths = [];
|
|
340
|
+
for (const candidate of supplied.map(String)) {
|
|
341
|
+
if (!validatePath(candidate) || !this.context.pathSecurity.resolveProjectPath(projectPath, candidate)) {
|
|
342
|
+
return { error: `Invalid artifact path: ${candidate}` };
|
|
343
|
+
}
|
|
344
|
+
paths.push(candidate.replaceAll('\\', '/'));
|
|
345
|
+
}
|
|
346
|
+
return { paths };
|
|
347
|
+
}
|
|
348
|
+
async runNative(godot, projectPath, paths, artifactPaths, timeoutMs, failFast) {
|
|
349
|
+
const cases = [];
|
|
350
|
+
const runs = [];
|
|
351
|
+
for (const path of paths) {
|
|
352
|
+
const result = await this.runProcess(godot, ['--headless', '--path', projectPath, '--script', join(projectPath, path)], projectPath, timeoutMs);
|
|
353
|
+
const parsed = this.parseCaseMarkers(`${result.stdout}\n${result.stderr}`, path);
|
|
354
|
+
this.applyProcessOutcome(parsed, result);
|
|
355
|
+
cases.push(...(parsed.length > 0 ? parsed : [{
|
|
356
|
+
name: path, path, passed: result.exitCode === 0 && !result.timedOut,
|
|
357
|
+
duration_ms: result.durationMs,
|
|
358
|
+
...(result.timedOut ? { message: 'Timed out' } : result.exitCode === 0 ? {} : { message: `Exited with code ${result.exitCode}` }),
|
|
359
|
+
}]));
|
|
360
|
+
runs.push(this.processEvidence(path, result));
|
|
361
|
+
if (failFast && cases.some(testCase => !testCase.passed))
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
return this.testResponse('native', projectPath, artifactPaths, cases, runs);
|
|
365
|
+
}
|
|
366
|
+
async runGut(godot, projectPath, paths, artifactPaths, timeoutMs) {
|
|
367
|
+
const resources = paths.map(path => `res://${path}`).join(',');
|
|
368
|
+
const result = await this.runProcess(godot, [
|
|
369
|
+
'--headless', '--path', projectPath, '--script', 'res://addons/gut/gut_cmdln.gd',
|
|
370
|
+
'-gexit', '-glog=2', `-gtest=${resources}`,
|
|
371
|
+
], projectPath, timeoutMs);
|
|
372
|
+
const cases = this.parseCaseMarkers(`${result.stdout}\n${result.stderr}`, 'gut');
|
|
373
|
+
this.applyProcessOutcome(cases, result);
|
|
374
|
+
if (cases.length === 0)
|
|
375
|
+
cases.push({ name: 'GUT suite', path: resources, passed: result.exitCode === 0 && !result.timedOut, duration_ms: result.durationMs });
|
|
376
|
+
return this.testResponse('gut', projectPath, artifactPaths, cases, [this.processEvidence('GUT', result)]);
|
|
377
|
+
}
|
|
378
|
+
async runGdUnit(godot, projectPath, runner, paths, artifactPaths, timeoutMs) {
|
|
379
|
+
const runnerPath = join(projectPath, runner);
|
|
380
|
+
const runnerArgs = ['--godot_binary', godot, ...paths.flatMap(path => ['-a', join(projectPath, path)])];
|
|
381
|
+
const result = await this.runProcess(runnerPath, runnerArgs, projectPath, timeoutMs);
|
|
382
|
+
const cases = this.parseCaseMarkers(`${result.stdout}\n${result.stderr}`, 'gdunit4');
|
|
383
|
+
this.applyProcessOutcome(cases, result);
|
|
384
|
+
const acceptedExit = result.exitCode === 0;
|
|
385
|
+
if (cases.length === 0)
|
|
386
|
+
cases.push({
|
|
387
|
+
name: 'GdUnit4 suite', path: paths.join(','), passed: acceptedExit && !result.timedOut,
|
|
388
|
+
duration_ms: result.durationMs,
|
|
389
|
+
...([100, 101, '100', '101'].includes(result.exitCode ?? '') ? { message: `GdUnit4 outcome ${result.exitCode}` } : {}),
|
|
390
|
+
});
|
|
391
|
+
return this.testResponse('gdunit4', projectPath, artifactPaths, cases, [this.processEvidence('GdUnit4', result)]);
|
|
392
|
+
}
|
|
393
|
+
async runProcess(executable, args, cwd, timeoutMs) {
|
|
394
|
+
const started = performance.now();
|
|
395
|
+
try {
|
|
396
|
+
const { stdout, stderr } = await execFileAsync(executable, args, { cwd, timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 });
|
|
397
|
+
return { exitCode: 0, stdout: stdout ?? '', stderr: stderr ?? '', timedOut: false, durationMs: Math.round(performance.now() - started) };
|
|
398
|
+
}
|
|
399
|
+
catch (error) {
|
|
400
|
+
const failure = error;
|
|
401
|
+
return {
|
|
402
|
+
exitCode: failure.code ?? null,
|
|
403
|
+
stdout: failure.stdout ?? '',
|
|
404
|
+
stderr: failure.stderr ?? '',
|
|
405
|
+
timedOut: failure.killed === true || failure.signal != null || failure.code === 'ETIMEDOUT',
|
|
406
|
+
durationMs: Math.round(performance.now() - started),
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
parseCaseMarkers(output, fallbackPath) {
|
|
411
|
+
const cases = [];
|
|
412
|
+
for (const line of output.split(/\r?\n/)) {
|
|
413
|
+
const marker = /^GODOT_MCP_TEST_CASE\s+(.+)$/.exec(line.trim());
|
|
414
|
+
if (!marker)
|
|
415
|
+
continue;
|
|
416
|
+
try {
|
|
417
|
+
const value = JSON.parse(marker[1]);
|
|
418
|
+
if (typeof value.name !== 'string' || typeof value.passed !== 'boolean')
|
|
419
|
+
continue;
|
|
420
|
+
cases.push({
|
|
421
|
+
name: value.name, path: typeof value.path === 'string' ? value.path : fallbackPath,
|
|
422
|
+
passed: value.passed,
|
|
423
|
+
...(typeof value.duration_ms === 'number' ? { duration_ms: value.duration_ms } : {}),
|
|
424
|
+
...(typeof value.message === 'string' ? { message: value.message } : {}),
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
// Malformed markers remain in bounded raw output but are not trusted as cases.
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return cases;
|
|
432
|
+
}
|
|
433
|
+
applyProcessOutcome(cases, result) {
|
|
434
|
+
if (result.exitCode === 0 && !result.timedOut)
|
|
435
|
+
return;
|
|
436
|
+
const message = result.timedOut ? 'Timed out' : `Exited with code ${result.exitCode}`;
|
|
437
|
+
for (const testCase of cases) {
|
|
438
|
+
testCase.passed = false;
|
|
439
|
+
testCase.message ??= message;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
processEvidence(name, result) {
|
|
443
|
+
return {
|
|
444
|
+
name, exit_code: result.exitCode, timed_out: result.timedOut, duration_ms: result.durationMs,
|
|
445
|
+
stdout: result.stdout.slice(-64 * 1024), stderr: result.stderr.slice(-64 * 1024),
|
|
446
|
+
output_truncated: result.stdout.length > 64 * 1024 || result.stderr.length > 64 * 1024,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
testResponse(framework, projectPath, artifactPaths, cases, runs) {
|
|
450
|
+
const failed = cases.filter(testCase => !testCase.passed).length;
|
|
451
|
+
const artifacts = artifactPaths.flatMap(path => {
|
|
452
|
+
const fullPath = this.context.pathSecurity.resolveProjectPath(projectPath, path);
|
|
453
|
+
if (!fullPath || !existsSync(fullPath) || !statSync(fullPath).isFile())
|
|
454
|
+
return [];
|
|
455
|
+
const stat = statSync(fullPath);
|
|
456
|
+
return [{ path, bytes: stat.size, modified_at: stat.mtime.toISOString() }];
|
|
457
|
+
});
|
|
458
|
+
const missingArtifacts = artifactPaths.filter(path => !artifacts.some(artifact => artifact.path === path));
|
|
459
|
+
const result = { framework, passed: failed === 0, total: cases.length, failed, cases, runs, artifacts, missing_artifacts: missingArtifacts };
|
|
460
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], ...(failed === 0 ? {} : { isError: true }) };
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
function parseIniDocument(content) {
|
|
464
|
+
const result = {};
|
|
465
|
+
let section = '';
|
|
466
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
467
|
+
const line = rawLine.trim();
|
|
468
|
+
const header = /^\[([^\]]+)\]$/.exec(line);
|
|
469
|
+
if (header) {
|
|
470
|
+
section = header[1];
|
|
471
|
+
result[section] ??= {};
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
const setting = /^([^=]+)=(.*)$/.exec(line);
|
|
475
|
+
if (setting && section)
|
|
476
|
+
result[section][setting[1].trim()] = setting[2].trim();
|
|
477
|
+
}
|
|
478
|
+
return result;
|
|
479
|
+
}
|
|
480
|
+
function formatGodotSetting(value) {
|
|
481
|
+
if (typeof value === 'string')
|
|
482
|
+
return JSON.stringify(value);
|
|
483
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
484
|
+
return String(value);
|
|
485
|
+
throw new Error('Import setting values must be strings, numbers, or booleans.');
|
|
486
|
+
}
|
|
487
|
+
function decodeGodotSetting(value) {
|
|
488
|
+
if (value === undefined)
|
|
489
|
+
return null;
|
|
490
|
+
if (value === 'true')
|
|
491
|
+
return true;
|
|
492
|
+
if (value === 'false')
|
|
493
|
+
return false;
|
|
494
|
+
if (/^-?\d+(?:\.\d+)?$/.test(value))
|
|
495
|
+
return Number(value);
|
|
496
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
497
|
+
try {
|
|
498
|
+
return JSON.parse(value);
|
|
499
|
+
}
|
|
500
|
+
catch {
|
|
501
|
+
return value.slice(1, -1);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return value;
|
|
505
|
+
}
|
|
506
|
+
/** Inspects, edits, and synchronously reimports Godot source assets. */
|
|
507
|
+
export class ImportPipelineService {
|
|
508
|
+
context;
|
|
509
|
+
constructor(context) {
|
|
510
|
+
this.context = context;
|
|
511
|
+
}
|
|
512
|
+
async execute(args) {
|
|
513
|
+
const importSettings = args?.settings;
|
|
514
|
+
args = normalizeParameters(args || {});
|
|
515
|
+
if (importSettings !== undefined)
|
|
516
|
+
args.settings = importSettings;
|
|
517
|
+
if (!args.projectPath || !args.action)
|
|
518
|
+
return createErrorResponse('projectPath and action are required.');
|
|
519
|
+
if (!validProject(this.context, args.projectPath))
|
|
520
|
+
return createErrorResponse('Invalid project path.');
|
|
521
|
+
if (args.action === 'reimport')
|
|
522
|
+
return this.reimport(args.projectPath, args.timeoutSeconds ?? 120);
|
|
523
|
+
if (!args.sourcePath || !validatePath(args.sourcePath))
|
|
524
|
+
return createErrorResponse('A valid sourcePath is required.');
|
|
525
|
+
const sourcePath = this.context.pathSecurity.resolveProjectPath(args.projectPath, args.sourcePath);
|
|
526
|
+
if (!sourcePath || !existsSync(sourcePath))
|
|
527
|
+
return createErrorResponse(`Source asset does not exist: ${args.sourcePath}`);
|
|
528
|
+
const metadataPath = `${sourcePath}.import`;
|
|
529
|
+
if (!existsSync(metadataPath))
|
|
530
|
+
return createErrorResponse(`Import metadata does not exist: ${args.sourcePath}.import; run reimport first.`);
|
|
531
|
+
const document = parseIniDocument(readFileSync(metadataPath, 'utf8'));
|
|
532
|
+
if (args.action === 'inspect')
|
|
533
|
+
return this.response(args.sourcePath, document);
|
|
534
|
+
if (args.action === 'dependencies')
|
|
535
|
+
return this.response(args.sourcePath, document, true);
|
|
536
|
+
if (args.action !== 'change')
|
|
537
|
+
return createErrorResponse('action must be inspect, change, reimport, or dependencies.');
|
|
538
|
+
if (!args.settings || typeof args.settings !== 'object' || Array.isArray(args.settings)) {
|
|
539
|
+
return createErrorResponse('settings is required for change.');
|
|
540
|
+
}
|
|
541
|
+
try {
|
|
542
|
+
let content = readFileSync(metadataPath, 'utf8');
|
|
543
|
+
for (const [key, value] of Object.entries(args.settings)) {
|
|
544
|
+
if (!/^[A-Za-z0-9_./-]+$/.test(key))
|
|
545
|
+
return createErrorResponse(`Invalid import setting key: ${key}`);
|
|
546
|
+
const formatted = formatGodotSetting(value);
|
|
547
|
+
const sectionIndex = content.indexOf('[params]');
|
|
548
|
+
if (sectionIndex < 0)
|
|
549
|
+
content += `\n[params]\n\n${key}=${formatted}\n`;
|
|
550
|
+
else {
|
|
551
|
+
const sectionEnd = content.indexOf('\n[', sectionIndex + 8);
|
|
552
|
+
const end = sectionEnd < 0 ? content.length : sectionEnd;
|
|
553
|
+
const section = content.slice(sectionIndex, end);
|
|
554
|
+
const pattern = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}=.*$`, 'm');
|
|
555
|
+
const replacement = `${key}=${formatted}`;
|
|
556
|
+
content = content.slice(0, sectionIndex)
|
|
557
|
+
+ (pattern.test(section) ? section.replace(pattern, replacement) : `${section.trimEnd()}\n${replacement}\n`)
|
|
558
|
+
+ content.slice(end);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
writeFileSync(metadataPath, content, 'utf8');
|
|
562
|
+
return this.response(args.sourcePath, parseIniDocument(content));
|
|
563
|
+
}
|
|
564
|
+
catch (error) {
|
|
565
|
+
return createErrorResponse(`Failed to change import settings: ${errorMessage(error)}`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
response(sourcePath, document, dependenciesOnly = false) {
|
|
569
|
+
const dependencies = {
|
|
570
|
+
source_file: decodeGodotSetting(document.deps?.source_file),
|
|
571
|
+
destination_files: this.parseArray(document.deps?.dest_files),
|
|
572
|
+
imported_path: decodeGodotSetting(document.remap?.path),
|
|
573
|
+
};
|
|
574
|
+
const result = dependenciesOnly ? { source_path: sourcePath, dependencies } : {
|
|
575
|
+
source_path: sourcePath, importer: decodeGodotSetting(document.remap?.importer),
|
|
576
|
+
resource_type: decodeGodotSetting(document.remap?.type),
|
|
577
|
+
settings: Object.fromEntries(Object.entries(document.params ?? {}).map(([key, value]) => [key, decodeGodotSetting(value)])),
|
|
578
|
+
dependencies,
|
|
579
|
+
};
|
|
580
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
581
|
+
}
|
|
582
|
+
parseArray(value) {
|
|
583
|
+
if (!value)
|
|
584
|
+
return [];
|
|
585
|
+
return [...value.matchAll(/"([^"]+)"/g)].map(match => match[1]);
|
|
586
|
+
}
|
|
587
|
+
async reimport(projectPath, timeoutSeconds) {
|
|
588
|
+
if (!this.context.executable.path)
|
|
589
|
+
await this.context.executable.detect();
|
|
590
|
+
if (!this.context.executable.path)
|
|
591
|
+
return createErrorResponse('Could not find Godot executable.');
|
|
592
|
+
const started = performance.now();
|
|
593
|
+
try {
|
|
594
|
+
const { stdout, stderr } = await execFileAsync(this.context.executable.path, [
|
|
595
|
+
'--headless', '--path', projectPath, '--import',
|
|
596
|
+
], { cwd: projectPath, timeout: Math.round(timeoutSeconds * 1000), maxBuffer: 4 * 1024 * 1024 });
|
|
597
|
+
const diagnostics = `${stdout ?? ''}\n${stderr ?? ''}`.split(/\r?\n/)
|
|
598
|
+
.filter(line => /\b(?:warning|error)\b/i.test(line)).slice(0, 256);
|
|
599
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
600
|
+
imported: true, duration_ms: Math.round(performance.now() - started), diagnostics,
|
|
601
|
+
stdout: (stdout ?? '').slice(-64 * 1024), stderr: (stderr ?? '').slice(-64 * 1024),
|
|
602
|
+
}, null, 2) }] };
|
|
603
|
+
}
|
|
604
|
+
catch (error) {
|
|
605
|
+
const failure = error;
|
|
606
|
+
return createErrorResponse(JSON.stringify({ imported: false, exit_code: failure.code ?? null,
|
|
607
|
+
timed_out: failure.killed === true || failure.signal != null, stdout: (failure.stdout ?? '').slice(-64 * 1024),
|
|
608
|
+
stderr: (failure.stderr ?? '').slice(-64 * 1024) }, null, 2));
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
/** Builds a bounded static dependency and integrity report for project resources. */
|
|
613
|
+
export class ProjectIntegrityService {
|
|
614
|
+
context;
|
|
615
|
+
static resourceExtensions = new Set(['.tscn', '.tres', '.gd', '.gdshader', '.res', '.scn']);
|
|
616
|
+
constructor(context) {
|
|
617
|
+
this.context = context;
|
|
618
|
+
}
|
|
619
|
+
async execute(args) {
|
|
620
|
+
args = normalizeParameters(args || {});
|
|
621
|
+
if (!args.projectPath || !args.action)
|
|
622
|
+
return createErrorResponse('projectPath and action are required.');
|
|
623
|
+
if (!validProject(this.context, args.projectPath))
|
|
624
|
+
return createErrorResponse('Invalid project path.');
|
|
625
|
+
const inventory = this.scan(args.projectPath, args.maxFiles ?? 10000);
|
|
626
|
+
if ('error' in inventory)
|
|
627
|
+
return createErrorResponse(inventory.error);
|
|
628
|
+
if (args.action === 'analyze')
|
|
629
|
+
return { content: [{ type: 'text', text: JSON.stringify(this.analyze(args.projectPath, inventory.files), null, 2) }] };
|
|
630
|
+
if (args.action === 'leaks') {
|
|
631
|
+
const report = this.analyze(args.projectPath, inventory.files);
|
|
632
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
633
|
+
source: 'static-project-audit',
|
|
634
|
+
runtime_snapshot_required: true,
|
|
635
|
+
orphan_resources: report.orphan_resources,
|
|
636
|
+
orphan_nodes: report.orphan_nodes,
|
|
637
|
+
broken_references: report.broken_references,
|
|
638
|
+
cycles: report.cycles,
|
|
639
|
+
note: 'Static candidates are reported without mutating the project; use game_performance action=leaks for live ObjectDB counters.',
|
|
640
|
+
}, null, 2) }] };
|
|
641
|
+
}
|
|
642
|
+
if (args.action === 'assets')
|
|
643
|
+
return { content: [{ type: 'text', text: JSON.stringify(this.auditAssets(args.projectPath, args.maxFiles ?? 10000), null, 2) }] };
|
|
644
|
+
if (args.action === 'localization')
|
|
645
|
+
return { content: [{ type: 'text', text: JSON.stringify(this.auditLocalization(args.projectPath, args.maxFiles ?? 10000), null, 2) }] };
|
|
646
|
+
if (args.action === 'accessibility')
|
|
647
|
+
return { content: [{ type: 'text', text: JSON.stringify(this.auditAccessibility(args.projectPath, args.maxFiles ?? 10000), null, 2) }] };
|
|
648
|
+
if (args.action === 'extensions')
|
|
649
|
+
return { content: [{ type: 'text', text: JSON.stringify(this.auditExtensions(args.projectPath, args.maxFiles ?? 10000), null, 2) }] };
|
|
650
|
+
if (args.action !== 'preview_rename')
|
|
651
|
+
return createErrorResponse('action must be analyze, preview_rename, assets, localization, accessibility, extensions, or leaks.');
|
|
652
|
+
if (!args.sourcePath || !args.destinationPath || !validatePath(args.sourcePath) || !validatePath(args.destinationPath)) {
|
|
653
|
+
return createErrorResponse('Valid sourcePath and destinationPath are required for preview_rename.');
|
|
654
|
+
}
|
|
655
|
+
const source = `res://${args.sourcePath.replaceAll('\\', '/')}`;
|
|
656
|
+
const destination = this.context.pathSecurity.resolveProjectPath(args.projectPath, args.destinationPath);
|
|
657
|
+
const references = inventory.files.filter(file => readFileSync(join(args.projectPath, file), 'utf8').includes(source));
|
|
658
|
+
return { content: [{ type: 'text', text: JSON.stringify({ source_path: args.sourcePath,
|
|
659
|
+
destination_path: args.destinationPath, source_exists: existsSync(join(args.projectPath, args.sourcePath)),
|
|
660
|
+
destination_exists: destination ? existsSync(destination) : false, referencing_files: references,
|
|
661
|
+
uid_sidecar: existsSync(join(args.projectPath, `${args.sourcePath}.uid`)) ? `${args.sourcePath}.uid` : null,
|
|
662
|
+
changes_applied: false }, null, 2) }] };
|
|
663
|
+
}
|
|
664
|
+
scan(projectPath, maxFiles) {
|
|
665
|
+
const files = [];
|
|
666
|
+
const walk = (directory) => {
|
|
667
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
668
|
+
if (['.godot', '.git', 'node_modules'].includes(entry.name))
|
|
669
|
+
continue;
|
|
670
|
+
const full = join(directory, entry.name);
|
|
671
|
+
if (entry.isDirectory()) {
|
|
672
|
+
if (!walk(full))
|
|
673
|
+
return false;
|
|
674
|
+
}
|
|
675
|
+
else if (entry.isFile() && ProjectIntegrityService.resourceExtensions.has(extname(entry.name).toLowerCase())) {
|
|
676
|
+
files.push(relative(projectPath, full).replaceAll('\\', '/'));
|
|
677
|
+
if (files.length > maxFiles)
|
|
678
|
+
return false;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
return true;
|
|
682
|
+
};
|
|
683
|
+
return walk(projectPath) ? { files: files.sort() } : { error: `Project exceeds maxFiles limit (${maxFiles}).` };
|
|
684
|
+
}
|
|
685
|
+
analyze(projectPath, files) {
|
|
686
|
+
const fileSet = new Set(files);
|
|
687
|
+
const graph = {};
|
|
688
|
+
const uidOwners = new Map();
|
|
689
|
+
const brokenReferences = [];
|
|
690
|
+
const orphanNodes = [];
|
|
691
|
+
for (const file of files) {
|
|
692
|
+
const content = readFileSync(join(projectPath, file), 'utf8');
|
|
693
|
+
const references = [...new Set([...content.matchAll(/res:\/\/([^"'\s)\]]+)/g)].map(match => match[1]))];
|
|
694
|
+
graph[file] = references.filter(target => fileSet.has(target));
|
|
695
|
+
for (const target of references)
|
|
696
|
+
if (!existsSync(join(projectPath, target)))
|
|
697
|
+
brokenReferences.push({ source: file, target });
|
|
698
|
+
for (const match of content.matchAll(/uid(?:=|\s*=\s*)"(uid:\/\/[^"\s]+)"/g)) {
|
|
699
|
+
const owners = uidOwners.get(match[1]) ?? [];
|
|
700
|
+
owners.push(file);
|
|
701
|
+
uidOwners.set(match[1], owners);
|
|
702
|
+
}
|
|
703
|
+
if (extname(file) === '.tscn')
|
|
704
|
+
this.findOrphanNodes(file, content, orphanNodes);
|
|
705
|
+
}
|
|
706
|
+
const incoming = new Set(Object.values(graph).flat());
|
|
707
|
+
const projectConfig = readFileSync(join(projectPath, 'project.godot'), 'utf8');
|
|
708
|
+
const roots = new Set([...projectConfig.matchAll(/res:\/\/([^"\s]+)/g)].map(match => match[1]));
|
|
709
|
+
return {
|
|
710
|
+
scanned_files: files.length, graph, broken_references: brokenReferences,
|
|
711
|
+
duplicate_uids: [...uidOwners.entries()].filter(([, owners]) => new Set(owners).size > 1)
|
|
712
|
+
.map(([uid, owners]) => ({ uid, files: [...new Set(owners)] })),
|
|
713
|
+
cycles: this.cycles(graph), orphan_resources: files.filter(file => !incoming.has(file) && !roots.has(file)),
|
|
714
|
+
orphan_nodes: orphanNodes, limits: { max_files: files.length },
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
findOrphanNodes(scene, content, output) {
|
|
718
|
+
const known = new Set(['.']);
|
|
719
|
+
for (const match of content.matchAll(/^\[node\s+([^\]]+)\]$/gm)) {
|
|
720
|
+
const name = /(?:^|\s)name="([^"]+)"/.exec(match[1])?.[1];
|
|
721
|
+
if (!name)
|
|
722
|
+
continue;
|
|
723
|
+
const parent = /(?:^|\s)parent="([^"]+)"/.exec(match[1])?.[1] ?? '.';
|
|
724
|
+
if (parent !== '.' && !known.has(parent))
|
|
725
|
+
output.push({ scene, node: name, parent });
|
|
726
|
+
known.add(parent === '.' ? name : `${parent}/${name}`);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
cycles(graph) {
|
|
730
|
+
const cycles = [];
|
|
731
|
+
const visiting = new Set();
|
|
732
|
+
const visited = new Set();
|
|
733
|
+
const visit = (node, path) => {
|
|
734
|
+
if (visiting.has(node)) {
|
|
735
|
+
const start = path.indexOf(node);
|
|
736
|
+
cycles.push([...path.slice(start), node]);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
if (visited.has(node))
|
|
740
|
+
return;
|
|
741
|
+
visiting.add(node);
|
|
742
|
+
for (const next of graph[node] ?? [])
|
|
743
|
+
visit(next, [...path, node]);
|
|
744
|
+
visiting.delete(node);
|
|
745
|
+
visited.add(node);
|
|
746
|
+
};
|
|
747
|
+
for (const node of Object.keys(graph))
|
|
748
|
+
visit(node, []);
|
|
749
|
+
return cycles;
|
|
750
|
+
}
|
|
751
|
+
allProjectFiles(projectPath, maxFiles) {
|
|
752
|
+
const files = [];
|
|
753
|
+
const walk = (directory) => {
|
|
754
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
755
|
+
if (['.godot', '.git', 'node_modules'].includes(entry.name))
|
|
756
|
+
continue;
|
|
757
|
+
const full = join(directory, entry.name);
|
|
758
|
+
if (entry.isDirectory())
|
|
759
|
+
walk(full);
|
|
760
|
+
else if (entry.isFile()) {
|
|
761
|
+
files.push(relative(projectPath, full).replaceAll('\\', '/'));
|
|
762
|
+
if (files.length > maxFiles)
|
|
763
|
+
throw new Error(`Project exceeds maxFiles limit (${maxFiles}).`);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
};
|
|
767
|
+
walk(projectPath);
|
|
768
|
+
return files.sort();
|
|
769
|
+
}
|
|
770
|
+
auditAssets(projectPath, maxFiles) {
|
|
771
|
+
let files;
|
|
772
|
+
try {
|
|
773
|
+
files = this.allProjectFiles(projectPath, maxFiles);
|
|
774
|
+
}
|
|
775
|
+
catch (error) {
|
|
776
|
+
return { error: errorMessage(error), complete: false };
|
|
777
|
+
}
|
|
778
|
+
const categories = {
|
|
779
|
+
scenes: [], textures: [], models: [], animations: [], audio: [], fonts: [], shaders: [], imported: [],
|
|
780
|
+
};
|
|
781
|
+
const extensionMap = {
|
|
782
|
+
'.tscn': 'scenes', '.scn': 'scenes', '.png': 'textures', '.svg': 'textures', '.jpg': 'textures', '.jpeg': 'textures',
|
|
783
|
+
'.webp': 'textures', '.glb': 'models', '.gltf': 'models', '.obj': 'models', '.fbx': 'models', '.anim': 'animations',
|
|
784
|
+
'.wav': 'audio', '.ogg': 'audio', '.mp3': 'audio', '.ttf': 'fonts', '.otf': 'fonts', '.gdshader': 'shaders',
|
|
785
|
+
};
|
|
786
|
+
for (const file of files) {
|
|
787
|
+
const lower = file.toLowerCase();
|
|
788
|
+
const category = extensionMap[extname(lower)];
|
|
789
|
+
if (category)
|
|
790
|
+
categories[category].push(file);
|
|
791
|
+
if (lower.startsWith('.godot/imported/'))
|
|
792
|
+
categories.imported.push(file);
|
|
793
|
+
}
|
|
794
|
+
return { complete: true, files_scanned: files.length, categories, bounded: files.length >= maxFiles };
|
|
795
|
+
}
|
|
796
|
+
auditLocalization(projectPath, maxFiles) {
|
|
797
|
+
let files;
|
|
798
|
+
try {
|
|
799
|
+
files = this.allProjectFiles(projectPath, maxFiles);
|
|
800
|
+
}
|
|
801
|
+
catch (error) {
|
|
802
|
+
return { error: errorMessage(error), complete: false };
|
|
803
|
+
}
|
|
804
|
+
const sources = files.filter(file => /\.(csv|po|pot)$/i.test(file));
|
|
805
|
+
const entries = [];
|
|
806
|
+
for (const file of sources) {
|
|
807
|
+
const content = readFileSync(join(projectPath, file), 'utf8');
|
|
808
|
+
if (/\.csv$/i.test(file)) {
|
|
809
|
+
const lines = content.split(/\r?\n/).filter(Boolean);
|
|
810
|
+
const headers = (lines.shift() ?? '').split(',').map(value => value.trim());
|
|
811
|
+
const missing = [];
|
|
812
|
+
for (const line of lines) {
|
|
813
|
+
const columns = line.split(',');
|
|
814
|
+
headers.forEach((header, index) => { if (index > 0 && !columns[index]?.trim())
|
|
815
|
+
missing.push(`${columns[0] ?? '?'}:${header}`); });
|
|
816
|
+
}
|
|
817
|
+
entries.push({ file, format: 'csv', locales: headers.slice(1), keys: lines.length, missing });
|
|
818
|
+
}
|
|
819
|
+
else {
|
|
820
|
+
const ids = [...content.matchAll(/^msgid\s+"(.*)"$/gm)].map(match => match[1]);
|
|
821
|
+
const untranslated = [...content.matchAll(/^msgstr\s+""$/gm)].length;
|
|
822
|
+
entries.push({ file, format: 'po', keys: ids.length, untranslated });
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
return { complete: true, source_files: entries, source_count: entries.length };
|
|
826
|
+
}
|
|
827
|
+
auditAccessibility(projectPath, maxFiles) {
|
|
828
|
+
let files;
|
|
829
|
+
try {
|
|
830
|
+
files = this.allProjectFiles(projectPath, maxFiles);
|
|
831
|
+
}
|
|
832
|
+
catch (error) {
|
|
833
|
+
return { error: errorMessage(error), complete: false };
|
|
834
|
+
}
|
|
835
|
+
const scenes = files.filter(file => /\.(tscn|scn)$/i.test(file));
|
|
836
|
+
const controls = [];
|
|
837
|
+
for (const file of scenes) {
|
|
838
|
+
const content = readFileSync(join(projectPath, file), 'utf8');
|
|
839
|
+
for (const match of content.matchAll(/^\[node\s+([^\]]+type="([A-Za-z0-9_]+)"[^\]]*)\]$/gm)) {
|
|
840
|
+
const attributes = match[1];
|
|
841
|
+
const type = match[2];
|
|
842
|
+
if (!type.endsWith('Control') && !['Button', 'Label', 'LineEdit', 'TextEdit', 'CheckBox', 'Slider', 'Tree'].includes(type))
|
|
843
|
+
continue;
|
|
844
|
+
const name = /name="([^"]+)"/.exec(attributes)?.[1] ?? '?';
|
|
845
|
+
const hasText = new RegExp(`^text\\s*=`, 'm').test(content.slice(match.index ?? 0, (match.index ?? 0) + 1200));
|
|
846
|
+
const hasMinimum = new RegExp(`^custom_minimum_size\\s*=`, 'm').test(content.slice(match.index ?? 0, (match.index ?? 0) + 1200));
|
|
847
|
+
controls.push({ file, name, type, has_text: hasText, has_minimum_size: hasMinimum,
|
|
848
|
+
warnings: type === 'Button' && !hasText ? ['button_without_text_or_label'] : [] });
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
return { complete: true, scenes_scanned: scenes.length, controls, warning_count: controls.reduce((n, control) => n + control.warnings.length, 0) };
|
|
852
|
+
}
|
|
853
|
+
auditExtensions(projectPath, maxFiles) {
|
|
854
|
+
let files;
|
|
855
|
+
try {
|
|
856
|
+
files = this.allProjectFiles(projectPath, maxFiles);
|
|
857
|
+
}
|
|
858
|
+
catch (error) {
|
|
859
|
+
return { error: errorMessage(error), complete: false };
|
|
860
|
+
}
|
|
861
|
+
const extensionFiles = files.filter(file => file.toLowerCase().endsWith('.gdextension'));
|
|
862
|
+
const records = extensionFiles.map(file => {
|
|
863
|
+
const content = readFileSync(join(projectPath, file), 'utf8');
|
|
864
|
+
return { file, has_entry_symbol: /entry_symbol\s*=/.test(content), libraries: [...content.matchAll(/library\/[A-Za-z0-9_]+\s*=\s*"([^"]+)"/g)].map(match => match[1]),
|
|
865
|
+
has_native_library: /library\/|entry_symbol\s*=/.test(content) };
|
|
866
|
+
});
|
|
867
|
+
return { complete: true, extensions: records, extension_count: records.length, build_required: records.length > 0 };
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
/** Validates export prerequisites and owns export/artifact/smoke evidence. */
|
|
871
|
+
export class ExportReadinessService {
|
|
872
|
+
context;
|
|
873
|
+
constructor(context) {
|
|
874
|
+
this.context = context;
|
|
875
|
+
}
|
|
876
|
+
async execute(args) {
|
|
877
|
+
args = normalizeParameters(args || {});
|
|
878
|
+
if (!args.projectPath || !args.action || !args.presetName) {
|
|
879
|
+
return createErrorResponse('projectPath, action, and presetName are required.');
|
|
880
|
+
}
|
|
881
|
+
if (!validProject(this.context, args.projectPath))
|
|
882
|
+
return createErrorResponse('Invalid project path.');
|
|
883
|
+
const presetResult = this.readPreset(args.projectPath, args.presetName);
|
|
884
|
+
if ('error' in presetResult)
|
|
885
|
+
return createErrorResponse(JSON.stringify({ ready: false, category: presetResult.category, message: presetResult.error }, null, 2));
|
|
886
|
+
if (!this.context.executable.path)
|
|
887
|
+
await this.context.executable.detect();
|
|
888
|
+
if (!this.context.executable.path)
|
|
889
|
+
return createErrorResponse('Could not find Godot executable.');
|
|
890
|
+
const engine = await this.engineInfo(this.context.executable.path);
|
|
891
|
+
if ('error' in engine)
|
|
892
|
+
return createErrorResponse(engine.error);
|
|
893
|
+
const readiness = this.readiness(presetResult.preset, engine.version, args.debug === true);
|
|
894
|
+
if (args.action === 'inspect')
|
|
895
|
+
return this.response({ engine, preset: presetResult.preset, ...readiness });
|
|
896
|
+
if (args.action !== 'export_smoke')
|
|
897
|
+
return createErrorResponse('action must be inspect or export_smoke.');
|
|
898
|
+
if (typeof args.outputPath !== 'string')
|
|
899
|
+
return createErrorResponse('outputPath is required for export_smoke.');
|
|
900
|
+
const outputPath = isAbsolute(args.outputPath)
|
|
901
|
+
? (this.context.pathSecurity.isProjectPathAllowed(args.outputPath, true) ? resolve(args.outputPath) : null)
|
|
902
|
+
: this.context.pathSecurity.resolveProjectPath(args.projectPath, args.outputPath);
|
|
903
|
+
if (!outputPath)
|
|
904
|
+
return createErrorResponse('Invalid output path.');
|
|
905
|
+
if (!readiness.ready)
|
|
906
|
+
return createErrorResponse(JSON.stringify({ engine,
|
|
907
|
+
preset: presetResult.preset, ...readiness,
|
|
908
|
+
category: readiness.platform_known ? 'missing_templates' : 'unsupported_platform' }, null, 2));
|
|
909
|
+
try {
|
|
910
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
911
|
+
}
|
|
912
|
+
catch (error) {
|
|
913
|
+
return createErrorResponse(JSON.stringify({ ready: true, category: 'output_path_invalid',
|
|
914
|
+
message: errorMessage(error), output_path: args.outputPath }, null, 2));
|
|
915
|
+
}
|
|
916
|
+
const exported = await this.run(this.context.executable.path, [
|
|
917
|
+
'--headless', '--path', args.projectPath, args.debug ? '--export-debug' : '--export-release', args.presetName, outputPath,
|
|
918
|
+
], args.projectPath, Math.round((args.timeoutSeconds ?? 120) * 1000));
|
|
919
|
+
if (!exported.ok)
|
|
920
|
+
return createErrorResponse(JSON.stringify({ ready: true, category: this.classifyExportFailure(exported),
|
|
921
|
+
engine, preset: presetResult.preset, process: exported }, null, 2));
|
|
922
|
+
if (!existsSync(outputPath))
|
|
923
|
+
return createErrorResponse(JSON.stringify({ ready: true, category: 'artifact_missing',
|
|
924
|
+
message: 'Godot exited successfully but did not create the requested artifact.', process: exported }, null, 2));
|
|
925
|
+
const artifact = this.artifact(outputPath, args.projectPath);
|
|
926
|
+
const smokeEnabled = args.smoke !== false;
|
|
927
|
+
let smoke = { attempted: false, supported: false };
|
|
928
|
+
if (smokeEnabled && presetResult.preset.platform === 'Linux' && process.platform === 'linux') {
|
|
929
|
+
const result = await this.run(outputPath, ['--headless', '--quit-after', String(args.smokeTimeoutSeconds ?? 5)], dirname(outputPath), Math.round(((args.smokeTimeoutSeconds ?? 5) + 10) * 1000));
|
|
930
|
+
const expected = typeof args.expectedOutput === 'string' ? args.expectedOutput : null;
|
|
931
|
+
const matched = expected === null || result.stdout.includes(expected);
|
|
932
|
+
smoke = { attempted: true, supported: true, passed: result.ok && matched,
|
|
933
|
+
expected_output: expected, output_matched: matched, process: result };
|
|
934
|
+
if (!result.ok || !matched)
|
|
935
|
+
return createErrorResponse(JSON.stringify({ ready: true, category: 'smoke_failed', engine,
|
|
936
|
+
preset: presetResult.preset, artifact, smoke }, null, 2));
|
|
937
|
+
}
|
|
938
|
+
return this.response({ ready: true, category: 'success', engine, preset: presetResult.preset,
|
|
939
|
+
process: exported, artifact, smoke });
|
|
940
|
+
}
|
|
941
|
+
readPreset(projectPath, name) {
|
|
942
|
+
const configPath = join(projectPath, 'export_presets.cfg');
|
|
943
|
+
if (!existsSync(configPath))
|
|
944
|
+
return { error: 'export_presets.cfg does not exist.', category: 'preset_config_missing' };
|
|
945
|
+
const document = parseIniDocument(readFileSync(configPath, 'utf8'));
|
|
946
|
+
for (const [section, values] of Object.entries(document)) {
|
|
947
|
+
const match = /^preset\.(\d+)$/.exec(section);
|
|
948
|
+
if (!match || decodeGodotSetting(values.name) !== name)
|
|
949
|
+
continue;
|
|
950
|
+
const options = document[`preset.${match[1]}.options`] ?? {};
|
|
951
|
+
const platform = decodeGodotSetting(values.platform);
|
|
952
|
+
const exportPath = decodeGodotSetting(values.export_path);
|
|
953
|
+
return { preset: { name: String(name), platform: typeof platform === 'string' ? platform : '',
|
|
954
|
+
runnable: decodeGodotSetting(values.runnable) === true,
|
|
955
|
+
export_path: typeof exportPath === 'string' ? exportPath : null,
|
|
956
|
+
options: Object.fromEntries(Object.entries(options).map(([key, value]) => [key, decodeGodotSetting(value)])) } };
|
|
957
|
+
}
|
|
958
|
+
return { error: `Export preset not found: ${String(name)}`, category: 'preset_not_found' };
|
|
959
|
+
}
|
|
960
|
+
async engineInfo(executable) {
|
|
961
|
+
const result = await this.run(executable, ['--version'], process.cwd(), 10_000);
|
|
962
|
+
if (!result.ok)
|
|
963
|
+
return { error: `Could not query Godot version: ${result.stderr || result.stdout}` };
|
|
964
|
+
const raw = result.stdout.trim();
|
|
965
|
+
const match = /^(\d+\.\d+)(?:\.\d+)?\.([A-Za-z]+)/.exec(raw);
|
|
966
|
+
return { version: match ? `${match[1]}.${match[2].toLowerCase()}` : raw.split('.')[0], executable };
|
|
967
|
+
}
|
|
968
|
+
readiness(preset, version, debug) {
|
|
969
|
+
const mode = debug ? 'debug' : 'release';
|
|
970
|
+
const customTemplate = preset.options[`custom_template/${mode}`];
|
|
971
|
+
const templateDirs = this.templateDirectories(version);
|
|
972
|
+
const templateName = this.templateName(preset.platform, mode);
|
|
973
|
+
const installed = templateName
|
|
974
|
+
? templateDirs.find(path => existsSync(join(path, templateName))) ?? null
|
|
975
|
+
: null;
|
|
976
|
+
const customExists = typeof customTemplate === 'string' && customTemplate.length > 0 && existsSync(customTemplate);
|
|
977
|
+
const platformKnown = templateName !== null;
|
|
978
|
+
return { ready: platformKnown && (customExists || installed !== null), template_version: version,
|
|
979
|
+
template_directory: installed, searched_template_directories: templateDirs,
|
|
980
|
+
expected_template_file: templateName, platform_known: platformKnown,
|
|
981
|
+
custom_template: customTemplate || null, custom_template_exists: customExists,
|
|
982
|
+
platform_supported_for_smoke: preset.platform === 'Linux' && process.platform === 'linux' };
|
|
983
|
+
}
|
|
984
|
+
templateName(platform, mode) {
|
|
985
|
+
return {
|
|
986
|
+
Linux: `linux_${mode}.x86_64`, Windows: `windows_${mode}_x86_64.exe`,
|
|
987
|
+
macOS: `macos.zip`, Web: `web_${mode}.zip`,
|
|
988
|
+
}[platform] ?? null;
|
|
989
|
+
}
|
|
990
|
+
templateDirectories(version) {
|
|
991
|
+
const roots = new Set();
|
|
992
|
+
if (process.env.GODOT_MCP_EXPORT_XDG_DATA_HOME)
|
|
993
|
+
roots.add(process.env.GODOT_MCP_EXPORT_XDG_DATA_HOME);
|
|
994
|
+
if (process.env.XDG_DATA_HOME)
|
|
995
|
+
roots.add(process.env.XDG_DATA_HOME);
|
|
996
|
+
roots.add(join(homedir(), '.local', 'share'));
|
|
997
|
+
if (process.env.APPDATA)
|
|
998
|
+
roots.add(process.env.APPDATA);
|
|
999
|
+
roots.add(join(homedir(), 'Library', 'Application Support'));
|
|
1000
|
+
return [...roots].map(root => join(root, 'godot', 'export_templates', version));
|
|
1001
|
+
}
|
|
1002
|
+
artifact(path, projectPath) {
|
|
1003
|
+
const data = readFileSync(path);
|
|
1004
|
+
const stat = statSync(path);
|
|
1005
|
+
const pckPath = path.replace(/\.[^./]+$/, '.pck');
|
|
1006
|
+
return { path: relative(projectPath, path).replaceAll('\\', '/'), bytes: stat.size,
|
|
1007
|
+
sha256: createHash('sha256').update(data).digest('hex'), executable: (stat.mode & 0o111) !== 0,
|
|
1008
|
+
companion_pck: existsSync(pckPath) ? relative(projectPath, pckPath).replaceAll('\\', '/') : null };
|
|
1009
|
+
}
|
|
1010
|
+
classifyExportFailure(result) {
|
|
1011
|
+
if (result.timed_out)
|
|
1012
|
+
return 'timeout';
|
|
1013
|
+
const output = `${result.stdout}\n${result.stderr}`;
|
|
1014
|
+
if (/template|export templates/i.test(output))
|
|
1015
|
+
return 'missing_templates';
|
|
1016
|
+
if (/preset.*not found|invalid preset/i.test(output))
|
|
1017
|
+
return 'preset_not_found';
|
|
1018
|
+
if (/project\.godot|main scene|parse error/i.test(output))
|
|
1019
|
+
return 'invalid_project';
|
|
1020
|
+
return 'export_failed';
|
|
1021
|
+
}
|
|
1022
|
+
async run(executable, args, cwd, timeout) {
|
|
1023
|
+
const started = performance.now();
|
|
1024
|
+
try {
|
|
1025
|
+
const { stdout, stderr } = await execFileAsync(executable, args, { cwd, timeout, maxBuffer: 16 * 1024 * 1024 });
|
|
1026
|
+
return { ok: true, exit_code: 0, timed_out: false, duration_ms: Math.round(performance.now() - started),
|
|
1027
|
+
stdout: (stdout ?? '').slice(-128 * 1024), stderr: (stderr ?? '').slice(-128 * 1024) };
|
|
1028
|
+
}
|
|
1029
|
+
catch (error) {
|
|
1030
|
+
const failure = error;
|
|
1031
|
+
return { ok: false, exit_code: failure.code ?? null, timed_out: failure.killed === true || failure.signal != null,
|
|
1032
|
+
duration_ms: Math.round(performance.now() - started), stdout: (failure.stdout ?? '').slice(-128 * 1024),
|
|
1033
|
+
stderr: (failure.stderr ?? '').slice(-128 * 1024) };
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
response(value) {
|
|
1037
|
+
return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
/** Detects, restores, builds, and runs projects against the matching Godot.NET.Sdk. */
|
|
1041
|
+
export class DotnetWorkflowService {
|
|
1042
|
+
context;
|
|
1043
|
+
constructor(context) {
|
|
1044
|
+
this.context = context;
|
|
1045
|
+
}
|
|
1046
|
+
async execute(args) {
|
|
1047
|
+
args = normalizeParameters(args || {});
|
|
1048
|
+
if (!args.projectPath || !args.action)
|
|
1049
|
+
return createErrorResponse('projectPath and action are required.');
|
|
1050
|
+
if (!validProject(this.context, args.projectPath))
|
|
1051
|
+
return createErrorResponse('Invalid project path.');
|
|
1052
|
+
const project = this.projectInfo(args.projectPath, args.csprojPath);
|
|
1053
|
+
if ('error' in project)
|
|
1054
|
+
return createErrorResponse(JSON.stringify({ ready: false, category: project.category, message: project.error }, null, 2));
|
|
1055
|
+
if (!this.context.executable.path)
|
|
1056
|
+
await this.context.executable.detect();
|
|
1057
|
+
if (!this.context.executable.path)
|
|
1058
|
+
return createErrorResponse('Could not find Godot executable.');
|
|
1059
|
+
const engineResult = await this.run(this.context.executable.path, ['--version'], args.projectPath, 10_000);
|
|
1060
|
+
const dotnetResult = await this.run('dotnet', ['--version'], args.projectPath, 10_000);
|
|
1061
|
+
const engineVersion = engineResult.stdout.trim();
|
|
1062
|
+
const engineMatch = /^(\d+\.\d+)(?:\.\d+)?/.exec(engineVersion);
|
|
1063
|
+
const sdkMatch = /^Godot\.NET\.Sdk\/(\d+\.\d+\.\d+)$/.exec(project.sdk);
|
|
1064
|
+
const mono = /(?:^|\.)mono(?:\.|$)/i.test(engineVersion);
|
|
1065
|
+
const dotnetAvailable = dotnetResult.ok;
|
|
1066
|
+
const versionCompatible = Boolean(engineMatch && sdkMatch && sdkMatch[1].startsWith(`${engineMatch[1]}.`));
|
|
1067
|
+
const readiness = { ready: engineResult.ok && mono && dotnetAvailable && versionCompatible,
|
|
1068
|
+
engine: { executable: this.context.executable.path, version: engineVersion, dotnet_enabled: mono },
|
|
1069
|
+
dotnet: { available: dotnetAvailable, version: dotnetResult.ok ? dotnetResult.stdout.trim() : null },
|
|
1070
|
+
project };
|
|
1071
|
+
if (args.action === 'inspect')
|
|
1072
|
+
return this.response(readiness);
|
|
1073
|
+
if (!['restore', 'build', 'run'].includes(String(args.action))) {
|
|
1074
|
+
return createErrorResponse('action must be inspect, restore, build, or run.');
|
|
1075
|
+
}
|
|
1076
|
+
if (!readiness.ready)
|
|
1077
|
+
return createErrorResponse(JSON.stringify({ ...readiness,
|
|
1078
|
+
category: !mono ? 'dotnet_editor_required' : !dotnetAvailable ? 'dotnet_sdk_missing' : 'sdk_version_mismatch' }, null, 2));
|
|
1079
|
+
const timeoutMs = Math.round((args.timeoutSeconds ?? 120) * 1000);
|
|
1080
|
+
const restore = await this.run('dotnet', ['restore', project.path, '--nologo'], args.projectPath, timeoutMs);
|
|
1081
|
+
const restoreEvidence = { ...restore, diagnostics: this.diagnostics(restore) };
|
|
1082
|
+
if (!restore.ok)
|
|
1083
|
+
return createErrorResponse(JSON.stringify({ ...readiness,
|
|
1084
|
+
category: restore.timed_out ? 'timeout' : 'restore_failed', restore: restoreEvidence }, null, 2));
|
|
1085
|
+
if (args.action === 'restore')
|
|
1086
|
+
return this.response({ ...readiness, category: 'success', restore: restoreEvidence });
|
|
1087
|
+
const configuration = args.configuration === 'Release' ? 'Release' : 'Debug';
|
|
1088
|
+
const build = await this.run('dotnet', ['build', project.path, '--no-restore', '--nologo',
|
|
1089
|
+
'--configuration', configuration], args.projectPath, timeoutMs);
|
|
1090
|
+
const buildEvidence = { ...build, diagnostics: this.diagnostics(build) };
|
|
1091
|
+
if (!build.ok)
|
|
1092
|
+
return createErrorResponse(JSON.stringify({ ...readiness,
|
|
1093
|
+
category: build.timed_out ? 'timeout' : 'build_failed', restore: restoreEvidence, build: buildEvidence }, null, 2));
|
|
1094
|
+
const artifact = this.assemblyArtifact(args.projectPath, project, configuration);
|
|
1095
|
+
if (args.action === 'build')
|
|
1096
|
+
return this.response({ ...readiness, category: 'success',
|
|
1097
|
+
restore: restoreEvidence, build: buildEvidence, artifact });
|
|
1098
|
+
const run = await this.run(this.context.executable.path, ['--headless', '--path', args.projectPath,
|
|
1099
|
+
'--quit-after', String(args.runTimeoutSeconds ?? 5)], args.projectPath, Math.round(((args.runTimeoutSeconds ?? 5) + 10) * 1000));
|
|
1100
|
+
const expected = typeof args.expectedOutput === 'string' ? args.expectedOutput : null;
|
|
1101
|
+
const matched = expected === null || run.stdout.includes(expected);
|
|
1102
|
+
if (!run.ok || !matched)
|
|
1103
|
+
return createErrorResponse(JSON.stringify({ ...readiness,
|
|
1104
|
+
category: run.timed_out ? 'timeout' : 'run_failed', restore: restoreEvidence,
|
|
1105
|
+
build: buildEvidence, artifact, run: { ...run, expected_output: expected, output_matched: matched } }, null, 2));
|
|
1106
|
+
return this.response({ ...readiness, category: 'success', restore: restoreEvidence,
|
|
1107
|
+
build: buildEvidence, artifact, run: { ...run, expected_output: expected, output_matched: matched } });
|
|
1108
|
+
}
|
|
1109
|
+
projectInfo(projectPath, supplied) {
|
|
1110
|
+
const candidates = typeof supplied === 'string' ? [supplied] : readdirSync(projectPath)
|
|
1111
|
+
.filter(name => name.endsWith('.csproj'));
|
|
1112
|
+
if (candidates.length !== 1)
|
|
1113
|
+
return { error: candidates.length === 0
|
|
1114
|
+
? 'No .csproj was found; pass csprojPath or create a .NET project.'
|
|
1115
|
+
: 'Multiple .csproj files found; pass csprojPath.', category: 'csproj_not_found' };
|
|
1116
|
+
const relativePath = candidates[0].replaceAll('\\', '/');
|
|
1117
|
+
if (!validatePath(relativePath))
|
|
1118
|
+
return { error: 'Invalid csprojPath.', category: 'invalid_path' };
|
|
1119
|
+
const fullPath = this.context.pathSecurity.resolveProjectPath(projectPath, relativePath);
|
|
1120
|
+
if (!fullPath || !existsSync(fullPath))
|
|
1121
|
+
return { error: `Project file does not exist: ${relativePath}`, category: 'csproj_not_found' };
|
|
1122
|
+
const content = readFileSync(fullPath, 'utf8');
|
|
1123
|
+
const sdk = /<Project\s+Sdk="([^"]+)"/.exec(content)?.[1] ?? '';
|
|
1124
|
+
const sdkVersion = /^Godot\.NET\.Sdk\/(.+)$/.exec(sdk)?.[1] ?? '';
|
|
1125
|
+
const target = /<TargetFramework>([^<]+)<\/TargetFramework>/.exec(content)?.[1] ?? '';
|
|
1126
|
+
const assembly = /<RootNamespace>([^<]+)<\/RootNamespace>/.exec(content)?.[1]
|
|
1127
|
+
?? /<AssemblyName>([^<]+)<\/AssemblyName>/.exec(content)?.[1]
|
|
1128
|
+
?? relativePath.replace(/\.csproj$/i, '');
|
|
1129
|
+
if (!sdkVersion)
|
|
1130
|
+
return { error: 'The project does not use a versioned Godot.NET.Sdk.', category: 'invalid_csproj' };
|
|
1131
|
+
return { path: fullPath, relative_path: relativePath, sdk, sdk_version: sdkVersion,
|
|
1132
|
+
target_framework: target, assembly_name: assembly };
|
|
1133
|
+
}
|
|
1134
|
+
diagnostics(result) {
|
|
1135
|
+
const output = `${result.stdout}\n${result.stderr}`;
|
|
1136
|
+
const diagnostics = [];
|
|
1137
|
+
for (const line of output.split(/\r?\n/)) {
|
|
1138
|
+
const match = /^(?:(.+?)\((\d+)(?:,\d+)?\):\s*)?(error|warning)\s+([A-Z]+\d+)?\s*:\s*(.+?)(?:\s+\[[^\]]+\])?$/i.exec(line.trim());
|
|
1139
|
+
if (!match)
|
|
1140
|
+
continue;
|
|
1141
|
+
diagnostics.push({ file: match[1] ?? null, line: match[2] ? Number(match[2]) : null,
|
|
1142
|
+
severity: match[3].toLowerCase(), code: match[4] ?? null, message: match[5] });
|
|
1143
|
+
if (diagnostics.length >= 512)
|
|
1144
|
+
break;
|
|
1145
|
+
}
|
|
1146
|
+
return diagnostics;
|
|
1147
|
+
}
|
|
1148
|
+
assemblyArtifact(projectPath, project, configuration) {
|
|
1149
|
+
const candidates = [
|
|
1150
|
+
join(projectPath, 'bin', configuration, project.target_framework, `${project.assembly_name}.dll`),
|
|
1151
|
+
join(projectPath, '.godot', 'mono', 'temp', 'bin', configuration, `${project.assembly_name}.dll`),
|
|
1152
|
+
join(projectPath, '.godot', 'mono', 'temp', 'bin', configuration, project.target_framework, `${project.assembly_name}.dll`),
|
|
1153
|
+
];
|
|
1154
|
+
const path = candidates.find(existsSync);
|
|
1155
|
+
if (!path)
|
|
1156
|
+
return null;
|
|
1157
|
+
const data = readFileSync(path);
|
|
1158
|
+
return { path: relative(projectPath, path).replaceAll('\\', '/'), bytes: data.length,
|
|
1159
|
+
sha256: createHash('sha256').update(data).digest('hex') };
|
|
1160
|
+
}
|
|
1161
|
+
async run(executable, args, cwd, timeout) {
|
|
1162
|
+
const started = performance.now();
|
|
1163
|
+
try {
|
|
1164
|
+
const { stdout, stderr } = await execFileAsync(executable, args, { cwd, timeout, maxBuffer: 16 * 1024 * 1024 });
|
|
1165
|
+
return { ok: true, exit_code: 0, timed_out: false, duration_ms: Math.round(performance.now() - started),
|
|
1166
|
+
stdout: (stdout ?? '').slice(-256 * 1024), stderr: (stderr ?? '').slice(-256 * 1024) };
|
|
1167
|
+
}
|
|
1168
|
+
catch (error) {
|
|
1169
|
+
const failure = error;
|
|
1170
|
+
return { ok: false, exit_code: failure.code ?? null, timed_out: failure.killed === true || failure.signal != null,
|
|
1171
|
+
duration_ms: Math.round(performance.now() - started), stdout: (failure.stdout ?? '').slice(-256 * 1024),
|
|
1172
|
+
stderr: (failure.stderr ?? '').slice(-256 * 1024) };
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
response(value) {
|
|
1176
|
+
return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
/** Installs hash-pinned local EditorPlugins and validates real editor reloads. */
|
|
1180
|
+
export class AddonManagementService {
|
|
1181
|
+
context;
|
|
1182
|
+
static maxFiles = 2048;
|
|
1183
|
+
static maxBytes = 64 * 1024 * 1024;
|
|
1184
|
+
constructor(context) {
|
|
1185
|
+
this.context = context;
|
|
1186
|
+
}
|
|
1187
|
+
async execute(args) {
|
|
1188
|
+
args = normalizeParameters(args || {});
|
|
1189
|
+
if (!args.projectPath || !args.action || typeof args.pluginName !== 'string') {
|
|
1190
|
+
return createErrorResponse('projectPath, action, and pluginName are required.');
|
|
1191
|
+
}
|
|
1192
|
+
if (!validProject(this.context, args.projectPath))
|
|
1193
|
+
return createErrorResponse('Invalid project path.');
|
|
1194
|
+
if (!/^[A-Za-z0-9_.-]{1,80}$/.test(args.pluginName))
|
|
1195
|
+
return createErrorResponse('Invalid pluginName.');
|
|
1196
|
+
const target = join(args.projectPath, 'addons', args.pluginName);
|
|
1197
|
+
if (args.action === 'inspect')
|
|
1198
|
+
return this.inspect(args.projectPath, args.pluginName, target);
|
|
1199
|
+
if (args.action === 'enable' || args.action === 'disable') {
|
|
1200
|
+
if (!existsSync(join(target, 'plugin.cfg')))
|
|
1201
|
+
return createErrorResponse(`Add-on is not installed: ${args.pluginName}`);
|
|
1202
|
+
return this.setEnabledAndReload(args.projectPath, args.pluginName, args.action === 'enable', args.expectedOutput);
|
|
1203
|
+
}
|
|
1204
|
+
if (args.action === 'remove') {
|
|
1205
|
+
if (!existsSync(target))
|
|
1206
|
+
return createErrorResponse(`Add-on is not installed: ${args.pluginName}`);
|
|
1207
|
+
const disabled = await this.setEnabledAndReload(args.projectPath, args.pluginName, false, undefined);
|
|
1208
|
+
if (disabled.isError)
|
|
1209
|
+
return disabled;
|
|
1210
|
+
rmSync(target, { recursive: true, force: true });
|
|
1211
|
+
return this.response({ action: 'remove', plugin_name: args.pluginName, removed: true, enabled: false });
|
|
1212
|
+
}
|
|
1213
|
+
if (args.action !== 'install' && args.action !== 'update') {
|
|
1214
|
+
return createErrorResponse('action must be inspect, install, update, remove, enable, or disable.');
|
|
1215
|
+
}
|
|
1216
|
+
if (typeof args.sourcePath !== 'string' || typeof args.expectedSha256 !== 'string') {
|
|
1217
|
+
return createErrorResponse('sourcePath and expectedSha256 are required for install/update.');
|
|
1218
|
+
}
|
|
1219
|
+
const source = isAbsolute(args.sourcePath) ? resolve(args.sourcePath)
|
|
1220
|
+
: this.context.pathSecurity.resolveProjectPath(args.projectPath, args.sourcePath);
|
|
1221
|
+
if (!source || !this.context.pathSecurity.isProjectPathAllowed(source) || !existsSync(source) || !lstatSync(source).isDirectory()) {
|
|
1222
|
+
return createErrorResponse('sourcePath must be an allowed local add-on directory.');
|
|
1223
|
+
}
|
|
1224
|
+
const installed = existsSync(target);
|
|
1225
|
+
if (args.action === 'install' && installed)
|
|
1226
|
+
return createErrorResponse(`Add-on is already installed: ${args.pluginName}`);
|
|
1227
|
+
if (args.action === 'update' && !installed)
|
|
1228
|
+
return createErrorResponse(`Add-on is not installed: ${args.pluginName}`);
|
|
1229
|
+
const sourceEvidence = this.hashTree(source);
|
|
1230
|
+
if ('error' in sourceEvidence)
|
|
1231
|
+
return createErrorResponse(sourceEvidence.error);
|
|
1232
|
+
if (sourceEvidence.sha256 !== args.expectedSha256.toLowerCase()) {
|
|
1233
|
+
return createErrorResponse(JSON.stringify({ category: 'hash_mismatch', expected_sha256: args.expectedSha256,
|
|
1234
|
+
actual_sha256: sourceEvidence.sha256, files: sourceEvidence.files, bytes: sourceEvidence.bytes }, null, 2));
|
|
1235
|
+
}
|
|
1236
|
+
const metadata = await this.metadata(source);
|
|
1237
|
+
if ('error' in metadata)
|
|
1238
|
+
return createErrorResponse(JSON.stringify({ category: 'invalid_plugin', message: metadata.error }, null, 2));
|
|
1239
|
+
const compatibility = await this.compatibility(metadata);
|
|
1240
|
+
if (!compatibility.compatible)
|
|
1241
|
+
return createErrorResponse(JSON.stringify({ category: 'incompatible_plugin',
|
|
1242
|
+
metadata, compatibility }, null, 2));
|
|
1243
|
+
const wasEnabled = this.enabledPlugins(args.projectPath).includes(this.pluginPath(args.pluginName));
|
|
1244
|
+
const staging = join(args.projectPath, 'addons', `.${args.pluginName}.staging-${randomUUID()}`);
|
|
1245
|
+
const backup = join(args.projectPath, 'addons', `.${args.pluginName}.backup-${randomUUID()}`);
|
|
1246
|
+
mkdirSync(dirname(staging), { recursive: true });
|
|
1247
|
+
try {
|
|
1248
|
+
this.copyTree(source, staging);
|
|
1249
|
+
if (installed)
|
|
1250
|
+
renameSync(target, backup);
|
|
1251
|
+
renameSync(staging, target);
|
|
1252
|
+
const reload = await this.reload(args.projectPath, wasEnabled ? args.expectedOutput : undefined);
|
|
1253
|
+
if (!reload.ok) {
|
|
1254
|
+
rmSync(target, { recursive: true, force: true });
|
|
1255
|
+
if (installed && existsSync(backup))
|
|
1256
|
+
renameSync(backup, target);
|
|
1257
|
+
return createErrorResponse(JSON.stringify({ category: 'reload_failed', rolled_back: true, reload }, null, 2));
|
|
1258
|
+
}
|
|
1259
|
+
rmSync(backup, { recursive: true, force: true });
|
|
1260
|
+
let enabled = this.enabledPlugins(args.projectPath).includes(this.pluginPath(args.pluginName));
|
|
1261
|
+
if (args.enable === true && !enabled) {
|
|
1262
|
+
const enabledResult = await this.setEnabledAndReload(args.projectPath, args.pluginName, true, args.expectedOutput);
|
|
1263
|
+
if (enabledResult.isError)
|
|
1264
|
+
return enabledResult;
|
|
1265
|
+
enabled = true;
|
|
1266
|
+
}
|
|
1267
|
+
return this.response({ action: args.action, plugin_name: args.pluginName, installed: true, enabled,
|
|
1268
|
+
pin: sourceEvidence, metadata, compatibility, reload });
|
|
1269
|
+
}
|
|
1270
|
+
catch (error) {
|
|
1271
|
+
rmSync(staging, { recursive: true, force: true });
|
|
1272
|
+
if (!existsSync(target) && existsSync(backup))
|
|
1273
|
+
renameSync(backup, target);
|
|
1274
|
+
return createErrorResponse(`Add-on ${args.action} failed: ${errorMessage(error)}`);
|
|
1275
|
+
}
|
|
1276
|
+
finally {
|
|
1277
|
+
rmSync(backup, { recursive: true, force: true });
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
async inspect(projectPath, pluginName, target) {
|
|
1281
|
+
if (!existsSync(target))
|
|
1282
|
+
return this.response({ plugin_name: pluginName, installed: false, enabled: false });
|
|
1283
|
+
const hash = this.hashTree(target);
|
|
1284
|
+
if ('error' in hash)
|
|
1285
|
+
return createErrorResponse(hash.error);
|
|
1286
|
+
const metadata = await this.metadata(target);
|
|
1287
|
+
const compatibility = 'error' in metadata ? null : await this.compatibility(metadata);
|
|
1288
|
+
return this.response({ plugin_name: pluginName, installed: true,
|
|
1289
|
+
enabled: this.enabledPlugins(projectPath).includes(this.pluginPath(pluginName)), pin: hash,
|
|
1290
|
+
...('error' in metadata ? { valid: false, error: metadata.error }
|
|
1291
|
+
: { valid: compatibility?.compatible === true, metadata, compatibility }) });
|
|
1292
|
+
}
|
|
1293
|
+
async metadata(root) {
|
|
1294
|
+
const configPath = join(root, 'plugin.cfg');
|
|
1295
|
+
if (!existsSync(configPath))
|
|
1296
|
+
return { error: 'plugin.cfg is required.' };
|
|
1297
|
+
const values = parseIniDocument(readFileSync(configPath, 'utf8')).plugin;
|
|
1298
|
+
if (!values)
|
|
1299
|
+
return { error: 'plugin.cfg must contain a [plugin] section.' };
|
|
1300
|
+
const script = decodeGodotSetting(values.script);
|
|
1301
|
+
if (typeof script !== 'string' || !validatePath(script) || !existsSync(join(root, script))) {
|
|
1302
|
+
return { error: 'The [plugin] script must reference an existing project-relative file.' };
|
|
1303
|
+
}
|
|
1304
|
+
const source = readFileSync(join(root, script), 'utf8');
|
|
1305
|
+
if (!/@tool\b/.test(source) || !/extends\s+EditorPlugin\b/.test(source)) {
|
|
1306
|
+
return { error: 'The plugin script must use @tool and extend EditorPlugin.' };
|
|
1307
|
+
}
|
|
1308
|
+
return { name: decodeGodotSetting(values.name), description: decodeGodotSetting(values.description),
|
|
1309
|
+
author: decodeGodotSetting(values.author), version: decodeGodotSetting(values.version), script,
|
|
1310
|
+
minimum_godot_version: decodeGodotSetting(values.minimum_godot_version) };
|
|
1311
|
+
}
|
|
1312
|
+
hashTree(root) {
|
|
1313
|
+
const files = [];
|
|
1314
|
+
let bytes = 0;
|
|
1315
|
+
const walk = (directory) => {
|
|
1316
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
1317
|
+
const fullPath = join(directory, entry.name);
|
|
1318
|
+
const stat = lstatSync(fullPath);
|
|
1319
|
+
if (stat.isSymbolicLink())
|
|
1320
|
+
return `Symbolic links are not allowed in add-ons: ${relative(root, fullPath)}`;
|
|
1321
|
+
if (entry.isDirectory()) {
|
|
1322
|
+
const error = walk(fullPath);
|
|
1323
|
+
if (error)
|
|
1324
|
+
return error;
|
|
1325
|
+
}
|
|
1326
|
+
else if (entry.name.endsWith('.uid'))
|
|
1327
|
+
continue;
|
|
1328
|
+
else if (entry.isFile()) {
|
|
1329
|
+
bytes += stat.size;
|
|
1330
|
+
files.push({ relativePath: relative(root, fullPath).replaceAll('\\', '/'), fullPath, bytes: stat.size });
|
|
1331
|
+
if (files.length > AddonManagementService.maxFiles)
|
|
1332
|
+
return `Add-on exceeds ${AddonManagementService.maxFiles} files.`;
|
|
1333
|
+
if (bytes > AddonManagementService.maxBytes)
|
|
1334
|
+
return `Add-on exceeds ${AddonManagementService.maxBytes} bytes.`;
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
return null;
|
|
1338
|
+
};
|
|
1339
|
+
const error = walk(root);
|
|
1340
|
+
if (error)
|
|
1341
|
+
return { error };
|
|
1342
|
+
const hash = createHash('sha256');
|
|
1343
|
+
for (const file of files.sort((a, b) => a.relativePath.localeCompare(b.relativePath))) {
|
|
1344
|
+
hash.update(file.relativePath).update('\0').update(readFileSync(file.fullPath)).update('\0');
|
|
1345
|
+
}
|
|
1346
|
+
return { sha256: hash.digest('hex'), files: files.length, bytes };
|
|
1347
|
+
}
|
|
1348
|
+
copyTree(source, destination) {
|
|
1349
|
+
mkdirSync(destination, { recursive: true });
|
|
1350
|
+
for (const entry of readdirSync(source, { withFileTypes: true })) {
|
|
1351
|
+
const from = join(source, entry.name);
|
|
1352
|
+
const to = join(destination, entry.name);
|
|
1353
|
+
if (entry.isDirectory())
|
|
1354
|
+
this.copyTree(from, to);
|
|
1355
|
+
else if (entry.isFile())
|
|
1356
|
+
copyFileSync(from, to);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
async compatibility(metadata) {
|
|
1360
|
+
if (!this.context.executable.path)
|
|
1361
|
+
await this.context.executable.detect();
|
|
1362
|
+
if (!this.context.executable.path)
|
|
1363
|
+
return { compatible: false, reason: 'godot_not_found' };
|
|
1364
|
+
const versionResult = await this.run(this.context.executable.path, ['--version'], process.cwd(), 10_000);
|
|
1365
|
+
const version = /^(\d+)\.(\d+)/.exec(versionResult.stdout);
|
|
1366
|
+
const minimumRaw = metadata.minimum_godot_version;
|
|
1367
|
+
const minimum = typeof minimumRaw === 'string' ? /^(\d+)\.(\d+)/.exec(minimumRaw) : null;
|
|
1368
|
+
const compatible = versionResult.ok && (!minimum || Boolean(version)
|
|
1369
|
+
&& (Number(version[1]) > Number(minimum[1])
|
|
1370
|
+
|| Number(version[1]) === Number(minimum[1]) && Number(version[2]) >= Number(minimum[2])));
|
|
1371
|
+
return { compatible, engine_version: versionResult.stdout.trim(), minimum_godot_version: minimumRaw ?? null,
|
|
1372
|
+
reason: compatible ? null : minimum ? 'minimum_version_not_met' : 'godot_version_unavailable' };
|
|
1373
|
+
}
|
|
1374
|
+
pluginPath(pluginName) { return `res://addons/${pluginName}/plugin.cfg`; }
|
|
1375
|
+
enabledPlugins(projectPath) {
|
|
1376
|
+
const content = readFileSync(join(projectPath, 'project.godot'), 'utf8');
|
|
1377
|
+
const document = parseIniDocument(content);
|
|
1378
|
+
const value = document.editor_plugins?.enabled;
|
|
1379
|
+
return value ? [...value.matchAll(/"([^"]+)"/g)].map(match => match[1]) : [];
|
|
1380
|
+
}
|
|
1381
|
+
writeEnabledPlugins(projectPath, plugins) {
|
|
1382
|
+
const configPath = join(projectPath, 'project.godot');
|
|
1383
|
+
let content = readFileSync(configPath, 'utf8');
|
|
1384
|
+
const line = `enabled=PackedStringArray(${plugins.map(value => JSON.stringify(value)).join(', ')})`;
|
|
1385
|
+
const section = /\[editor_plugins\][\s\S]*?(?=\n\[|$)/.exec(content);
|
|
1386
|
+
if (!section)
|
|
1387
|
+
content += `\n[editor_plugins]\n\n${line}\n`;
|
|
1388
|
+
else {
|
|
1389
|
+
const updated = /^enabled=.*$/m.test(section[0]) ? section[0].replace(/^enabled=.*$/m, line)
|
|
1390
|
+
: `${section[0].trimEnd()}\n${line}\n`;
|
|
1391
|
+
content = content.slice(0, section.index) + updated + content.slice(section.index + section[0].length);
|
|
1392
|
+
}
|
|
1393
|
+
writeFileSync(configPath, content, 'utf8');
|
|
1394
|
+
}
|
|
1395
|
+
async setEnabledAndReload(projectPath, pluginName, enabled, expectedOutput) {
|
|
1396
|
+
const before = readFileSync(join(projectPath, 'project.godot'), 'utf8');
|
|
1397
|
+
const path = this.pluginPath(pluginName);
|
|
1398
|
+
const plugins = this.enabledPlugins(projectPath).filter(item => item !== path);
|
|
1399
|
+
if (enabled)
|
|
1400
|
+
plugins.push(path);
|
|
1401
|
+
this.writeEnabledPlugins(projectPath, plugins.sort());
|
|
1402
|
+
const reload = await this.reload(projectPath, expectedOutput);
|
|
1403
|
+
if (!reload.ok) {
|
|
1404
|
+
writeFileSync(join(projectPath, 'project.godot'), before, 'utf8');
|
|
1405
|
+
return createErrorResponse(JSON.stringify({ category: 'reload_failed', rolled_back: true, reload }, null, 2));
|
|
1406
|
+
}
|
|
1407
|
+
return this.response({ action: enabled ? 'enable' : 'disable', plugin_name: pluginName, enabled, reload });
|
|
1408
|
+
}
|
|
1409
|
+
async reload(projectPath, expectedOutput) {
|
|
1410
|
+
if (!this.context.executable.path)
|
|
1411
|
+
await this.context.executable.detect();
|
|
1412
|
+
if (!this.context.executable.path)
|
|
1413
|
+
return { ok: false, category: 'godot_not_found' };
|
|
1414
|
+
const result = await this.run(this.context.executable.path, ['--headless', '--editor', '--path', projectPath,
|
|
1415
|
+
'--quit-after', '3'], projectPath, 60_000);
|
|
1416
|
+
const expected = typeof expectedOutput === 'string' ? expectedOutput : null;
|
|
1417
|
+
const matched = expected === null || result.stdout.includes(expected);
|
|
1418
|
+
const rawDiagnostics = `${result.stdout}\n${result.stderr}`.split(/\r?\n/)
|
|
1419
|
+
.filter(line => /SCRIPT ERROR|Parse Error|\bERROR:/i.test(line)).slice(0, 256);
|
|
1420
|
+
const godot44 = result.stdout.includes('Godot Engine v4.4.');
|
|
1421
|
+
const knownDiagnostics = godot44 ? rawDiagnostics.filter(line => /progress dialog \(task\)|tasks\.has\(p_task\)/i.test(line)) : [];
|
|
1422
|
+
const diagnostics = rawDiagnostics.filter(line => !knownDiagnostics.includes(line));
|
|
1423
|
+
return { ...result, ok: result.ok && matched && diagnostics.length === 0,
|
|
1424
|
+
expected_output: expected, output_matched: matched, diagnostics, known_diagnostics: knownDiagnostics };
|
|
1425
|
+
}
|
|
1426
|
+
async run(executable, args, cwd, timeout) {
|
|
1427
|
+
const started = performance.now();
|
|
1428
|
+
try {
|
|
1429
|
+
const { stdout, stderr } = await execFileAsync(executable, args, { cwd, timeout, maxBuffer: 16 * 1024 * 1024 });
|
|
1430
|
+
return { ok: true, exit_code: 0, timed_out: false, duration_ms: Math.round(performance.now() - started),
|
|
1431
|
+
stdout: (stdout ?? '').slice(-128 * 1024), stderr: (stderr ?? '').slice(-128 * 1024) };
|
|
1432
|
+
}
|
|
1433
|
+
catch (error) {
|
|
1434
|
+
const failure = error;
|
|
1435
|
+
return { ok: false, exit_code: failure.code ?? null, timed_out: failure.killed === true || failure.signal != null,
|
|
1436
|
+
duration_ms: Math.round(performance.now() - started), stdout: (failure.stdout ?? '').slice(-128 * 1024),
|
|
1437
|
+
stderr: (failure.stderr ?? '').slice(-128 * 1024) };
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
response(value) {
|
|
1441
|
+
return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
/** Owns the common headless scene-operation delegation. */
|
|
1445
|
+
export class SceneOperationService {
|
|
1446
|
+
context;
|
|
1447
|
+
constructor(context) {
|
|
1448
|
+
this.context = context;
|
|
1449
|
+
}
|
|
1450
|
+
async run(operation, args, params) {
|
|
1451
|
+
args = normalizeParameters(args || {});
|
|
1452
|
+
if (!args.projectPath)
|
|
1453
|
+
return createErrorResponse('projectPath is required.');
|
|
1454
|
+
if (!validProject(this.context, args.projectPath))
|
|
1455
|
+
return createErrorResponse('Invalid path.');
|
|
1456
|
+
return this.context.operations.run(operation, args.projectPath, params);
|
|
1457
|
+
}
|
|
1458
|
+
}
|