uloop-cli 0.68.3 → 0.69.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/dist/cli.bundle.cjs +206 -112
- package/dist/cli.bundle.cjs.map +4 -4
- package/knip.json +4 -0
- package/package.json +4 -3
- package/src/__tests__/execute-tool.test.ts +10 -0
- package/src/__tests__/port-resolver.test.ts +68 -0
- package/src/__tests__/project-validator.test.ts +107 -0
- package/src/arg-parser.ts +0 -118
- package/src/cli.ts +24 -1
- package/src/compile-helpers.ts +5 -5
- package/src/default-tools.json +1 -1
- package/src/direct-unity-client.ts +2 -2
- package/src/execute-tool.ts +26 -0
- package/src/port-resolver.ts +39 -18
- package/src/project-root.ts +1 -1
- package/src/project-validator.ts +70 -0
- package/src/simple-framer.ts +2 -2
- package/src/skills/skills-manager.ts +9 -13
- package/src/skills/target-config.ts +1 -1
- package/src/spinner.ts +1 -1
- package/src/version.ts +1 -1
package/knip.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uloop-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.69.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",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"lint:fix": "eslint src --fix",
|
|
15
15
|
"format": "prettier --write src/**/*.ts",
|
|
16
16
|
"format:check": "prettier --check src/**/*.ts",
|
|
17
|
+
"knip": "knip",
|
|
17
18
|
"test:cli": "jest src/__tests__ --testTimeout=60000 --runInBand"
|
|
18
19
|
},
|
|
19
20
|
"keywords": [
|
|
@@ -48,7 +49,7 @@
|
|
|
48
49
|
"devDependencies": {
|
|
49
50
|
"@eslint/js": "10.0.1",
|
|
50
51
|
"@types/jest": "30.0.0",
|
|
51
|
-
"@types/node": "25.3.
|
|
52
|
+
"@types/node": "25.3.3",
|
|
52
53
|
"@types/semver": "7.7.1",
|
|
53
54
|
"esbuild": "0.27.3",
|
|
54
55
|
"eslint": "10.0.2",
|
|
@@ -56,9 +57,9 @@
|
|
|
56
57
|
"eslint-plugin-prettier": "5.5.5",
|
|
57
58
|
"eslint-plugin-security": "4.0.0",
|
|
58
59
|
"jest": "30.2.0",
|
|
60
|
+
"knip": "5.85.0",
|
|
59
61
|
"prettier": "3.8.1",
|
|
60
62
|
"ts-jest": "29.4.6",
|
|
61
|
-
"tsx": "4.21.0",
|
|
62
63
|
"typescript": "5.9.3",
|
|
63
64
|
"typescript-eslint": "8.56.1"
|
|
64
65
|
},
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { isTransportDisconnectError } from '../execute-tool.js';
|
|
2
|
+
import { UnityNotRunningError } from '../port-resolver.js';
|
|
3
|
+
import { ProjectMismatchError } from '../project-validator.js';
|
|
2
4
|
|
|
3
5
|
describe('isTransportDisconnectError', () => {
|
|
4
6
|
it('returns true for UNITY_NO_RESPONSE', () => {
|
|
@@ -28,4 +30,12 @@ describe('isTransportDisconnectError', () => {
|
|
|
28
30
|
expect(isTransportDisconnectError(null)).toBe(false);
|
|
29
31
|
expect(isTransportDisconnectError(undefined)).toBe(false);
|
|
30
32
|
});
|
|
33
|
+
|
|
34
|
+
it('returns false for UnityNotRunningError', () => {
|
|
35
|
+
expect(isTransportDisconnectError(new UnityNotRunningError('/project'))).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns false for ProjectMismatchError', () => {
|
|
39
|
+
expect(isTransportDisconnectError(new ProjectMismatchError('/a', '/b'))).toBe(false);
|
|
40
|
+
});
|
|
31
41
|
});
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
1
3
|
import { tmpdir } from 'os';
|
|
2
4
|
import {
|
|
3
5
|
resolvePortFromUnitySettings,
|
|
4
6
|
validateProjectPath,
|
|
5
7
|
resolveUnityPort,
|
|
8
|
+
UnityNotRunningError,
|
|
6
9
|
} from '../port-resolver.js';
|
|
7
10
|
|
|
8
11
|
describe('resolvePortFromUnitySettings', () => {
|
|
@@ -75,3 +78,68 @@ describe('resolveUnityPort', () => {
|
|
|
75
78
|
expect(port).toBe(8711);
|
|
76
79
|
});
|
|
77
80
|
});
|
|
81
|
+
|
|
82
|
+
describe('resolveUnityPort with project settings', () => {
|
|
83
|
+
let tempProjectRoot: string;
|
|
84
|
+
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
tempProjectRoot = mkdtempSync(join(tmpdir(), 'unity-port-test-'));
|
|
87
|
+
mkdirSync(join(tempProjectRoot, 'Assets'));
|
|
88
|
+
mkdirSync(join(tempProjectRoot, 'ProjectSettings'));
|
|
89
|
+
mkdirSync(join(tempProjectRoot, 'UserSettings'));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
afterEach(() => {
|
|
93
|
+
rmSync(tempProjectRoot, { recursive: true });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('throws UnityNotRunningError when isServerRunning is false', async () => {
|
|
97
|
+
writeFileSync(
|
|
98
|
+
join(tempProjectRoot, 'UserSettings/UnityMcpSettings.json'),
|
|
99
|
+
JSON.stringify({ isServerRunning: false, customPort: 8700 }),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
await expect(resolveUnityPort(undefined, tempProjectRoot)).rejects.toThrow(
|
|
103
|
+
UnityNotRunningError,
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('returns port when isServerRunning is true', async () => {
|
|
108
|
+
writeFileSync(
|
|
109
|
+
join(tempProjectRoot, 'UserSettings/UnityMcpSettings.json'),
|
|
110
|
+
JSON.stringify({ isServerRunning: true, customPort: 8711 }),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const port = await resolveUnityPort(undefined, tempProjectRoot);
|
|
114
|
+
expect(port).toBe(8711);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('returns port when isServerRunning is undefined (old settings format)', async () => {
|
|
118
|
+
writeFileSync(
|
|
119
|
+
join(tempProjectRoot, 'UserSettings/UnityMcpSettings.json'),
|
|
120
|
+
JSON.stringify({ customPort: 8711 }),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const port = await resolveUnityPort(undefined, tempProjectRoot);
|
|
124
|
+
expect(port).toBe(8711);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('throws when settings file has no valid port', async () => {
|
|
128
|
+
writeFileSync(
|
|
129
|
+
join(tempProjectRoot, 'UserSettings/UnityMcpSettings.json'),
|
|
130
|
+
JSON.stringify({ isServerRunning: true }),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
await expect(resolveUnityPort(undefined, tempProjectRoot)).rejects.toThrow(
|
|
134
|
+
'Could not read Unity server port from settings',
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('throws when settings file contains invalid JSON', async () => {
|
|
139
|
+
writeFileSync(join(tempProjectRoot, 'UserSettings/UnityMcpSettings.json'), 'not valid json{{{');
|
|
140
|
+
|
|
141
|
+
await expect(resolveUnityPort(undefined, tempProjectRoot)).rejects.toThrow(
|
|
142
|
+
'Could not read Unity server port from settings',
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { mkdtempSync, mkdirSync, rmSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { tmpdir } from 'os';
|
|
4
|
+
import { ProjectMismatchError, validateConnectedProject } from '../project-validator.js';
|
|
5
|
+
import { isTransportDisconnectError } from '../execute-tool.js';
|
|
6
|
+
import type { DirectUnityClient } from '../direct-unity-client.js';
|
|
7
|
+
|
|
8
|
+
function createMockClient(response?: unknown, error?: Error): DirectUnityClient {
|
|
9
|
+
return {
|
|
10
|
+
isConnected: () => true,
|
|
11
|
+
sendRequest: jest.fn().mockImplementation(() => {
|
|
12
|
+
if (error) {
|
|
13
|
+
return Promise.reject(error);
|
|
14
|
+
}
|
|
15
|
+
return Promise.resolve(response);
|
|
16
|
+
}),
|
|
17
|
+
connect: jest.fn(),
|
|
18
|
+
disconnect: jest.fn(),
|
|
19
|
+
} as unknown as DirectUnityClient;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('validateConnectedProject', () => {
|
|
23
|
+
let tempDirA: string;
|
|
24
|
+
let tempDirB: string;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
tempDirA = mkdtempSync(join(tmpdir(), 'project-a-'));
|
|
28
|
+
tempDirB = mkdtempSync(join(tmpdir(), 'project-b-'));
|
|
29
|
+
mkdirSync(join(tempDirA, 'Assets'));
|
|
30
|
+
mkdirSync(join(tempDirB, 'Assets'));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
jest.restoreAllMocks();
|
|
35
|
+
rmSync(tempDirA, { recursive: true });
|
|
36
|
+
rmSync(tempDirB, { recursive: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('throws ProjectMismatchError when connected project differs from expected', async () => {
|
|
40
|
+
const client = createMockClient({ DataPath: join(tempDirB, 'Assets') });
|
|
41
|
+
|
|
42
|
+
await expect(validateConnectedProject(client, tempDirA)).rejects.toThrow(ProjectMismatchError);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('does not throw when connected project matches expected', async () => {
|
|
46
|
+
const client = createMockClient({ DataPath: join(tempDirA, 'Assets') });
|
|
47
|
+
|
|
48
|
+
await expect(validateConnectedProject(client, tempDirA)).resolves.toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('normalizes paths with trailing separators', async () => {
|
|
52
|
+
const client = createMockClient({ DataPath: join(tempDirA, 'Assets') });
|
|
53
|
+
|
|
54
|
+
await expect(validateConnectedProject(client, tempDirA + '/')).resolves.toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('logs warning and continues when get-version returns Method not found', async () => {
|
|
58
|
+
const client = createMockClient(undefined, new Error('Unity error: Method not found (-32601)'));
|
|
59
|
+
const stderrSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
60
|
+
|
|
61
|
+
await expect(validateConnectedProject(client, tempDirA)).resolves.toBeUndefined();
|
|
62
|
+
|
|
63
|
+
expect(stderrSpy).toHaveBeenCalledWith(
|
|
64
|
+
expect.stringContaining('Could not verify project identity'),
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('re-throws non-Method-not-found errors', async () => {
|
|
69
|
+
const client = createMockClient(undefined, new Error('Unity error: some other error'));
|
|
70
|
+
|
|
71
|
+
await expect(validateConnectedProject(client, tempDirA)).rejects.toThrow(
|
|
72
|
+
'Unity error: some other error',
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('logs warning and continues when DataPath is missing from response', async () => {
|
|
77
|
+
const client = createMockClient({});
|
|
78
|
+
const stderrSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
79
|
+
|
|
80
|
+
await expect(validateConnectedProject(client, tempDirA)).resolves.toBeUndefined();
|
|
81
|
+
|
|
82
|
+
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('invalid get-version response'));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('logs warning and continues when DataPath is empty string', async () => {
|
|
86
|
+
const client = createMockClient({ DataPath: '' });
|
|
87
|
+
const stderrSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
88
|
+
|
|
89
|
+
await expect(validateConnectedProject(client, tempDirA)).resolves.toBeUndefined();
|
|
90
|
+
|
|
91
|
+
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('invalid get-version response'));
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('ProjectMismatchError', () => {
|
|
96
|
+
it('is not a transport disconnect error', () => {
|
|
97
|
+
const error = new ProjectMismatchError('/project/a', '/project/b');
|
|
98
|
+
expect(isTransportDisconnectError(error)).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('stores expected and connected project roots', () => {
|
|
102
|
+
const error = new ProjectMismatchError('/expected/path', '/connected/path');
|
|
103
|
+
expect(error.expectedProjectRoot).toBe('/expected/path');
|
|
104
|
+
expect(error.connectedProjectRoot).toBe('/connected/path');
|
|
105
|
+
expect(error.message).toBe('PROJECT_MISMATCH');
|
|
106
|
+
});
|
|
107
|
+
});
|
package/src/arg-parser.ts
CHANGED
|
@@ -3,31 +3,6 @@
|
|
|
3
3
|
* Converts CLI options to Unity tool parameters.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
// Object keys come from tool schema definitions which are internal trusted data
|
|
7
|
-
/* eslint-disable security/detect-object-injection */
|
|
8
|
-
|
|
9
|
-
export interface ToolParameter {
|
|
10
|
-
Type: string;
|
|
11
|
-
Description: string;
|
|
12
|
-
DefaultValue?: unknown;
|
|
13
|
-
Enum?: string[];
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface ToolSchema {
|
|
17
|
-
properties: Record<string, ToolParameter>;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Converts kebab-case CLI option name to PascalCase parameter name.
|
|
22
|
-
* e.g., "force-recompile" -> "ForceRecompile"
|
|
23
|
-
*/
|
|
24
|
-
export function kebabToPascalCase(kebab: string): string {
|
|
25
|
-
return kebab
|
|
26
|
-
.split('-')
|
|
27
|
-
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
28
|
-
.join('');
|
|
29
|
-
}
|
|
30
|
-
|
|
31
6
|
/**
|
|
32
7
|
* Converts PascalCase parameter name to kebab-case CLI option name.
|
|
33
8
|
* e.g., "ForceRecompile" -> "force-recompile"
|
|
@@ -36,96 +11,3 @@ export function pascalToKebabCase(pascal: string): string {
|
|
|
36
11
|
const kebab = pascal.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
37
12
|
return kebab.startsWith('-') ? kebab.slice(1) : kebab;
|
|
38
13
|
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Parses CLI arguments into tool parameters based on the tool schema.
|
|
42
|
-
*/
|
|
43
|
-
export function parseToolArgs(
|
|
44
|
-
args: string[],
|
|
45
|
-
schema: ToolSchema,
|
|
46
|
-
cliOptions: Record<string, unknown>,
|
|
47
|
-
): Record<string, unknown> {
|
|
48
|
-
const params: Record<string, unknown> = {};
|
|
49
|
-
|
|
50
|
-
for (const [paramName, paramInfo] of Object.entries(schema.properties)) {
|
|
51
|
-
const kebabName = pascalToKebabCase(paramName);
|
|
52
|
-
const cliValue = cliOptions[kebabName];
|
|
53
|
-
|
|
54
|
-
if (cliValue === undefined) {
|
|
55
|
-
if (paramInfo.DefaultValue !== undefined) {
|
|
56
|
-
params[paramName] = paramInfo.DefaultValue;
|
|
57
|
-
}
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
params[paramName] = convertValue(cliValue, paramInfo.Type);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
return params;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function convertValue(value: unknown, type: string): unknown {
|
|
68
|
-
if (value === undefined || value === null) {
|
|
69
|
-
return value;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const lowerType = type.toLowerCase();
|
|
73
|
-
|
|
74
|
-
switch (lowerType) {
|
|
75
|
-
case 'boolean':
|
|
76
|
-
if (typeof value === 'boolean') {
|
|
77
|
-
return value;
|
|
78
|
-
}
|
|
79
|
-
if (typeof value === 'string') {
|
|
80
|
-
return value.toLowerCase() === 'true';
|
|
81
|
-
}
|
|
82
|
-
return Boolean(value);
|
|
83
|
-
|
|
84
|
-
case 'number':
|
|
85
|
-
case 'integer':
|
|
86
|
-
if (typeof value === 'number') {
|
|
87
|
-
return value;
|
|
88
|
-
}
|
|
89
|
-
if (typeof value === 'string') {
|
|
90
|
-
const parsed = parseFloat(value);
|
|
91
|
-
return isNaN(parsed) ? 0 : parsed;
|
|
92
|
-
}
|
|
93
|
-
return Number(value);
|
|
94
|
-
|
|
95
|
-
case 'string':
|
|
96
|
-
if (typeof value === 'string') {
|
|
97
|
-
return value;
|
|
98
|
-
}
|
|
99
|
-
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
100
|
-
return String(value);
|
|
101
|
-
}
|
|
102
|
-
return JSON.stringify(value);
|
|
103
|
-
|
|
104
|
-
case 'array':
|
|
105
|
-
if (Array.isArray(value)) {
|
|
106
|
-
return value;
|
|
107
|
-
}
|
|
108
|
-
if (typeof value === 'string') {
|
|
109
|
-
return value.split(',').map((s) => s.trim());
|
|
110
|
-
}
|
|
111
|
-
return [value];
|
|
112
|
-
|
|
113
|
-
default:
|
|
114
|
-
return value;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Generates commander.js option string from parameter info.
|
|
120
|
-
* e.g., "--force-recompile" for boolean, "--max-count <value>" for others
|
|
121
|
-
*/
|
|
122
|
-
export function generateOptionString(paramName: string, paramInfo: ToolParameter): string {
|
|
123
|
-
const kebabName = pascalToKebabCase(paramName);
|
|
124
|
-
const lowerType = paramInfo.Type.toLowerCase();
|
|
125
|
-
|
|
126
|
-
if (lowerType === 'boolean') {
|
|
127
|
-
return `--${kebabName}`;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
return `--${kebabName} <value>`;
|
|
131
|
-
}
|
package/src/cli.ts
CHANGED
|
@@ -35,7 +35,8 @@ import { registerLaunchCommand } from './commands/launch.js';
|
|
|
35
35
|
import { registerFocusWindowCommand } from './commands/focus-window.js';
|
|
36
36
|
import { VERSION } from './version.js';
|
|
37
37
|
import { findUnityProjectRoot } from './project-root.js';
|
|
38
|
-
import { validateProjectPath } from './port-resolver.js';
|
|
38
|
+
import { validateProjectPath, UnityNotRunningError } from './port-resolver.js';
|
|
39
|
+
import { ProjectMismatchError } from './project-validator.js';
|
|
39
40
|
import { filterEnabledTools, isToolEnabled } from './tool-settings-loader.js';
|
|
40
41
|
|
|
41
42
|
interface CliOptions extends GlobalOptions {
|
|
@@ -422,6 +423,28 @@ async function runWithErrorHandling(fn: () => Promise<void>): Promise<void> {
|
|
|
422
423
|
try {
|
|
423
424
|
await fn();
|
|
424
425
|
} catch (error) {
|
|
426
|
+
if (error instanceof UnityNotRunningError) {
|
|
427
|
+
console.error('\x1b[31mError: Unity Editor for this project is not running.\x1b[0m');
|
|
428
|
+
console.error('');
|
|
429
|
+
console.error(` Project: ${error.projectRoot}`);
|
|
430
|
+
console.error('');
|
|
431
|
+
console.error('Start the Unity Editor for this project and try again.');
|
|
432
|
+
process.exit(1);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (error instanceof ProjectMismatchError) {
|
|
436
|
+
console.error('\x1b[31mError: Unity Editor for this project is not running.\x1b[0m');
|
|
437
|
+
console.error('');
|
|
438
|
+
console.error(` Project: ${error.expectedProjectRoot}`);
|
|
439
|
+
console.error(` Connected to: ${error.connectedProjectRoot}`);
|
|
440
|
+
console.error('');
|
|
441
|
+
console.error('Another Unity instance was found, but it belongs to a different project.');
|
|
442
|
+
console.error(
|
|
443
|
+
'Start the Unity Editor for this project, or use --port to specify the target.',
|
|
444
|
+
);
|
|
445
|
+
process.exit(1);
|
|
446
|
+
}
|
|
447
|
+
|
|
425
448
|
const message = error instanceof Error ? error.message : String(error);
|
|
426
449
|
|
|
427
450
|
// Unity busy states have clear causes - no version diagnostic needed
|
package/src/compile-helpers.ts
CHANGED
|
@@ -17,14 +17,14 @@ import { join } from 'path';
|
|
|
17
17
|
// Only alphanumeric, underscore, and hyphen — blocks path separators and traversal sequences
|
|
18
18
|
const SAFE_REQUEST_ID_PATTERN: RegExp = /^[a-zA-Z0-9_-]+$/;
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
const COMPILE_FORCE_RECOMPILE_ARG_KEYS = [
|
|
21
21
|
'ForceRecompile',
|
|
22
22
|
'forceRecompile',
|
|
23
23
|
'force_recompile',
|
|
24
24
|
'force-recompile',
|
|
25
25
|
] as const;
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
const COMPILE_WAIT_FOR_DOMAIN_RELOAD_ARG_KEYS = [
|
|
28
28
|
'WaitForDomainReload',
|
|
29
29
|
'waitForDomainReload',
|
|
30
30
|
'wait_for_domain_reload',
|
|
@@ -36,7 +36,7 @@ export interface CompileExecutionOptions {
|
|
|
36
36
|
waitForDomainReload: boolean;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
interface CompileCompletionWaitOptions {
|
|
40
40
|
projectRoot: string;
|
|
41
41
|
requestId: string;
|
|
42
42
|
timeoutMs: number;
|
|
@@ -45,9 +45,9 @@ export interface CompileCompletionWaitOptions {
|
|
|
45
45
|
isUnityReadyWhenIdle?: () => Promise<boolean>;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
type CompileCompletionOutcome = 'completed' | 'timed_out';
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
interface CompileCompletionResult<T> {
|
|
51
51
|
outcome: CompileCompletionOutcome;
|
|
52
52
|
result?: T;
|
|
53
53
|
}
|
package/src/default-tools.json
CHANGED
|
@@ -13,14 +13,14 @@ const JSONRPC_VERSION = '2.0';
|
|
|
13
13
|
const DEFAULT_HOST = '127.0.0.1';
|
|
14
14
|
const NETWORK_TIMEOUT_MS = 180000;
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
interface JsonRpcRequest {
|
|
17
17
|
jsonrpc: string;
|
|
18
18
|
method: string;
|
|
19
19
|
params?: Record<string, unknown>;
|
|
20
20
|
id: number;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
interface JsonRpcResponse {
|
|
24
24
|
jsonrpc: string;
|
|
25
25
|
result?: unknown;
|
|
26
26
|
error?: {
|
package/src/execute-tool.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { join } from 'path';
|
|
|
13
13
|
import * as semver from 'semver';
|
|
14
14
|
import { DirectUnityClient } from './direct-unity-client.js';
|
|
15
15
|
import { resolveUnityPort, validateProjectPath } from './port-resolver.js';
|
|
16
|
+
import { validateConnectedProject } from './project-validator.js';
|
|
16
17
|
import { saveToolsCache, getCacheFilePath, ToolsCache, ToolDefinition } from './tool-cache.js';
|
|
17
18
|
import { VERSION } from './version.js';
|
|
18
19
|
import { createSpinner } from './spinner.js';
|
|
@@ -214,6 +215,9 @@ export async function executeToolCommand(
|
|
|
214
215
|
? validateProjectPath(globalOptions.projectPath)
|
|
215
216
|
: findUnityProjectRoot();
|
|
216
217
|
|
|
218
|
+
// Validate project identity only when port was auto-resolved (not --port) and project root is known
|
|
219
|
+
const shouldValidateProject = portNumber === undefined && projectRoot !== null;
|
|
220
|
+
|
|
217
221
|
// Monotonically-increasing flag: once true, retries cannot reset it to false.
|
|
218
222
|
// The retry loop overwrites `lastError` and `immediateResult` on each attempt,
|
|
219
223
|
// which destroys the evidence of whether an earlier attempt successfully dispatched
|
|
@@ -229,6 +233,10 @@ export async function executeToolCommand(
|
|
|
229
233
|
try {
|
|
230
234
|
await client.connect();
|
|
231
235
|
|
|
236
|
+
if (shouldValidateProject) {
|
|
237
|
+
await validateConnectedProject(client, projectRoot);
|
|
238
|
+
}
|
|
239
|
+
|
|
232
240
|
spinner.update(`Executing ${toolName}...`);
|
|
233
241
|
// connect() succeeded: socket is established. sendRequest() calls socket.write()
|
|
234
242
|
// synchronously (direct-unity-client.ts:136), so the data reaches the kernel
|
|
@@ -383,6 +391,11 @@ export async function listAvailableTools(globalOptions: GlobalOptions): Promise<
|
|
|
383
391
|
portNumber = parsed;
|
|
384
392
|
}
|
|
385
393
|
const port = await resolveUnityPort(portNumber, globalOptions.projectPath);
|
|
394
|
+
const projectRoot =
|
|
395
|
+
globalOptions.projectPath !== undefined
|
|
396
|
+
? validateProjectPath(globalOptions.projectPath)
|
|
397
|
+
: findUnityProjectRoot();
|
|
398
|
+
const shouldValidateProject = portNumber === undefined && projectRoot !== null;
|
|
386
399
|
|
|
387
400
|
const restoreStdin = suppressStdinEcho();
|
|
388
401
|
const spinner = createSpinner('Connecting to Unity...');
|
|
@@ -395,6 +408,10 @@ export async function listAvailableTools(globalOptions: GlobalOptions): Promise<
|
|
|
395
408
|
try {
|
|
396
409
|
await client.connect();
|
|
397
410
|
|
|
411
|
+
if (shouldValidateProject) {
|
|
412
|
+
await validateConnectedProject(client, projectRoot);
|
|
413
|
+
}
|
|
414
|
+
|
|
398
415
|
spinner.update('Fetching tool list...');
|
|
399
416
|
const result = await client.sendRequest<{
|
|
400
417
|
Tools: Array<{ name: string; description: string }>;
|
|
@@ -471,6 +488,11 @@ export async function syncTools(globalOptions: GlobalOptions): Promise<void> {
|
|
|
471
488
|
portNumber = parsed;
|
|
472
489
|
}
|
|
473
490
|
const port = await resolveUnityPort(portNumber, globalOptions.projectPath);
|
|
491
|
+
const projectRoot =
|
|
492
|
+
globalOptions.projectPath !== undefined
|
|
493
|
+
? validateProjectPath(globalOptions.projectPath)
|
|
494
|
+
: findUnityProjectRoot();
|
|
495
|
+
const shouldValidateProject = portNumber === undefined && projectRoot !== null;
|
|
474
496
|
|
|
475
497
|
const restoreStdin = suppressStdinEcho();
|
|
476
498
|
const spinner = createSpinner('Connecting to Unity...');
|
|
@@ -483,6 +505,10 @@ export async function syncTools(globalOptions: GlobalOptions): Promise<void> {
|
|
|
483
505
|
try {
|
|
484
506
|
await client.connect();
|
|
485
507
|
|
|
508
|
+
if (shouldValidateProject) {
|
|
509
|
+
await validateConnectedProject(client, projectRoot);
|
|
510
|
+
}
|
|
511
|
+
|
|
486
512
|
spinner.update('Syncing tools...');
|
|
487
513
|
const result = await client.sendRequest<{
|
|
488
514
|
Tools: UnityToolInfo[];
|
package/src/port-resolver.ts
CHANGED
|
@@ -11,7 +11,11 @@ import { existsSync } from 'fs';
|
|
|
11
11
|
import { join, resolve } from 'path';
|
|
12
12
|
import { findUnityProjectRoot, isUnityProject, hasUloopInstalled } from './project-root.js';
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
export class UnityNotRunningError extends Error {
|
|
15
|
+
constructor(public readonly projectRoot: string) {
|
|
16
|
+
super('UNITY_NOT_RUNNING');
|
|
17
|
+
}
|
|
18
|
+
}
|
|
15
19
|
|
|
16
20
|
interface UnityMcpSettings {
|
|
17
21
|
isServerRunning?: boolean;
|
|
@@ -78,11 +82,7 @@ export async function resolveUnityPort(
|
|
|
78
82
|
|
|
79
83
|
if (projectPath !== undefined) {
|
|
80
84
|
const resolved = validateProjectPath(projectPath);
|
|
81
|
-
|
|
82
|
-
if (settingsPort !== null) {
|
|
83
|
-
return settingsPort;
|
|
84
|
-
}
|
|
85
|
-
return DEFAULT_PORT;
|
|
85
|
+
return await readPortFromSettingsOrThrow(resolved);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
const projectRoot = findUnityProjectRoot();
|
|
@@ -92,34 +92,55 @@ export async function resolveUnityPort(
|
|
|
92
92
|
);
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
return settingsPort;
|
|
98
|
-
}
|
|
95
|
+
return await readPortFromSettingsOrThrow(projectRoot);
|
|
96
|
+
}
|
|
99
97
|
|
|
100
|
-
|
|
98
|
+
function createSettingsReadError(projectRoot: string): Error {
|
|
99
|
+
const settingsPath = join(projectRoot, 'UserSettings/UnityMcpSettings.json');
|
|
100
|
+
return new Error(
|
|
101
|
+
`Could not read Unity server port from settings.\n\n` +
|
|
102
|
+
` Settings file: ${settingsPath}\n\n` +
|
|
103
|
+
`Run 'uloop launch -r' to restart Unity, or use --port to specify the port directly.`,
|
|
104
|
+
);
|
|
101
105
|
}
|
|
102
106
|
|
|
103
|
-
|
|
107
|
+
// File I/O and JSON parsing can fail for external reasons (permissions, corruption, concurrent writes)
|
|
108
|
+
async function readPortFromSettingsOrThrow(projectRoot: string): Promise<number> {
|
|
104
109
|
const settingsPath = join(projectRoot, 'UserSettings/UnityMcpSettings.json');
|
|
105
110
|
|
|
106
111
|
if (!existsSync(settingsPath)) {
|
|
107
|
-
|
|
112
|
+
throw createSettingsReadError(projectRoot);
|
|
108
113
|
}
|
|
109
114
|
|
|
110
115
|
let content: string;
|
|
111
116
|
try {
|
|
112
117
|
content = await readFile(settingsPath, 'utf-8');
|
|
113
118
|
} catch {
|
|
114
|
-
|
|
119
|
+
throw createSettingsReadError(projectRoot);
|
|
115
120
|
}
|
|
116
121
|
|
|
117
|
-
let
|
|
122
|
+
let parsed: unknown;
|
|
118
123
|
try {
|
|
119
|
-
|
|
124
|
+
parsed = JSON.parse(content);
|
|
120
125
|
} catch {
|
|
121
|
-
|
|
126
|
+
throw createSettingsReadError(projectRoot);
|
|
122
127
|
}
|
|
123
128
|
|
|
124
|
-
|
|
129
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
130
|
+
throw createSettingsReadError(projectRoot);
|
|
131
|
+
}
|
|
132
|
+
const settings = parsed as UnityMcpSettings;
|
|
133
|
+
|
|
134
|
+
// Only block when isServerRunning is explicitly false (Unity clean shutdown).
|
|
135
|
+
// undefined/missing means old settings format — proceed to next validation stage.
|
|
136
|
+
if (settings.isServerRunning === false) {
|
|
137
|
+
throw new UnityNotRunningError(projectRoot);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const port = resolvePortFromUnitySettings(settings);
|
|
141
|
+
if (port === null) {
|
|
142
|
+
throw createSettingsReadError(projectRoot);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return port;
|
|
125
146
|
}
|
package/src/project-root.ts
CHANGED
|
@@ -143,7 +143,7 @@ export function findUnityProjectRoot(startPath: string = process.cwd()): string
|
|
|
143
143
|
return findUnityProjectInParents(startPath);
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
|
|
146
|
+
interface UnityProjectStatus {
|
|
147
147
|
found: boolean;
|
|
148
148
|
path: string | null;
|
|
149
149
|
hasUloop: boolean;
|