uloop-cli 0.64.1 → 0.66.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 +567 -147
- package/dist/cli.bundle.cjs.map +4 -4
- package/package.json +5 -5
- package/src/__tests__/execute-tool.test.ts +31 -0
- package/src/__tests__/port-resolver.test.ts +31 -1
- package/src/cli.ts +54 -8
- package/src/commands/focus-window.ts +20 -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 +184 -19
- package/src/port-resolver.ts +42 -4
- package/src/project-root.ts +2 -2
- package/src/skills/skill-definitions/cli-only/uloop-focus-window/SKILL.md +7 -0
- 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.66.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,24 +42,24 @@
|
|
|
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",
|
|
55
55
|
"eslint-config-prettier": "10.1.8",
|
|
56
56
|
"eslint-plugin-prettier": "5.5.5",
|
|
57
|
-
"eslint-plugin-security": "
|
|
57
|
+
"eslint-plugin-security": "4.0.0",
|
|
58
58
|
"jest": "30.2.0",
|
|
59
59
|
"prettier": "3.8.1",
|
|
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
|
}
|
|
@@ -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
|
+
});
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { tmpdir } from 'os';
|
|
2
|
+
import {
|
|
3
|
+
resolvePortFromUnitySettings,
|
|
4
|
+
validateProjectPath,
|
|
5
|
+
resolveUnityPort,
|
|
6
|
+
} from '../port-resolver.js';
|
|
2
7
|
|
|
3
8
|
describe('resolvePortFromUnitySettings', () => {
|
|
4
9
|
it('returns serverPort when server is running and serverPort is valid', () => {
|
|
@@ -61,3 +66,28 @@ describe('resolvePortFromUnitySettings', () => {
|
|
|
61
66
|
expect(port).toBeNull();
|
|
62
67
|
});
|
|
63
68
|
});
|
|
69
|
+
|
|
70
|
+
describe('validateProjectPath', () => {
|
|
71
|
+
it('throws when path does not exist', () => {
|
|
72
|
+
expect(() => validateProjectPath('/nonexistent/path/to/project')).toThrow(
|
|
73
|
+
'Path does not exist: /nonexistent/path/to/project',
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('throws when path is not a Unity project', () => {
|
|
78
|
+
expect(() => validateProjectPath(tmpdir())).toThrow('Not a Unity project');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('resolveUnityPort', () => {
|
|
83
|
+
it('throws when both port and projectPath are specified', async () => {
|
|
84
|
+
await expect(resolveUnityPort(8700, '/some/path')).rejects.toThrow(
|
|
85
|
+
'Cannot specify both --port and --project-path',
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('returns explicit port when only port is specified', async () => {
|
|
90
|
+
const port = await resolveUnityPort(8711);
|
|
91
|
+
expect(port).toBe(8711);
|
|
92
|
+
});
|
|
93
|
+
});
|
package/src/cli.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { registerLaunchCommand } from './commands/launch.js';
|
|
|
33
33
|
import { registerFocusWindowCommand } from './commands/focus-window.js';
|
|
34
34
|
import { VERSION } from './version.js';
|
|
35
35
|
import { findUnityProjectRoot } from './project-root.js';
|
|
36
|
+
import { validateProjectPath } from './port-resolver.js';
|
|
36
37
|
|
|
37
38
|
interface CliOptions extends GlobalOptions {
|
|
38
39
|
[key: string]: unknown;
|
|
@@ -73,16 +74,18 @@ program
|
|
|
73
74
|
.command('list')
|
|
74
75
|
.description('List all available tools from Unity')
|
|
75
76
|
.option('-p, --port <port>', 'Unity TCP port')
|
|
77
|
+
.option('--project-path <path>', 'Unity project path')
|
|
76
78
|
.action(async (options: CliOptions) => {
|
|
77
|
-
await runWithErrorHandling(() => listAvailableTools(options));
|
|
79
|
+
await runWithErrorHandling(() => listAvailableTools(extractGlobalOptions(options)));
|
|
78
80
|
});
|
|
79
81
|
|
|
80
82
|
program
|
|
81
83
|
.command('sync')
|
|
82
84
|
.description('Sync tool definitions from Unity to local cache')
|
|
83
85
|
.option('-p, --port <port>', 'Unity TCP port')
|
|
86
|
+
.option('--project-path <path>', 'Unity project path')
|
|
84
87
|
.action(async (options: CliOptions) => {
|
|
85
|
-
await runWithErrorHandling(() => syncTools(options));
|
|
88
|
+
await runWithErrorHandling(() => syncTools(extractGlobalOptions(options)));
|
|
86
89
|
});
|
|
87
90
|
|
|
88
91
|
program
|
|
@@ -104,8 +107,12 @@ program
|
|
|
104
107
|
program
|
|
105
108
|
.command('fix')
|
|
106
109
|
.description('Clean up stale lock files that may prevent CLI from connecting')
|
|
107
|
-
.
|
|
108
|
-
|
|
110
|
+
.option('--project-path <path>', 'Unity project path')
|
|
111
|
+
.action(async (options: { projectPath?: string }) => {
|
|
112
|
+
await runWithErrorHandling(() => {
|
|
113
|
+
cleanupLockFiles(options.projectPath);
|
|
114
|
+
return Promise.resolve();
|
|
115
|
+
});
|
|
109
116
|
});
|
|
110
117
|
|
|
111
118
|
// Register skills subcommand
|
|
@@ -144,6 +151,7 @@ function registerToolCommand(tool: ToolDefinition): void {
|
|
|
144
151
|
|
|
145
152
|
// Add global options
|
|
146
153
|
cmd.option('-p, --port <port>', 'Unity TCP port');
|
|
154
|
+
cmd.option('--project-path <path>', 'Unity project path');
|
|
147
155
|
|
|
148
156
|
cmd.action(async (options: CliOptions) => {
|
|
149
157
|
const params = buildParams(options, properties);
|
|
@@ -294,6 +302,7 @@ function convertValue(value: unknown, propInfo: ToolProperty): unknown {
|
|
|
294
302
|
function extractGlobalOptions(options: Record<string, unknown>): GlobalOptions {
|
|
295
303
|
return {
|
|
296
304
|
port: options['port'] as string | undefined,
|
|
305
|
+
projectPath: options['projectPath'] as string | undefined,
|
|
297
306
|
};
|
|
298
307
|
}
|
|
299
308
|
|
|
@@ -592,8 +601,9 @@ const LOCK_FILES = ['compiling.lock', 'domainreload.lock', 'serverstarting.lock'
|
|
|
592
601
|
/**
|
|
593
602
|
* Clean up stale lock files that may prevent CLI from connecting to Unity.
|
|
594
603
|
*/
|
|
595
|
-
function cleanupLockFiles(): void {
|
|
596
|
-
const projectRoot =
|
|
604
|
+
function cleanupLockFiles(projectPath?: string): void {
|
|
605
|
+
const projectRoot =
|
|
606
|
+
projectPath !== undefined ? validateProjectPath(projectPath) : findUnityProjectRoot();
|
|
597
607
|
if (projectRoot === null) {
|
|
598
608
|
console.error('Could not find Unity project root.');
|
|
599
609
|
process.exit(1);
|
|
@@ -756,6 +766,41 @@ function shouldSkipAutoSync(cmdName: string | undefined, args: string[]): boolea
|
|
|
756
766
|
return args.some((arg) => (NO_SYNC_FLAGS as readonly string[]).includes(arg));
|
|
757
767
|
}
|
|
758
768
|
|
|
769
|
+
function extractSyncGlobalOptions(args: string[]): GlobalOptions {
|
|
770
|
+
const options: GlobalOptions = {};
|
|
771
|
+
|
|
772
|
+
for (let i = 0; i < args.length; i++) {
|
|
773
|
+
const arg = args[i];
|
|
774
|
+
if (arg === '--port' || arg === '-p') {
|
|
775
|
+
const nextArg = args[i + 1];
|
|
776
|
+
if (nextArg !== undefined && !nextArg.startsWith('-')) {
|
|
777
|
+
options.port = nextArg;
|
|
778
|
+
}
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
if (arg.startsWith('--port=')) {
|
|
783
|
+
options.port = arg.slice('--port='.length);
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
if (arg === '--project-path') {
|
|
788
|
+
const nextArg = args[i + 1];
|
|
789
|
+
if (nextArg !== undefined && !nextArg.startsWith('-')) {
|
|
790
|
+
options.projectPath = nextArg;
|
|
791
|
+
}
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
if (arg.startsWith('--project-path=')) {
|
|
796
|
+
options.projectPath = arg.slice('--project-path='.length);
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
return options;
|
|
802
|
+
}
|
|
803
|
+
|
|
759
804
|
/**
|
|
760
805
|
* Main entry point with auto-sync for unknown commands.
|
|
761
806
|
*/
|
|
@@ -766,6 +811,7 @@ async function main(): Promise<void> {
|
|
|
766
811
|
|
|
767
812
|
const args = process.argv.slice(2);
|
|
768
813
|
const cmdName = args.find((arg) => !arg.startsWith('-'));
|
|
814
|
+
const syncGlobalOptions = extractSyncGlobalOptions(args);
|
|
769
815
|
|
|
770
816
|
if (!shouldSkipAutoSync(cmdName, args)) {
|
|
771
817
|
// Check if cache version is outdated and auto-sync if needed
|
|
@@ -775,7 +821,7 @@ async function main(): Promise<void> {
|
|
|
775
821
|
`\x1b[33mCache outdated (${cachedVersion} → ${VERSION}). Syncing tools from Unity...\x1b[0m`,
|
|
776
822
|
);
|
|
777
823
|
try {
|
|
778
|
-
await syncTools(
|
|
824
|
+
await syncTools(syncGlobalOptions);
|
|
779
825
|
console.log('\x1b[32m✓ Tools synced successfully.\x1b[0m\n');
|
|
780
826
|
} catch (error) {
|
|
781
827
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -800,7 +846,7 @@ async function main(): Promise<void> {
|
|
|
800
846
|
if (cmdName && !commandExists(cmdName)) {
|
|
801
847
|
console.log(`\x1b[33mUnknown command '${cmdName}'. Syncing tools from Unity...\x1b[0m`);
|
|
802
848
|
try {
|
|
803
|
-
await syncTools(
|
|
849
|
+
await syncTools(syncGlobalOptions);
|
|
804
850
|
const newCache = loadToolsCache();
|
|
805
851
|
const tool = newCache.tools.find((t) => t.name === cmdName);
|
|
806
852
|
if (tool) {
|
|
@@ -10,13 +10,31 @@
|
|
|
10
10
|
import { Command } from 'commander';
|
|
11
11
|
import { findRunningUnityProcess, focusUnityProcess } from 'launch-unity';
|
|
12
12
|
import { findUnityProjectRoot } from '../project-root.js';
|
|
13
|
+
import { validateProjectPath } from '../port-resolver.js';
|
|
13
14
|
|
|
14
15
|
export function registerFocusWindowCommand(program: Command): void {
|
|
15
16
|
program
|
|
16
17
|
.command('focus-window')
|
|
17
18
|
.description('Bring Unity Editor window to front using OS-level commands')
|
|
18
|
-
.
|
|
19
|
-
|
|
19
|
+
.option('--project-path <path>', 'Unity project path')
|
|
20
|
+
.action(async (options: { projectPath?: string }) => {
|
|
21
|
+
let projectRoot: string | null;
|
|
22
|
+
if (options.projectPath !== undefined) {
|
|
23
|
+
try {
|
|
24
|
+
projectRoot = validateProjectPath(options.projectPath);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
console.error(
|
|
27
|
+
JSON.stringify({
|
|
28
|
+
Success: false,
|
|
29
|
+
Message: error instanceof Error ? error.message : String(error),
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
projectRoot = findUnityProjectRoot();
|
|
37
|
+
}
|
|
20
38
|
if (projectRoot === null) {
|
|
21
39
|
console.error(
|
|
22
40
|
JSON.stringify({
|
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.66.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
|
}
|
|
@@ -72,7 +72,16 @@ export class DirectUnityClient {
|
|
|
72
72
|
|
|
73
73
|
return new Promise((resolve, reject) => {
|
|
74
74
|
const socket = this.socket!;
|
|
75
|
+
|
|
76
|
+
const cleanup = (): void => {
|
|
77
|
+
clearTimeout(timeoutId);
|
|
78
|
+
socket.off('data', onData);
|
|
79
|
+
socket.off('error', onError);
|
|
80
|
+
socket.off('close', onClose);
|
|
81
|
+
};
|
|
82
|
+
|
|
75
83
|
const timeoutId = setTimeout(() => {
|
|
84
|
+
cleanup();
|
|
76
85
|
reject(
|
|
77
86
|
new Error(
|
|
78
87
|
`Request timed out after ${NETWORK_TIMEOUT_MS}ms. Unity may be frozen or busy. [For AI] Run 'uloop focus-window' to bring Unity to the front, then retry the tool. If the issue persists, report this to the user and ask how to proceed. Do NOT kill Unity processes without user permission.`,
|
|
@@ -98,9 +107,7 @@ export class DirectUnityClient {
|
|
|
98
107
|
return;
|
|
99
108
|
}
|
|
100
109
|
|
|
101
|
-
|
|
102
|
-
socket.off('data', onData);
|
|
103
|
-
|
|
110
|
+
cleanup();
|
|
104
111
|
this.receiveBuffer = extractResult.remainingData;
|
|
105
112
|
|
|
106
113
|
const response = JSON.parse(extractResult.jsonContent) as JsonRpcResponse;
|
|
@@ -113,7 +120,19 @@ export class DirectUnityClient {
|
|
|
113
120
|
resolve(response.result as T);
|
|
114
121
|
};
|
|
115
122
|
|
|
123
|
+
const onError = (error: Error): void => {
|
|
124
|
+
cleanup();
|
|
125
|
+
reject(new Error(`Connection lost: ${error.message}`));
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const onClose = (): void => {
|
|
129
|
+
cleanup();
|
|
130
|
+
reject(new Error('UNITY_NO_RESPONSE'));
|
|
131
|
+
};
|
|
132
|
+
|
|
116
133
|
socket.on('data', onData);
|
|
134
|
+
socket.on('error', onError);
|
|
135
|
+
socket.on('close', onClose);
|
|
117
136
|
socket.write(framedMessage);
|
|
118
137
|
});
|
|
119
138
|
}
|