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/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "uloop-cli",
3
- "version": "1.7.2",
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": "./dist/cli.bundle.cjs"
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.5.2",
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.1",
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 { isTransportDisconnectError } from '../execute-tool.js';
2
- import { UnityNotRunningError } from '../port-resolver.js';
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('throws UnityNotRunningError when isServerRunning is false', async () => {
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 expect(resolveUnityPort(undefined, tempProjectRoot)).rejects.toThrow(
103
- UnityNotRunningError,
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
+ });
@@ -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 { validateProjectPath, UnityNotRunningError } from './port-resolver.js';
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
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.7.2",
2
+ "version": "1.7.3",
3
3
  "tools": [
4
4
  {
5
5
  "name": "compile",
@@ -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 { resolveUnityPort, validateProjectPath } from './port-resolver.js';
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 instanceof Error) {
321
- throw lastError;
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
- if (lastError instanceof Error) {
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
- throw lastError;
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
- throw lastError;
630
+ await throwFinalToolError(lastError, projectRoot, shouldValidateProject);
577
631
  }