uloop-cli 1.7.2 → 1.7.3
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 +289 -30
- package/dist/cli.bundle.cjs.map +4 -4
- package/package.json +4 -4
- package/src/__tests__/cli-project-error.test.ts +13 -1
- package/src/__tests__/execute-tool.test.ts +67 -2
- package/src/__tests__/package-metadata.test.ts +28 -0
- package/src/__tests__/port-resolver.test.ts +3 -5
- package/src/__tests__/unity-process.test.ts +289 -0
- package/src/cli-project-error.ts +12 -2
- package/src/cli.ts +6 -2
- package/src/default-tools.json +1 -1
- package/src/execute-tool.ts +68 -14
- package/src/port-resolver.ts +6 -6
- package/src/unity-process.ts +337 -0
- package/src/version.ts +1 -1
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uloop-cli",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.3",
|
|
4
4
|
"//version": "x-release-please-version",
|
|
5
5
|
"description": "CLI tool for Unity Editor communication via Unity CLI Loop",
|
|
6
6
|
"main": "dist/cli.bundle.cjs",
|
|
7
7
|
"bin": {
|
|
8
|
-
"uloop": "
|
|
8
|
+
"uloop": "dist/cli.bundle.cjs"
|
|
9
9
|
},
|
|
10
10
|
"type": "module",
|
|
11
11
|
"scripts": {
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@eslint/js": "10.0.1",
|
|
51
51
|
"@types/jest": "30.0.0",
|
|
52
|
-
"@types/node": "25.
|
|
52
|
+
"@types/node": "25.6.0",
|
|
53
53
|
"@types/semver": "7.7.1",
|
|
54
54
|
"esbuild": "0.28.0",
|
|
55
55
|
"eslint": "10.2.0",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"eslint-plugin-security": "4.0.0",
|
|
59
59
|
"jest": "30.3.0",
|
|
60
60
|
"knip": "6.3.1",
|
|
61
|
-
"prettier": "3.8.
|
|
61
|
+
"prettier": "3.8.2",
|
|
62
62
|
"ts-jest": "29.4.9",
|
|
63
63
|
"typescript": "5.9.3",
|
|
64
64
|
"typescript-eslint": "8.58.1"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getProjectResolutionErrorLines } from '../cli-project-error.js';
|
|
2
|
-
import { UnityNotRunningError } from '../port-resolver.js';
|
|
2
|
+
import { UnityNotRunningError, UnityServerNotRunningError } from '../port-resolver.js';
|
|
3
3
|
import { ProjectMismatchError } from '../project-validator.js';
|
|
4
4
|
|
|
5
5
|
describe('getProjectResolutionErrorLines', () => {
|
|
@@ -30,4 +30,16 @@ describe('getProjectResolutionErrorLines', () => {
|
|
|
30
30
|
'Start the Unity Editor for this project, or use --project-path to specify the target.',
|
|
31
31
|
]);
|
|
32
32
|
});
|
|
33
|
+
|
|
34
|
+
it('returns server-not-running guidance for UnityServerNotRunningError', () => {
|
|
35
|
+
const lines = getProjectResolutionErrorLines(new UnityServerNotRunningError('/project/root'));
|
|
36
|
+
|
|
37
|
+
expect(lines).toEqual([
|
|
38
|
+
'Error: Unity Editor is running, but Unity CLI Loop server is not.',
|
|
39
|
+
'',
|
|
40
|
+
' Project: /project/root',
|
|
41
|
+
'',
|
|
42
|
+
'Start the server from: Window > Unity CLI Loop > Server',
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
33
45
|
});
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
diagnoseRetryableProjectConnectionError,
|
|
3
|
+
isTransportDisconnectError,
|
|
4
|
+
} from '../execute-tool.js';
|
|
5
|
+
import { UnityNotRunningError, UnityServerNotRunningError } from '../port-resolver.js';
|
|
3
6
|
import { ProjectMismatchError } from '../project-validator.js';
|
|
4
7
|
|
|
5
8
|
describe('isTransportDisconnectError', () => {
|
|
@@ -35,7 +38,69 @@ describe('isTransportDisconnectError', () => {
|
|
|
35
38
|
expect(isTransportDisconnectError(new UnityNotRunningError('/project'))).toBe(false);
|
|
36
39
|
});
|
|
37
40
|
|
|
41
|
+
it('returns false for UnityServerNotRunningError', () => {
|
|
42
|
+
expect(isTransportDisconnectError(new UnityServerNotRunningError('/project'))).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
38
45
|
it('returns false for ProjectMismatchError', () => {
|
|
39
46
|
expect(isTransportDisconnectError(new ProjectMismatchError('/a', '/b'))).toBe(false);
|
|
40
47
|
});
|
|
41
48
|
});
|
|
49
|
+
|
|
50
|
+
describe('diagnoseRetryableProjectConnectionError', () => {
|
|
51
|
+
it('returns UnityNotRunningError when connection fails and Unity is not running', async () => {
|
|
52
|
+
const error = await diagnoseRetryableProjectConnectionError(
|
|
53
|
+
new Error('Connection error: connect ECONNREFUSED 127.0.0.1:8711'),
|
|
54
|
+
'/project',
|
|
55
|
+
true,
|
|
56
|
+
{
|
|
57
|
+
findRunningUnityProcessForProjectFn: jest.fn().mockResolvedValue(null),
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
expect(error).toBeInstanceOf(UnityNotRunningError);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns UnityServerNotRunningError when Unity is running but server is unavailable', async () => {
|
|
65
|
+
const error = await diagnoseRetryableProjectConnectionError(
|
|
66
|
+
new Error('UNITY_NO_RESPONSE'),
|
|
67
|
+
'/project',
|
|
68
|
+
true,
|
|
69
|
+
{
|
|
70
|
+
findRunningUnityProcessForProjectFn: jest.fn().mockResolvedValue({ pid: 1234 }),
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
expect(error).toBeInstanceOf(UnityServerNotRunningError);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('preserves non-retryable errors', async () => {
|
|
78
|
+
const originalError = new ProjectMismatchError('/expected', '/actual');
|
|
79
|
+
|
|
80
|
+
const error = await diagnoseRetryableProjectConnectionError(originalError, '/project', true, {
|
|
81
|
+
findRunningUnityProcessForProjectFn: jest.fn(),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(error).toBe(originalError);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('preserves retryable errors when project diagnosis is disabled', async () => {
|
|
88
|
+
const originalError = new Error('Connection error: connect ECONNREFUSED 127.0.0.1:8711');
|
|
89
|
+
|
|
90
|
+
const error = await diagnoseRetryableProjectConnectionError(originalError, '/project', false, {
|
|
91
|
+
findRunningUnityProcessForProjectFn: jest.fn(),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(error).toBe(originalError);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('preserves the original error when OS-level process inspection fails', async () => {
|
|
98
|
+
const originalError = new Error('Connection error: connect ECONNREFUSED 127.0.0.1:8711');
|
|
99
|
+
|
|
100
|
+
const error = await diagnoseRetryableProjectConnectionError(originalError, '/project', true, {
|
|
101
|
+
findRunningUnityProcessForProjectFn: jest.fn().mockRejectedValue(new Error('ps failed')),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(error).toBe(originalError);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Test reads the checked-in manifest through a stable relative path during Jest execution.
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
|
|
6
|
+
type PackageManifest = {
|
|
7
|
+
readonly bin?: Record<string, string>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function loadPackageManifest(): PackageManifest {
|
|
11
|
+
const packageJsonPath = join(__dirname, '..', '..', 'package.json');
|
|
12
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
13
|
+
const packageJsonText = readFileSync(packageJsonPath, 'utf8');
|
|
14
|
+
return JSON.parse(packageJsonText) as PackageManifest;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('package metadata', () => {
|
|
18
|
+
it('avoids bin target prefixes that npm normalizes during publish', () => {
|
|
19
|
+
const packageManifest = loadPackageManifest();
|
|
20
|
+
const binEntries = Object.entries(packageManifest.bin ?? {});
|
|
21
|
+
|
|
22
|
+
expect(binEntries.length).toBeGreaterThan(0);
|
|
23
|
+
|
|
24
|
+
for (const [, binTarget] of binEntries) {
|
|
25
|
+
expect(binTarget).not.toMatch(/^(?:\.{1,2}[\\/]|[\\/])/);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
resolvePortFromUnitySettings,
|
|
6
6
|
validateProjectPath,
|
|
7
7
|
resolveUnityPort,
|
|
8
|
-
UnityNotRunningError,
|
|
9
8
|
} from '../port-resolver.js';
|
|
10
9
|
|
|
11
10
|
describe('resolvePortFromUnitySettings', () => {
|
|
@@ -93,15 +92,14 @@ describe('resolveUnityPort with project settings', () => {
|
|
|
93
92
|
rmSync(tempProjectRoot, { recursive: true });
|
|
94
93
|
});
|
|
95
94
|
|
|
96
|
-
it('
|
|
95
|
+
it('returns port when isServerRunning is false', async () => {
|
|
97
96
|
writeFileSync(
|
|
98
97
|
join(tempProjectRoot, 'UserSettings/UnityMcpSettings.json'),
|
|
99
98
|
JSON.stringify({ isServerRunning: false, customPort: 8700 }),
|
|
100
99
|
);
|
|
101
100
|
|
|
102
|
-
await
|
|
103
|
-
|
|
104
|
-
);
|
|
101
|
+
const port = await resolveUnityPort(undefined, tempProjectRoot);
|
|
102
|
+
expect(port).toBe(8700);
|
|
105
103
|
});
|
|
106
104
|
|
|
107
105
|
it('returns port when isServerRunning is true', async () => {
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildUnityProcessCommand,
|
|
3
|
+
extractUnityProjectPath,
|
|
4
|
+
findRunningUnityProcessForProject,
|
|
5
|
+
isUnityEditorProcess,
|
|
6
|
+
isUnityProcessForProject,
|
|
7
|
+
normalizeUnityProjectPath,
|
|
8
|
+
parseUnityProcesses,
|
|
9
|
+
tokenizeCommandLine,
|
|
10
|
+
} from '../unity-process.js';
|
|
11
|
+
|
|
12
|
+
describe('buildUnityProcessCommand', () => {
|
|
13
|
+
it('builds ps command for macOS', () => {
|
|
14
|
+
expect(buildUnityProcessCommand('darwin')).toEqual({
|
|
15
|
+
command: 'ps',
|
|
16
|
+
args: ['-Ao', 'pid=,command='],
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('builds powershell command for Windows', () => {
|
|
21
|
+
expect(buildUnityProcessCommand('win32')).toEqual({
|
|
22
|
+
command: 'powershell.exe',
|
|
23
|
+
args: [
|
|
24
|
+
'-NoProfile',
|
|
25
|
+
'-NonInteractive',
|
|
26
|
+
'-Command',
|
|
27
|
+
'Get-CimInstance Win32_Process -Filter "name = \'Unity.exe\'" | Select-Object ProcessId, CommandLine | ConvertTo-Json -Compress',
|
|
28
|
+
],
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('tokenizeCommandLine', () => {
|
|
34
|
+
it('keeps quoted project path as one token', () => {
|
|
35
|
+
expect(
|
|
36
|
+
tokenizeCommandLine(
|
|
37
|
+
'/Applications/Unity.app/Contents/MacOS/Unity -projectPath "/Users/me/My Project"',
|
|
38
|
+
),
|
|
39
|
+
).toEqual([
|
|
40
|
+
'/Applications/Unity.app/Contents/MacOS/Unity',
|
|
41
|
+
'-projectPath',
|
|
42
|
+
'/Users/me/My Project',
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('extractUnityProjectPath', () => {
|
|
48
|
+
it('extracts macOS project path', () => {
|
|
49
|
+
expect(
|
|
50
|
+
extractUnityProjectPath(
|
|
51
|
+
'/Applications/Unity.app/Contents/MacOS/Unity -projectPath /Users/me/project',
|
|
52
|
+
),
|
|
53
|
+
).toBe('/Users/me/project');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('extracts Windows project path case-insensitively', () => {
|
|
57
|
+
expect(
|
|
58
|
+
extractUnityProjectPath(
|
|
59
|
+
'C:\\Program Files\\Unity\\Editor\\Unity.exe -projectpath "C:\\Work\\My Project"',
|
|
60
|
+
),
|
|
61
|
+
).toBe('C:\\Work\\My Project');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('normalizeUnityProjectPath', () => {
|
|
66
|
+
it('normalizes Windows paths case-insensitively', () => {
|
|
67
|
+
expect(normalizeUnityProjectPath('C:\\Work\\My Project\\', 'win32')).toBe('c:/work/my project');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('parseUnityProcesses', () => {
|
|
72
|
+
it('parses ps output', () => {
|
|
73
|
+
expect(
|
|
74
|
+
parseUnityProcesses(
|
|
75
|
+
'darwin',
|
|
76
|
+
'123 /Applications/Unity.app/Contents/MacOS/Unity -projectPath /Users/me/project\n',
|
|
77
|
+
),
|
|
78
|
+
).toEqual([
|
|
79
|
+
{
|
|
80
|
+
pid: 123,
|
|
81
|
+
commandLine: '/Applications/Unity.app/Contents/MacOS/Unity -projectPath /Users/me/project',
|
|
82
|
+
},
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('parses Windows powershell JSON array output', () => {
|
|
87
|
+
expect(
|
|
88
|
+
parseUnityProcesses(
|
|
89
|
+
'win32',
|
|
90
|
+
'[{"ProcessId":101,"CommandLine":"C:\\\\Program Files\\\\Unity\\\\Editor\\\\Unity.exe -projectPath \\"C:\\\\Work\\\\Project A\\""}]',
|
|
91
|
+
),
|
|
92
|
+
).toEqual([
|
|
93
|
+
{
|
|
94
|
+
pid: 101,
|
|
95
|
+
commandLine:
|
|
96
|
+
'C:\\Program Files\\Unity\\Editor\\Unity.exe -projectPath "C:\\Work\\Project A"',
|
|
97
|
+
},
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('returns empty array when Windows output is empty', () => {
|
|
102
|
+
expect(parseUnityProcesses('win32', '')).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('isUnityProcessForProject', () => {
|
|
107
|
+
it('matches project path on macOS', () => {
|
|
108
|
+
expect(
|
|
109
|
+
isUnityProcessForProject(
|
|
110
|
+
'/Applications/Unity.app/Contents/MacOS/Unity -projectPath "/Users/me/Project A"',
|
|
111
|
+
'/Users/me/Project A',
|
|
112
|
+
'darwin',
|
|
113
|
+
),
|
|
114
|
+
).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('matches a macOS project path even when ps output has flattened quotes', () => {
|
|
118
|
+
expect(
|
|
119
|
+
isUnityProcessForProject(
|
|
120
|
+
'/Applications/Unity.app/Contents/MacOS/Unity -projectPath /Users/me/My Project',
|
|
121
|
+
'/Users/me/My Project',
|
|
122
|
+
'darwin',
|
|
123
|
+
),
|
|
124
|
+
).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('matches a macOS project path when ps output keeps trailing slashes', () => {
|
|
128
|
+
expect(
|
|
129
|
+
isUnityProcessForProject(
|
|
130
|
+
'/Applications/Unity.app/Contents/MacOS/Unity -projectPath /Users/me/My Project/',
|
|
131
|
+
'/Users/me/My Project',
|
|
132
|
+
'darwin',
|
|
133
|
+
),
|
|
134
|
+
).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('matches a macOS project path when ps output keeps repeated trailing slashes', () => {
|
|
138
|
+
expect(
|
|
139
|
+
isUnityProcessForProject(
|
|
140
|
+
'/Applications/Unity.app/Contents/MacOS/Unity -projectPath /Users/me/My Project///',
|
|
141
|
+
'/Users/me/My Project',
|
|
142
|
+
'darwin',
|
|
143
|
+
),
|
|
144
|
+
).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('does not match a different macOS project that only shares the prefix', () => {
|
|
148
|
+
expect(
|
|
149
|
+
isUnityProcessForProject(
|
|
150
|
+
'/Applications/Unity.app/Contents/MacOS/Unity -projectPath /Users/me/My Project Backup',
|
|
151
|
+
'/Users/me/My Project',
|
|
152
|
+
'darwin',
|
|
153
|
+
),
|
|
154
|
+
).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('matches project path on Windows case-insensitively', () => {
|
|
158
|
+
expect(
|
|
159
|
+
isUnityProcessForProject(
|
|
160
|
+
'C:\\Program Files\\Unity\\Editor\\Unity.exe -projectPath "C:\\Work\\Project A"',
|
|
161
|
+
'c:/work/project a',
|
|
162
|
+
'win32',
|
|
163
|
+
),
|
|
164
|
+
).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('isUnityEditorProcess', () => {
|
|
169
|
+
it('detects the Unity editor on macOS', () => {
|
|
170
|
+
expect(
|
|
171
|
+
isUnityEditorProcess(
|
|
172
|
+
'/Applications/Unity/Hub/Editor/2022.3.62f3/Unity.app/Contents/MacOS/Unity -projectPath "/Users/me/Project A"',
|
|
173
|
+
'darwin',
|
|
174
|
+
),
|
|
175
|
+
).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('rejects a non-Unity process on macOS even when projectPath is present', () => {
|
|
179
|
+
expect(
|
|
180
|
+
isUnityEditorProcess(
|
|
181
|
+
'/usr/local/bin/custom-tool -projectPath "/Users/me/Project A"',
|
|
182
|
+
'darwin',
|
|
183
|
+
),
|
|
184
|
+
).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('detects the Unity editor on Windows without relying on command line casing', () => {
|
|
188
|
+
expect(
|
|
189
|
+
isUnityEditorProcess(
|
|
190
|
+
'C:\\Program Files\\Unity\\Editor\\UNITY.EXE -projectPath "C:\\Work\\Project A"',
|
|
191
|
+
'win32',
|
|
192
|
+
),
|
|
193
|
+
).toBe(true);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe('findRunningUnityProcessForProject', () => {
|
|
198
|
+
it('returns null when no Unity process is running', async () => {
|
|
199
|
+
const runCommand = jest.fn<Promise<string>, [string, string[]]>().mockResolvedValue('');
|
|
200
|
+
|
|
201
|
+
await expect(
|
|
202
|
+
findRunningUnityProcessForProject('/Users/me/project', {
|
|
203
|
+
platform: 'darwin',
|
|
204
|
+
runCommand,
|
|
205
|
+
}),
|
|
206
|
+
).resolves.toBeNull();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('returns matching Unity process on macOS', async () => {
|
|
210
|
+
const runCommand = jest
|
|
211
|
+
.fn<Promise<string>, [string, string[]]>()
|
|
212
|
+
.mockResolvedValue(
|
|
213
|
+
[
|
|
214
|
+
'111 /Applications/Unity.app/Contents/MacOS/Unity -projectPath /Users/me/other',
|
|
215
|
+
'222 /Applications/Unity.app/Contents/MacOS/Unity -projectPath "/Users/me/project"',
|
|
216
|
+
].join('\n'),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
await expect(
|
|
220
|
+
findRunningUnityProcessForProject('/Users/me/project', {
|
|
221
|
+
platform: 'darwin',
|
|
222
|
+
runCommand,
|
|
223
|
+
}),
|
|
224
|
+
).resolves.toEqual({ pid: 222 });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('returns a matching macOS Unity process when ps output has flattened quotes', async () => {
|
|
228
|
+
const runCommand = jest
|
|
229
|
+
.fn<Promise<string>, [string, string[]]>()
|
|
230
|
+
.mockResolvedValue(
|
|
231
|
+
'222 /Applications/Unity.app/Contents/MacOS/Unity -projectPath /Users/me/My Project',
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
await expect(
|
|
235
|
+
findRunningUnityProcessForProject('/Users/me/My Project', {
|
|
236
|
+
platform: 'darwin',
|
|
237
|
+
runCommand,
|
|
238
|
+
}),
|
|
239
|
+
).resolves.toEqual({ pid: 222 });
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('returns a matching macOS Unity process when ps output keeps trailing slashes', async () => {
|
|
243
|
+
const runCommand = jest
|
|
244
|
+
.fn<Promise<string>, [string, string[]]>()
|
|
245
|
+
.mockResolvedValue(
|
|
246
|
+
'222 /Applications/Unity.app/Contents/MacOS/Unity -projectPath /Users/me/My Project///',
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
await expect(
|
|
250
|
+
findRunningUnityProcessForProject('/Users/me/My Project', {
|
|
251
|
+
platform: 'darwin',
|
|
252
|
+
runCommand,
|
|
253
|
+
}),
|
|
254
|
+
).resolves.toEqual({ pid: 222 });
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('ignores non-Unity processes that happen to share the same projectPath', async () => {
|
|
258
|
+
const runCommand = jest
|
|
259
|
+
.fn<Promise<string>, [string, string[]]>()
|
|
260
|
+
.mockResolvedValue(
|
|
261
|
+
[
|
|
262
|
+
'111 /usr/local/bin/custom-tool -projectPath "/Users/me/project"',
|
|
263
|
+
'222 /Applications/Unity.app/Contents/MacOS/Unity -projectPath "/Users/me/project"',
|
|
264
|
+
].join('\n'),
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
await expect(
|
|
268
|
+
findRunningUnityProcessForProject('/Users/me/project', {
|
|
269
|
+
platform: 'darwin',
|
|
270
|
+
runCommand,
|
|
271
|
+
}),
|
|
272
|
+
).resolves.toEqual({ pid: 222 });
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('returns matching Unity process on Windows', async () => {
|
|
276
|
+
const runCommand = jest
|
|
277
|
+
.fn<Promise<string>, [string, string[]]>()
|
|
278
|
+
.mockResolvedValue(
|
|
279
|
+
'[{"ProcessId":333,"CommandLine":"C:\\\\Program Files\\\\Unity\\\\Editor\\\\UNITY.EXE -projectPath \\"C:\\\\Work\\\\My Project\\""}]',
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
await expect(
|
|
283
|
+
findRunningUnityProcessForProject('c:/work/my project', {
|
|
284
|
+
platform: 'win32',
|
|
285
|
+
runCommand,
|
|
286
|
+
}),
|
|
287
|
+
).resolves.toEqual({ pid: 333 });
|
|
288
|
+
});
|
|
289
|
+
});
|
package/src/cli-project-error.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
|
-
import { UnityNotRunningError } from './port-resolver.js';
|
|
1
|
+
import { UnityNotRunningError, UnityServerNotRunningError } from './port-resolver.js';
|
|
2
2
|
import { ProjectMismatchError } from './project-validator.js';
|
|
3
3
|
|
|
4
4
|
export function getProjectResolutionErrorLines(
|
|
5
|
-
error: UnityNotRunningError | ProjectMismatchError,
|
|
5
|
+
error: UnityNotRunningError | UnityServerNotRunningError | ProjectMismatchError,
|
|
6
6
|
): string[] {
|
|
7
|
+
if (error instanceof UnityServerNotRunningError) {
|
|
8
|
+
return [
|
|
9
|
+
'Error: Unity Editor is running, but Unity CLI Loop server is not.',
|
|
10
|
+
'',
|
|
11
|
+
` Project: ${error.projectRoot}`,
|
|
12
|
+
'',
|
|
13
|
+
'Start the server from: Window > Unity CLI Loop > Server',
|
|
14
|
+
];
|
|
15
|
+
}
|
|
16
|
+
|
|
7
17
|
if (error instanceof UnityNotRunningError) {
|
|
8
18
|
return [
|
|
9
19
|
'Error: Unity Editor for this project is not running.',
|
package/src/cli.ts
CHANGED
|
@@ -36,7 +36,11 @@ import { registerLaunchCommand } from './commands/launch.js';
|
|
|
36
36
|
import { registerFocusWindowCommand } from './commands/focus-window.js';
|
|
37
37
|
import { VERSION } from './version.js';
|
|
38
38
|
import { findUnityProjectRoot } from './project-root.js';
|
|
39
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
validateProjectPath,
|
|
41
|
+
UnityNotRunningError,
|
|
42
|
+
UnityServerNotRunningError,
|
|
43
|
+
} from './port-resolver.js';
|
|
40
44
|
import { ProjectMismatchError } from './project-validator.js';
|
|
41
45
|
import { filterEnabledTools, isToolEnabled } from './tool-settings-loader.js';
|
|
42
46
|
import { getProjectResolutionErrorLines } from './cli-project-error.js';
|
|
@@ -430,7 +434,7 @@ async function runWithErrorHandling(fn: () => Promise<void>): Promise<void> {
|
|
|
430
434
|
try {
|
|
431
435
|
await fn();
|
|
432
436
|
} catch (error) {
|
|
433
|
-
if (error instanceof UnityNotRunningError) {
|
|
437
|
+
if (error instanceof UnityNotRunningError || error instanceof UnityServerNotRunningError) {
|
|
434
438
|
for (const line of getProjectResolutionErrorLines(error)) {
|
|
435
439
|
console.error(line.startsWith('Error: ') ? `\x1b[31m${line}\x1b[0m` : line);
|
|
436
440
|
}
|
package/src/default-tools.json
CHANGED
package/src/execute-tool.ts
CHANGED
|
@@ -13,12 +13,18 @@ import { existsSync } from 'fs';
|
|
|
13
13
|
import { join } from 'path';
|
|
14
14
|
import * as semver from 'semver';
|
|
15
15
|
import { DirectUnityClient } from './direct-unity-client.js';
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
resolveUnityPort,
|
|
18
|
+
UnityNotRunningError,
|
|
19
|
+
UnityServerNotRunningError,
|
|
20
|
+
validateProjectPath,
|
|
21
|
+
} from './port-resolver.js';
|
|
17
22
|
import { validateConnectedProject } from './project-validator.js';
|
|
18
23
|
import { saveToolsCache, getCacheFilePath, ToolsCache, ToolDefinition } from './tool-cache.js';
|
|
19
24
|
import { VERSION } from './version.js';
|
|
20
25
|
import { createSpinner } from './spinner.js';
|
|
21
26
|
import { findUnityProjectRoot } from './project-root.js';
|
|
27
|
+
import { findRunningUnityProcessForProject } from './unity-process.js';
|
|
22
28
|
import {
|
|
23
29
|
type CompileExecutionOptions,
|
|
24
30
|
ensureCompileRequestId,
|
|
@@ -77,6 +83,14 @@ const MAX_RETRIES = 3;
|
|
|
77
83
|
const COMPILE_WAIT_TIMEOUT_MS = 90000;
|
|
78
84
|
const COMPILE_WAIT_POLL_INTERVAL_MS = 100;
|
|
79
85
|
|
|
86
|
+
interface ConnectionFailureDiagnosisDependencies {
|
|
87
|
+
findRunningUnityProcessForProjectFn: typeof findRunningUnityProcessForProject;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const defaultConnectionFailureDiagnosisDependencies: ConnectionFailureDiagnosisDependencies = {
|
|
91
|
+
findRunningUnityProcessForProjectFn: findRunningUnityProcessForProject,
|
|
92
|
+
};
|
|
93
|
+
|
|
80
94
|
function getCompileExecutionOptions(
|
|
81
95
|
toolName: string,
|
|
82
96
|
params: Record<string, unknown>,
|
|
@@ -103,6 +117,54 @@ function isRetryableError(error: unknown): boolean {
|
|
|
103
117
|
);
|
|
104
118
|
}
|
|
105
119
|
|
|
120
|
+
export async function diagnoseRetryableProjectConnectionError(
|
|
121
|
+
error: unknown,
|
|
122
|
+
projectRoot: string | null,
|
|
123
|
+
shouldDiagnoseProjectState: boolean,
|
|
124
|
+
dependencies: ConnectionFailureDiagnosisDependencies = defaultConnectionFailureDiagnosisDependencies,
|
|
125
|
+
): Promise<unknown> {
|
|
126
|
+
if (!shouldDiagnoseProjectState || projectRoot === null || !isRetryableError(error)) {
|
|
127
|
+
return error;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const runningProcess = await dependencies
|
|
131
|
+
.findRunningUnityProcessForProjectFn(projectRoot)
|
|
132
|
+
.catch(() => undefined);
|
|
133
|
+
|
|
134
|
+
if (runningProcess === undefined) {
|
|
135
|
+
return error;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (runningProcess === null) {
|
|
139
|
+
return new UnityNotRunningError(projectRoot);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return new UnityServerNotRunningError(projectRoot);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function throwFinalToolError(
|
|
146
|
+
error: unknown,
|
|
147
|
+
projectRoot: string | null,
|
|
148
|
+
shouldDiagnoseProjectState: boolean,
|
|
149
|
+
): Promise<never> {
|
|
150
|
+
const diagnosedError = await diagnoseRetryableProjectConnectionError(
|
|
151
|
+
error,
|
|
152
|
+
projectRoot,
|
|
153
|
+
shouldDiagnoseProjectState,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
if (diagnosedError instanceof Error) {
|
|
157
|
+
throw diagnosedError;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (typeof diagnosedError === 'string') {
|
|
161
|
+
throw new Error(diagnosedError);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const serializedError = JSON.stringify(diagnosedError);
|
|
165
|
+
throw new Error(serializedError ?? 'Unknown error');
|
|
166
|
+
}
|
|
167
|
+
|
|
106
168
|
// Distinct from isRetryableError(): that function covers pre-connection failures
|
|
107
169
|
// (ECONNREFUSED, EADDRNOTAVAIL) which cannot occur after dispatch.
|
|
108
170
|
// This function covers post-dispatch TCP failures where Unity may have received
|
|
@@ -317,8 +379,8 @@ export async function executeToolCommand(
|
|
|
317
379
|
if (immediateResult === undefined && !requestDispatched) {
|
|
318
380
|
spinner.stop();
|
|
319
381
|
restoreStdin();
|
|
320
|
-
if (lastError
|
|
321
|
-
|
|
382
|
+
if (lastError !== undefined) {
|
|
383
|
+
await throwFinalToolError(lastError, projectRoot, shouldValidateProject);
|
|
322
384
|
}
|
|
323
385
|
throw new Error(
|
|
324
386
|
'Compile request never reached Unity. Check that Unity is running and retry.',
|
|
@@ -380,15 +442,7 @@ export async function executeToolCommand(
|
|
|
380
442
|
if (lastError === undefined) {
|
|
381
443
|
throw new Error('Tool execution failed without error details.');
|
|
382
444
|
}
|
|
383
|
-
|
|
384
|
-
throw lastError;
|
|
385
|
-
}
|
|
386
|
-
if (typeof lastError === 'string') {
|
|
387
|
-
throw new Error(lastError);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
const serializedError = JSON.stringify(lastError);
|
|
391
|
-
throw new Error(serializedError ?? 'Unknown error');
|
|
445
|
+
await throwFinalToolError(lastError, projectRoot, shouldValidateProject);
|
|
392
446
|
}
|
|
393
447
|
|
|
394
448
|
export async function listAvailableTools(globalOptions: GlobalOptions): Promise<void> {
|
|
@@ -455,7 +509,7 @@ export async function listAvailableTools(globalOptions: GlobalOptions): Promise<
|
|
|
455
509
|
|
|
456
510
|
spinner.stop();
|
|
457
511
|
restoreStdin();
|
|
458
|
-
|
|
512
|
+
await throwFinalToolError(lastError, projectRoot, shouldValidateProject);
|
|
459
513
|
}
|
|
460
514
|
|
|
461
515
|
interface UnityToolInfo {
|
|
@@ -573,5 +627,5 @@ export async function syncTools(globalOptions: GlobalOptions): Promise<void> {
|
|
|
573
627
|
|
|
574
628
|
spinner.stop();
|
|
575
629
|
restoreStdin();
|
|
576
|
-
|
|
630
|
+
await throwFinalToolError(lastError, projectRoot, shouldValidateProject);
|
|
577
631
|
}
|