uloop-cli 0.64.0 → 0.65.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/README.md +3 -0
- package/dist/cli.bundle.cjs +476 -118
- package/dist/cli.bundle.cjs.map +4 -4
- package/package.json +4 -4
- package/src/__tests__/cli-e2e.test.ts +147 -3
- package/src/__tests__/execute-tool.test.ts +31 -0
- package/src/cli.ts +22 -2
- package/src/commands/launch.ts +3 -0
- package/src/compile-helpers.ts +291 -0
- package/src/default-tools.json +5 -1
- package/src/direct-unity-client.ts +22 -3
- package/src/execute-tool.ts +170 -10
- package/src/version.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uloop-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.65.0",
|
|
4
4
|
"//version": "x-release-please-version",
|
|
5
5
|
"description": "CLI tool for Unity Editor communication via uLoopMCP",
|
|
6
6
|
"main": "dist/cli.bundle.cjs",
|
|
@@ -42,13 +42,13 @@
|
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"commander": "14.0.3",
|
|
45
|
-
"launch-unity": "0.
|
|
45
|
+
"launch-unity": "0.16.0",
|
|
46
46
|
"semver": "7.7.4"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"@eslint/js": "9.39.2",
|
|
50
50
|
"@types/jest": "30.0.0",
|
|
51
|
-
"@types/node": "25.
|
|
51
|
+
"@types/node": "25.3.0",
|
|
52
52
|
"@types/semver": "7.7.1",
|
|
53
53
|
"esbuild": "0.27.3",
|
|
54
54
|
"eslint": "9.39.2",
|
|
@@ -60,6 +60,6 @@
|
|
|
60
60
|
"ts-jest": "29.4.6",
|
|
61
61
|
"tsx": "4.21.0",
|
|
62
62
|
"typescript": "5.9.3",
|
|
63
|
-
"typescript-eslint": "8.
|
|
63
|
+
"typescript-eslint": "8.56.0"
|
|
64
64
|
}
|
|
65
65
|
}
|
|
@@ -7,7 +7,12 @@
|
|
|
7
7
|
* @jest-environment node
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
execSync,
|
|
12
|
+
ExecSyncOptionsWithStringEncoding,
|
|
13
|
+
spawnSync,
|
|
14
|
+
SpawnSyncOptionsWithStringEncoding,
|
|
15
|
+
} from 'child_process';
|
|
11
16
|
import { join } from 'path';
|
|
12
17
|
|
|
13
18
|
const CLI_PATH = join(__dirname, '../..', 'dist/cli.bundle.cjs');
|
|
@@ -21,6 +26,13 @@ const EXEC_OPTIONS: ExecSyncOptionsWithStringEncoding = {
|
|
|
21
26
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
22
27
|
};
|
|
23
28
|
|
|
29
|
+
const SPAWN_OPTIONS: SpawnSyncOptionsWithStringEncoding = {
|
|
30
|
+
encoding: 'utf-8',
|
|
31
|
+
timeout: 60000,
|
|
32
|
+
cwd: UNITY_PROJECT_ROOT,
|
|
33
|
+
stdio: 'pipe',
|
|
34
|
+
};
|
|
35
|
+
|
|
24
36
|
const INTERVAL_MS = 1500;
|
|
25
37
|
const DOMAIN_RELOAD_RETRY_MS = 3000;
|
|
26
38
|
const DOMAIN_RELOAD_MAX_RETRIES = 3;
|
|
@@ -54,6 +66,15 @@ function runCli(args: string): { stdout: string; stderr: string; exitCode: numbe
|
|
|
54
66
|
}
|
|
55
67
|
}
|
|
56
68
|
|
|
69
|
+
function runCliParts(args: string[]): { stdout: string; stderr: string; exitCode: number } {
|
|
70
|
+
const result = spawnSync('node', [CLI_PATH, ...args], SPAWN_OPTIONS);
|
|
71
|
+
return {
|
|
72
|
+
stdout: result.stdout ?? '',
|
|
73
|
+
stderr: result.stderr ?? '',
|
|
74
|
+
exitCode: result.status ?? 1,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
57
78
|
function runCliWithRetry(args: string): { stdout: string; stderr: string; exitCode: number } {
|
|
58
79
|
for (let attempt = 0; attempt < DOMAIN_RELOAD_MAX_RETRIES; attempt++) {
|
|
59
80
|
const result = runCli(args);
|
|
@@ -72,12 +93,44 @@ function runCliWithRetry(args: string): { stdout: string; stderr: string; exitCo
|
|
|
72
93
|
return runCli(args);
|
|
73
94
|
}
|
|
74
95
|
|
|
96
|
+
function runCliWithRetryParts(args: string[]): {
|
|
97
|
+
stdout: string;
|
|
98
|
+
stderr: string;
|
|
99
|
+
exitCode: number;
|
|
100
|
+
} {
|
|
101
|
+
for (let attempt = 0; attempt < DOMAIN_RELOAD_MAX_RETRIES; attempt++) {
|
|
102
|
+
const result = runCliParts(args);
|
|
103
|
+
const output = result.stderr || result.stdout;
|
|
104
|
+
|
|
105
|
+
if (result.exitCode === 0 || !isDomainReloadError(output)) {
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (attempt < DOMAIN_RELOAD_MAX_RETRIES - 1) {
|
|
110
|
+
sleepSync(DOMAIN_RELOAD_RETRY_MS);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return runCliParts(args);
|
|
115
|
+
}
|
|
116
|
+
|
|
75
117
|
function runCliJson<T>(args: string): T {
|
|
76
118
|
const { stdout, stderr, exitCode } = runCliWithRetry(args);
|
|
77
119
|
if (exitCode !== 0) {
|
|
78
120
|
throw new Error(`CLI failed with exit code ${exitCode}: ${stderr || stdout}`);
|
|
79
121
|
}
|
|
80
|
-
|
|
122
|
+
|
|
123
|
+
const trimmedOutput = stdout.trim();
|
|
124
|
+
const jsonStartByLine = trimmedOutput.lastIndexOf('\n{');
|
|
125
|
+
const jsonStart = jsonStartByLine >= 0 ? jsonStartByLine + 1 : trimmedOutput.indexOf('{');
|
|
126
|
+
const jsonEnd = trimmedOutput.lastIndexOf('}');
|
|
127
|
+
|
|
128
|
+
if (jsonStart < 0 || jsonEnd < 0 || jsonEnd < jsonStart) {
|
|
129
|
+
throw new Error(`JSON payload not found in CLI output: ${trimmedOutput}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const jsonPayload = trimmedOutput.slice(jsonStart, jsonEnd + 1);
|
|
133
|
+
return JSON.parse(jsonPayload) as T;
|
|
81
134
|
}
|
|
82
135
|
|
|
83
136
|
describe('CLI E2E Tests (requires running Unity)', () => {
|
|
@@ -97,6 +150,7 @@ describe('CLI E2E Tests (requires running Unity)', () => {
|
|
|
97
150
|
describe('get-logs', () => {
|
|
98
151
|
const TEST_LOG_MENU_PATH = 'uLoopMCP/Debug/LogGetter Tests/Output Test Logs';
|
|
99
152
|
const MENU_ITEM_WAIT_MS = 1000;
|
|
153
|
+
const ERROR_FAMILY_PREFIX = 'CliE2EErrorFamily';
|
|
100
154
|
|
|
101
155
|
function setupTestLogs(): void {
|
|
102
156
|
runCliWithRetry('clear-console');
|
|
@@ -107,6 +161,38 @@ describe('CLI E2E Tests (requires running Unity)', () => {
|
|
|
107
161
|
sleepSync(MENU_ITEM_WAIT_MS);
|
|
108
162
|
}
|
|
109
163
|
|
|
164
|
+
function setupErrorFamilyLogs(token: string): void {
|
|
165
|
+
runCliWithRetry('clear-console');
|
|
166
|
+
const code = [
|
|
167
|
+
'using UnityEngine;',
|
|
168
|
+
'using System;',
|
|
169
|
+
`Debug.LogError("${ERROR_FAMILY_PREFIX}_Error_${token}");`,
|
|
170
|
+
`Debug.LogException(new InvalidOperationException("${ERROR_FAMILY_PREFIX}_Exception_${token}"));`,
|
|
171
|
+
`Debug.LogAssertion("${ERROR_FAMILY_PREFIX}_Assert_${token}");`,
|
|
172
|
+
`Debug.LogWarning("${ERROR_FAMILY_PREFIX}_Warning_${token}");`,
|
|
173
|
+
].join(' ');
|
|
174
|
+
const result = runCliWithRetryParts(['execute-dynamic-code', '--code', code]);
|
|
175
|
+
if (result.exitCode !== 0) {
|
|
176
|
+
throw new Error(`execute-dynamic-code failed: ${result.stderr || result.stdout}`);
|
|
177
|
+
}
|
|
178
|
+
sleepSync(MENU_ITEM_WAIT_MS);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function setupAssertTextLogs(token: string): void {
|
|
182
|
+
runCliWithRetry('clear-console');
|
|
183
|
+
const code = [
|
|
184
|
+
'using UnityEngine;',
|
|
185
|
+
`Debug.Log("Please assert your identity ${token}");`,
|
|
186
|
+
`Debug.LogWarning("All assertions passed ${token}");`,
|
|
187
|
+
`Debug.LogError("${ERROR_FAMILY_PREFIX}_ErrorOnly_${token}");`,
|
|
188
|
+
].join(' ');
|
|
189
|
+
const result = runCliWithRetryParts(['execute-dynamic-code', '--code', code]);
|
|
190
|
+
if (result.exitCode !== 0) {
|
|
191
|
+
throw new Error(`execute-dynamic-code failed: ${result.stderr || result.stdout}`);
|
|
192
|
+
}
|
|
193
|
+
sleepSync(MENU_ITEM_WAIT_MS);
|
|
194
|
+
}
|
|
195
|
+
|
|
110
196
|
it('should retrieve test logs after executing Output Test Logs menu item', () => {
|
|
111
197
|
setupTestLogs();
|
|
112
198
|
|
|
@@ -163,6 +249,64 @@ describe('CLI E2E Tests (requires running Unity)', () => {
|
|
|
163
249
|
expect(messages.some((m) => m.includes('This is an error log'))).toBe(true);
|
|
164
250
|
});
|
|
165
251
|
|
|
252
|
+
it('should filter by lowercase log type error', () => {
|
|
253
|
+
setupTestLogs();
|
|
254
|
+
|
|
255
|
+
const result = runCliJson<{ Logs: Array<{ Type: string; Message: string }> }>(
|
|
256
|
+
'get-logs --log-type error',
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
expect(result.Logs.length).toBeGreaterThan(0);
|
|
260
|
+
for (const log of result.Logs) {
|
|
261
|
+
expect(log.Type).toBe('Error');
|
|
262
|
+
}
|
|
263
|
+
const messages = result.Logs.map((log) => log.Message);
|
|
264
|
+
expect(messages.some((m) => m.includes('This is an error log'))).toBe(true);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('should include error and exception logs in Error filter', () => {
|
|
268
|
+
const token = `${Date.now()}`;
|
|
269
|
+
setupErrorFamilyLogs(token);
|
|
270
|
+
|
|
271
|
+
const result = runCliJson<{ Logs: Array<{ Type: string; Message: string }> }>(
|
|
272
|
+
`get-logs --log-type Error --search-text "${token}" --max-count 20`,
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
expect(result.Logs.length).toBeGreaterThanOrEqual(2);
|
|
276
|
+
for (const log of result.Logs) {
|
|
277
|
+
expect(log.Type).toBe('Error');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const messages = result.Logs.map((log) => log.Message);
|
|
281
|
+
expect(messages.some((m) => m.includes(`${ERROR_FAMILY_PREFIX}_Error_${token}`))).toBe(true);
|
|
282
|
+
expect(messages.some((m) => m.includes(`${ERROR_FAMILY_PREFIX}_Exception_${token}`))).toBe(
|
|
283
|
+
true,
|
|
284
|
+
);
|
|
285
|
+
expect(messages.some((m) => m.includes(`${ERROR_FAMILY_PREFIX}_Warning_${token}`))).toBe(
|
|
286
|
+
false,
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('should not include plain assert text logs in Error filter', () => {
|
|
291
|
+
const token = `${Date.now()}`;
|
|
292
|
+
setupAssertTextLogs(token);
|
|
293
|
+
|
|
294
|
+
const result = runCliJson<{ Logs: Array<{ Type: string; Message: string }> }>(
|
|
295
|
+
`get-logs --log-type Error --search-text "${token}" --max-count 20`,
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
for (const log of result.Logs) {
|
|
299
|
+
expect(log.Type).toBe('Error');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const messages = result.Logs.map((log) => log.Message);
|
|
303
|
+
expect(messages.some((m) => m.includes(`${ERROR_FAMILY_PREFIX}_ErrorOnly_${token}`))).toBe(
|
|
304
|
+
true,
|
|
305
|
+
);
|
|
306
|
+
expect(messages.some((m) => m.includes(`Please assert your identity ${token}`))).toBe(false);
|
|
307
|
+
expect(messages.some((m) => m.includes(`All assertions passed ${token}`))).toBe(false);
|
|
308
|
+
});
|
|
309
|
+
|
|
166
310
|
it('should search logs by text', () => {
|
|
167
311
|
setupTestLogs();
|
|
168
312
|
|
|
@@ -487,7 +631,7 @@ describe('CLI E2E Tests (requires running Unity)', () => {
|
|
|
487
631
|
const { stdout, exitCode } = runCli('launch --help');
|
|
488
632
|
|
|
489
633
|
expect(exitCode).toBe(0);
|
|
490
|
-
expect(stdout).toContain('
|
|
634
|
+
expect(stdout).toContain('Open a Unity project');
|
|
491
635
|
expect(stdout).toContain('--restart');
|
|
492
636
|
expect(stdout).toContain('--platform');
|
|
493
637
|
expect(stdout).toContain('--max-depth');
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { isTransportDisconnectError } from '../execute-tool.js';
|
|
2
|
+
|
|
3
|
+
describe('isTransportDisconnectError', () => {
|
|
4
|
+
it('returns true for UNITY_NO_RESPONSE', () => {
|
|
5
|
+
expect(isTransportDisconnectError(new Error('UNITY_NO_RESPONSE'))).toBe(true);
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
it('returns true for Connection lost with details', () => {
|
|
9
|
+
expect(isTransportDisconnectError(new Error('Connection lost: read ECONNRESET'))).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('returns true for Connection lost with EPIPE', () => {
|
|
13
|
+
expect(isTransportDisconnectError(new Error('Connection lost: write EPIPE'))).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('returns false for JSON-RPC error from Unity', () => {
|
|
17
|
+
expect(isTransportDisconnectError(new Error('Unity error: compilation failed'))).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns false for connection refused (pre-dispatch error)', () => {
|
|
21
|
+
expect(isTransportDisconnectError(new Error('connect ECONNREFUSED 127.0.0.1:8711'))).toBe(
|
|
22
|
+
false,
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('returns false for non-Error values', () => {
|
|
27
|
+
expect(isTransportDisconnectError('UNITY_NO_RESPONSE')).toBe(false);
|
|
28
|
+
expect(isTransportDisconnectError(null)).toBe(false);
|
|
29
|
+
expect(isTransportDisconnectError(undefined)).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
});
|
package/src/cli.ts
CHANGED
|
@@ -756,6 +756,25 @@ function shouldSkipAutoSync(cmdName: string | undefined, args: string[]): boolea
|
|
|
756
756
|
return args.some((arg) => (NO_SYNC_FLAGS as readonly string[]).includes(arg));
|
|
757
757
|
}
|
|
758
758
|
|
|
759
|
+
function extractSyncGlobalOptions(args: string[]): GlobalOptions {
|
|
760
|
+
for (let i = 0; i < args.length; i++) {
|
|
761
|
+
const arg = args[i];
|
|
762
|
+
if (arg === '--port' || arg === '-p') {
|
|
763
|
+
const nextArg = args[i + 1];
|
|
764
|
+
if (nextArg !== undefined && !nextArg.startsWith('-')) {
|
|
765
|
+
return { port: nextArg };
|
|
766
|
+
}
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
if (arg.startsWith('--port=')) {
|
|
771
|
+
return { port: arg.slice('--port='.length) };
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
return {};
|
|
776
|
+
}
|
|
777
|
+
|
|
759
778
|
/**
|
|
760
779
|
* Main entry point with auto-sync for unknown commands.
|
|
761
780
|
*/
|
|
@@ -766,6 +785,7 @@ async function main(): Promise<void> {
|
|
|
766
785
|
|
|
767
786
|
const args = process.argv.slice(2);
|
|
768
787
|
const cmdName = args.find((arg) => !arg.startsWith('-'));
|
|
788
|
+
const syncGlobalOptions = extractSyncGlobalOptions(args);
|
|
769
789
|
|
|
770
790
|
if (!shouldSkipAutoSync(cmdName, args)) {
|
|
771
791
|
// Check if cache version is outdated and auto-sync if needed
|
|
@@ -775,7 +795,7 @@ async function main(): Promise<void> {
|
|
|
775
795
|
`\x1b[33mCache outdated (${cachedVersion} → ${VERSION}). Syncing tools from Unity...\x1b[0m`,
|
|
776
796
|
);
|
|
777
797
|
try {
|
|
778
|
-
await syncTools(
|
|
798
|
+
await syncTools(syncGlobalOptions);
|
|
779
799
|
console.log('\x1b[32m✓ Tools synced successfully.\x1b[0m\n');
|
|
780
800
|
} catch (error) {
|
|
781
801
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -800,7 +820,7 @@ async function main(): Promise<void> {
|
|
|
800
820
|
if (cmdName && !commandExists(cmdName)) {
|
|
801
821
|
console.log(`\x1b[33mUnknown command '${cmdName}'. Syncing tools from Unity...\x1b[0m`);
|
|
802
822
|
try {
|
|
803
|
-
await syncTools(
|
|
823
|
+
await syncTools(syncGlobalOptions);
|
|
804
824
|
const newCache = loadToolsCache();
|
|
805
825
|
const tool = newCache.tools.find((t) => t.name === cmdName);
|
|
806
826
|
if (tool) {
|
package/src/commands/launch.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { orchestrateLaunch } from 'launch-unity';
|
|
|
14
14
|
interface LaunchCommandOptions {
|
|
15
15
|
restart?: boolean;
|
|
16
16
|
quit?: boolean;
|
|
17
|
+
deleteRecovery?: boolean;
|
|
17
18
|
platform?: string;
|
|
18
19
|
maxDepth?: string;
|
|
19
20
|
addUnityHub?: boolean;
|
|
@@ -30,6 +31,7 @@ export function registerLaunchCommand(program: Command): void {
|
|
|
30
31
|
)
|
|
31
32
|
.argument('[project-path]', 'Path to Unity project')
|
|
32
33
|
.option('-r, --restart', 'Kill running Unity and restart')
|
|
34
|
+
.option('-d, --delete-recovery', 'Delete Assets/_Recovery before launch')
|
|
33
35
|
.option('-q, --quit', 'Gracefully quit running Unity')
|
|
34
36
|
.option('-p, --platform <platform>', 'Build target (e.g., Android, iOS)')
|
|
35
37
|
.option('--max-depth <n>', 'Search depth when project-path is omitted', '3')
|
|
@@ -66,6 +68,7 @@ async function runLaunchCommand(
|
|
|
66
68
|
unityArgs: [],
|
|
67
69
|
restart: options.restart === true,
|
|
68
70
|
quit: options.quit === true,
|
|
71
|
+
deleteRecovery: options.deleteRecovery === true,
|
|
69
72
|
addUnityHub: options.addUnityHub === true,
|
|
70
73
|
favoriteUnityHub: options.favorite === true,
|
|
71
74
|
});
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compile-related helpers duplicated from TypeScriptServer~ to avoid cross-project imports
|
|
3
|
+
* that violate CLI's rootDir boundary (TS6059).
|
|
4
|
+
*
|
|
5
|
+
* Source: TypeScriptServer~/src/compile/compile-domain-reload-helpers.ts
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// CLI tools output to console by design, object keys come from Unity tool responses which are trusted,
|
|
9
|
+
// and lock file paths are constructed from trusted project root detection
|
|
10
|
+
/* eslint-disable security/detect-object-injection, security/detect-non-literal-fs-filename */
|
|
11
|
+
|
|
12
|
+
import assert from 'node:assert';
|
|
13
|
+
import { existsSync, readFileSync } from 'fs';
|
|
14
|
+
import * as net from 'net';
|
|
15
|
+
import { join } from 'path';
|
|
16
|
+
|
|
17
|
+
// Only alphanumeric, underscore, and hyphen — blocks path separators and traversal sequences
|
|
18
|
+
const SAFE_REQUEST_ID_PATTERN: RegExp = /^[a-zA-Z0-9_-]+$/;
|
|
19
|
+
|
|
20
|
+
export const COMPILE_FORCE_RECOMPILE_ARG_KEYS = [
|
|
21
|
+
'ForceRecompile',
|
|
22
|
+
'forceRecompile',
|
|
23
|
+
'force_recompile',
|
|
24
|
+
'force-recompile',
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
export const COMPILE_WAIT_FOR_DOMAIN_RELOAD_ARG_KEYS = [
|
|
28
|
+
'WaitForDomainReload',
|
|
29
|
+
'waitForDomainReload',
|
|
30
|
+
'wait_for_domain_reload',
|
|
31
|
+
'wait-for-domain-reload',
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
export interface CompileExecutionOptions {
|
|
35
|
+
forceRecompile: boolean;
|
|
36
|
+
waitForDomainReload: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CompileCompletionWaitOptions {
|
|
40
|
+
projectRoot: string;
|
|
41
|
+
requestId: string;
|
|
42
|
+
timeoutMs: number;
|
|
43
|
+
pollIntervalMs: number;
|
|
44
|
+
unityPort?: number;
|
|
45
|
+
isUnityReadyWhenIdle?: () => Promise<boolean>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type CompileCompletionOutcome = 'completed' | 'timed_out';
|
|
49
|
+
|
|
50
|
+
export interface CompileCompletionResult<T> {
|
|
51
|
+
outcome: CompileCompletionOutcome;
|
|
52
|
+
result?: T;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Between compilationFinished and beforeAssemblyReload, there is a brief gap (~50ms measured)
|
|
57
|
+
* where no lock files exist. This grace period prevents false "completed" detection during that gap.
|
|
58
|
+
*/
|
|
59
|
+
const LOCK_GRACE_PERIOD_MS = 500;
|
|
60
|
+
|
|
61
|
+
const READINESS_CHECK_TIMEOUT_MS = 3000;
|
|
62
|
+
const DEFAULT_HOST = '127.0.0.1';
|
|
63
|
+
const CONTENT_LENGTH_HEADER = 'Content-Length:';
|
|
64
|
+
const HEADER_SEPARATOR = '\r\n\r\n';
|
|
65
|
+
|
|
66
|
+
function toBoolean(value: unknown): boolean {
|
|
67
|
+
if (typeof value === 'boolean') {
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (typeof value === 'string') {
|
|
72
|
+
return value.toLowerCase() === 'true';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getCompileBooleanArg(args: Record<string, unknown>, keys: readonly string[]): boolean {
|
|
79
|
+
for (const key of keys) {
|
|
80
|
+
if (!(key in args)) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return toBoolean(args[key]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function resolveCompileExecutionOptions(
|
|
91
|
+
args: Record<string, unknown>,
|
|
92
|
+
): CompileExecutionOptions {
|
|
93
|
+
return {
|
|
94
|
+
forceRecompile: getCompileBooleanArg(args, COMPILE_FORCE_RECOMPILE_ARG_KEYS),
|
|
95
|
+
waitForDomainReload: getCompileBooleanArg(args, COMPILE_WAIT_FOR_DOMAIN_RELOAD_ARG_KEYS),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function createCompileRequestId(): string {
|
|
100
|
+
const timestamp = Date.now();
|
|
101
|
+
const randomToken = Math.floor(Math.random() * 1000000)
|
|
102
|
+
.toString()
|
|
103
|
+
.padStart(6, '0');
|
|
104
|
+
return `compile_${timestamp}_${randomToken}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function ensureCompileRequestId(args: Record<string, unknown>): string {
|
|
108
|
+
const existingRequestId = args['RequestId'];
|
|
109
|
+
if (typeof existingRequestId === 'string' && existingRequestId.length > 0) {
|
|
110
|
+
if (SAFE_REQUEST_ID_PATTERN.test(existingRequestId)) {
|
|
111
|
+
return existingRequestId;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const requestId: string = createCompileRequestId();
|
|
116
|
+
args['RequestId'] = requestId;
|
|
117
|
+
return requestId;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function getCompileResultFilePath(projectRoot: string, requestId: string): string {
|
|
121
|
+
assert(
|
|
122
|
+
SAFE_REQUEST_ID_PATTERN.test(requestId),
|
|
123
|
+
`requestId contains unsafe characters: '${requestId}'`,
|
|
124
|
+
);
|
|
125
|
+
return join(projectRoot, 'Temp', 'uLoopMCP', 'compile-results', `${requestId}.json`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isUnityBusyByLockFiles(projectRoot: string): boolean {
|
|
129
|
+
const compilingLockPath = join(projectRoot, 'Temp', 'compiling.lock');
|
|
130
|
+
if (existsSync(compilingLockPath)) {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const domainReloadLockPath = join(projectRoot, 'Temp', 'domainreload.lock');
|
|
135
|
+
if (existsSync(domainReloadLockPath)) {
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const serverStartingLockPath = join(projectRoot, 'Temp', 'serverstarting.lock');
|
|
140
|
+
return existsSync(serverStartingLockPath);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function stripUtf8Bom(content: string): string {
|
|
144
|
+
if (content.charCodeAt(0) === 0xfeff) {
|
|
145
|
+
return content.slice(1);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return content;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function tryReadCompileResult<T>(projectRoot: string, requestId: string): T | undefined {
|
|
152
|
+
const resultFilePath = getCompileResultFilePath(projectRoot, requestId);
|
|
153
|
+
if (!existsSync(resultFilePath)) {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const content = readFileSync(resultFilePath, 'utf-8');
|
|
159
|
+
const parsed = JSON.parse(stripUtf8Bom(content)) as unknown;
|
|
160
|
+
return parsed as T;
|
|
161
|
+
} catch {
|
|
162
|
+
// File may be partially written by Unity or deleted between existsSync and readFileSync (TOCTOU).
|
|
163
|
+
// Return undefined so the polling loop retries on the next tick.
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Verify Unity server can actually process a JSON-RPC request, not just accept TCP.
|
|
170
|
+
* Sends a lightweight get-tool-details request and waits for any valid framed response.
|
|
171
|
+
*/
|
|
172
|
+
function canSendRequestToUnity(port: number): Promise<boolean> {
|
|
173
|
+
return new Promise((resolve) => {
|
|
174
|
+
const socket = new net.Socket();
|
|
175
|
+
const timer = setTimeout(() => {
|
|
176
|
+
socket.destroy();
|
|
177
|
+
resolve(false);
|
|
178
|
+
}, READINESS_CHECK_TIMEOUT_MS);
|
|
179
|
+
|
|
180
|
+
const cleanup = (): void => {
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
socket.destroy();
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
socket.connect(port, DEFAULT_HOST, () => {
|
|
186
|
+
const rpcRequest: string = JSON.stringify({
|
|
187
|
+
jsonrpc: '2.0',
|
|
188
|
+
method: 'get-tool-details',
|
|
189
|
+
params: { IncludeDevelopmentOnly: false },
|
|
190
|
+
id: 0,
|
|
191
|
+
});
|
|
192
|
+
const contentLength: number = Buffer.byteLength(rpcRequest, 'utf8');
|
|
193
|
+
const frame: string = `${CONTENT_LENGTH_HEADER} ${contentLength}${HEADER_SEPARATOR}${rpcRequest}`;
|
|
194
|
+
socket.write(frame);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
let buffer: Buffer = Buffer.alloc(0);
|
|
198
|
+
socket.on('data', (chunk: Buffer) => {
|
|
199
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
200
|
+
const sepIndex: number = buffer.indexOf(HEADER_SEPARATOR);
|
|
201
|
+
if (sepIndex !== -1) {
|
|
202
|
+
cleanup();
|
|
203
|
+
resolve(true);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
socket.on('error', () => {
|
|
208
|
+
cleanup();
|
|
209
|
+
resolve(false);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
socket.on('close', () => {
|
|
213
|
+
clearTimeout(timer);
|
|
214
|
+
resolve(false);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function sleep(ms: number): Promise<void> {
|
|
220
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Wait until the compile result file exists AND all Unity lock files are gone.
|
|
225
|
+
* After the result file appears and locks disappear, waits an additional grace period
|
|
226
|
+
* to catch the gap between compilationFinished and beforeAssemblyReload (~50ms measured).
|
|
227
|
+
*/
|
|
228
|
+
export async function waitForCompileCompletion<T>(
|
|
229
|
+
options: CompileCompletionWaitOptions,
|
|
230
|
+
): Promise<CompileCompletionResult<T>> {
|
|
231
|
+
const startTime: number = Date.now();
|
|
232
|
+
let idleSinceTimestamp: number | null = null;
|
|
233
|
+
|
|
234
|
+
while (Date.now() - startTime < options.timeoutMs) {
|
|
235
|
+
const result: T | undefined = tryReadCompileResult<T>(options.projectRoot, options.requestId);
|
|
236
|
+
const isBusy: boolean = isUnityBusyByLockFiles(options.projectRoot);
|
|
237
|
+
|
|
238
|
+
if (result !== undefined && !isBusy) {
|
|
239
|
+
const now: number = Date.now();
|
|
240
|
+
if (idleSinceTimestamp === null) {
|
|
241
|
+
idleSinceTimestamp = now;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const idleDuration: number = now - idleSinceTimestamp;
|
|
245
|
+
if (idleDuration >= LOCK_GRACE_PERIOD_MS) {
|
|
246
|
+
if (options.unityPort !== undefined) {
|
|
247
|
+
const isReady: boolean = await canSendRequestToUnity(options.unityPort);
|
|
248
|
+
if (isReady) {
|
|
249
|
+
return { outcome: 'completed', result };
|
|
250
|
+
}
|
|
251
|
+
} else if (options.isUnityReadyWhenIdle) {
|
|
252
|
+
const isReady: boolean = await options.isUnityReadyWhenIdle();
|
|
253
|
+
if (isReady) {
|
|
254
|
+
return { outcome: 'completed', result };
|
|
255
|
+
}
|
|
256
|
+
} else {
|
|
257
|
+
return { outcome: 'completed', result };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
} else {
|
|
261
|
+
idleSinceTimestamp = null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
await sleep(options.pollIntervalMs);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const lastResult: T | undefined = tryReadCompileResult<T>(options.projectRoot, options.requestId);
|
|
268
|
+
if (lastResult !== undefined && !isUnityBusyByLockFiles(options.projectRoot)) {
|
|
269
|
+
// Guard the compilationFinished→beforeAssemblyReload gap, same as the main loop
|
|
270
|
+
await sleep(LOCK_GRACE_PERIOD_MS);
|
|
271
|
+
if (isUnityBusyByLockFiles(options.projectRoot)) {
|
|
272
|
+
return { outcome: 'timed_out' };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (options.unityPort !== undefined) {
|
|
276
|
+
const isReady: boolean = await canSendRequestToUnity(options.unityPort);
|
|
277
|
+
if (isReady) {
|
|
278
|
+
return { outcome: 'completed', result: lastResult };
|
|
279
|
+
}
|
|
280
|
+
} else if (options.isUnityReadyWhenIdle) {
|
|
281
|
+
const isReady: boolean = await options.isUnityReadyWhenIdle();
|
|
282
|
+
if (isReady) {
|
|
283
|
+
return { outcome: 'completed', result: lastResult };
|
|
284
|
+
}
|
|
285
|
+
} else {
|
|
286
|
+
return { outcome: 'completed', result: lastResult };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return { outcome: 'timed_out' };
|
|
291
|
+
}
|
package/src/default-tools.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
2
|
+
"version": "0.65.0",
|
|
3
3
|
"tools": [
|
|
4
4
|
{
|
|
5
5
|
"name": "compile",
|
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
"ForceRecompile": {
|
|
11
11
|
"type": "boolean",
|
|
12
12
|
"description": "Force full recompilation"
|
|
13
|
+
},
|
|
14
|
+
"WaitForDomainReload": {
|
|
15
|
+
"type": "boolean",
|
|
16
|
+
"description": "Wait for domain reload completion before returning"
|
|
13
17
|
}
|
|
14
18
|
}
|
|
15
19
|
}
|