uloop-cli 1.7.0 → 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.
@@ -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
+ });
@@ -0,0 +1,36 @@
1
+ import { UnityNotRunningError, UnityServerNotRunningError } from './port-resolver.js';
2
+ import { ProjectMismatchError } from './project-validator.js';
3
+
4
+ export function getProjectResolutionErrorLines(
5
+ error: UnityNotRunningError | UnityServerNotRunningError | ProjectMismatchError,
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
+
17
+ if (error instanceof UnityNotRunningError) {
18
+ return [
19
+ 'Error: Unity Editor for this project is not running.',
20
+ '',
21
+ ` Project: ${error.projectRoot}`,
22
+ '',
23
+ 'Start the Unity Editor for this project and try again.',
24
+ ];
25
+ }
26
+
27
+ return [
28
+ 'Error: Connected Unity instance belongs to a different project.',
29
+ '',
30
+ ` Project: ${error.expectedProjectRoot}`,
31
+ ` Connected to: ${error.connectedProjectRoot}`,
32
+ '',
33
+ 'Another Unity instance was found, but it belongs to a different project.',
34
+ 'Start the Unity Editor for this project, or use --project-path to specify the target.',
35
+ ];
36
+ }
package/src/cli.ts CHANGED
@@ -36,9 +36,14 @@ 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';
46
+ import { getProjectResolutionErrorLines } from './cli-project-error.js';
42
47
 
43
48
  interface CliOptions extends GlobalOptions {
44
49
  [key: string]: unknown;
@@ -429,25 +434,17 @@ async function runWithErrorHandling(fn: () => Promise<void>): Promise<void> {
429
434
  try {
430
435
  await fn();
431
436
  } catch (error) {
432
- if (error instanceof UnityNotRunningError) {
433
- console.error('\x1b[31mError: Unity Editor for this project is not running.\x1b[0m');
434
- console.error('');
435
- console.error(` Project: ${error.projectRoot}`);
436
- console.error('');
437
- console.error('Start the Unity Editor for this project and try again.');
437
+ if (error instanceof UnityNotRunningError || error instanceof UnityServerNotRunningError) {
438
+ for (const line of getProjectResolutionErrorLines(error)) {
439
+ console.error(line.startsWith('Error: ') ? `\x1b[31m${line}\x1b[0m` : line);
440
+ }
438
441
  process.exit(1);
439
442
  }
440
443
 
441
444
  if (error instanceof ProjectMismatchError) {
442
- console.error('\x1b[31mError: Unity Editor for this project is not running.\x1b[0m');
443
- console.error('');
444
- console.error(` Project: ${error.expectedProjectRoot}`);
445
- console.error(` Connected to: ${error.connectedProjectRoot}`);
446
- console.error('');
447
- console.error('Another Unity instance was found, but it belongs to a different project.');
448
- console.error(
449
- 'Start the Unity Editor for this project, or use --project-path to specify the target.',
450
- );
445
+ for (const line of getProjectResolutionErrorLines(error)) {
446
+ console.error(line.startsWith('Error: ') ? `\x1b[31m${line}\x1b[0m` : line);
447
+ }
451
448
  process.exit(1);
452
449
  }
453
450
 
@@ -604,11 +601,9 @@ compdef _uloop uloop`;
604
601
  /**
605
602
  * Get the currently installed version of uloop-cli from npm.
606
603
  */
607
- function getInstalledVersion(callback: (version: string | null) => void): void {
604
+ export function getInstalledVersion(callback: (version: string | null) => void): void {
608
605
  const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
609
- const child = spawn(npmCommand, ['list', '-g', 'uloop-cli', '--json'], {
610
- shell: true,
611
- });
606
+ const child = spawn(npmCommand, ['list', '-g', 'uloop-cli', '--json']);
612
607
 
613
608
  let stdout = '';
614
609
  child.stdout.on('data', (data: Buffer) => {
@@ -663,14 +658,13 @@ function getInstalledVersion(callback: (version: string | null) => void): void {
663
658
  /**
664
659
  * Update uloop CLI to the latest version using npm.
665
660
  */
666
- function updateCli(): void {
661
+ export function updateCli(): void {
667
662
  const previousVersion = VERSION;
668
663
  console.log('Updating uloop-cli to the latest version...');
669
664
 
670
665
  const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
671
666
  const child = spawn(npmCommand, ['install', '-g', 'uloop-cli@latest'], {
672
667
  stdio: 'inherit',
673
- shell: true,
674
668
  });
675
669
 
676
670
  child.on('close', (code) => {
@@ -1039,4 +1033,6 @@ async function main(): Promise<void> {
1039
1033
  program.parse();
1040
1034
  }
1041
1035
 
1042
- void main();
1036
+ if (process.env.JEST_WORKER_ID === undefined) {
1037
+ void main();
1038
+ }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.7.0",
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
@@ -188,6 +250,14 @@ function checkUnityBusyState(projectPath?: string): void {
188
250
  }
189
251
  }
190
252
 
253
+ function checkUnityBusyStateBeforeProjectResolution(globalOptions: GlobalOptions): void {
254
+ if (globalOptions.port !== undefined) {
255
+ return;
256
+ }
257
+
258
+ checkUnityBusyState(globalOptions.projectPath);
259
+ }
260
+
191
261
  export async function executeToolCommand(
192
262
  toolName: string,
193
263
  params: Record<string, unknown>,
@@ -201,6 +271,7 @@ export async function executeToolCommand(
201
271
  }
202
272
  portNumber = parsed;
203
273
  }
274
+ checkUnityBusyStateBeforeProjectResolution(globalOptions);
204
275
  const port = await resolveUnityPort(portNumber, globalOptions.projectPath);
205
276
  const compileOptions = getCompileExecutionOptions(toolName, params);
206
277
  const shouldWaitForDomainReload = compileOptions.waitForDomainReload;
@@ -228,7 +299,7 @@ export async function executeToolCommand(
228
299
  let requestDispatched = false;
229
300
 
230
301
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
231
- checkUnityBusyState(globalOptions.projectPath);
302
+ checkUnityBusyStateBeforeProjectResolution(globalOptions);
232
303
 
233
304
  const client = new DirectUnityClient(port);
234
305
  try {
@@ -308,8 +379,8 @@ export async function executeToolCommand(
308
379
  if (immediateResult === undefined && !requestDispatched) {
309
380
  spinner.stop();
310
381
  restoreStdin();
311
- if (lastError instanceof Error) {
312
- throw lastError;
382
+ if (lastError !== undefined) {
383
+ await throwFinalToolError(lastError, projectRoot, shouldValidateProject);
313
384
  }
314
385
  throw new Error(
315
386
  'Compile request never reached Unity. Check that Unity is running and retry.',
@@ -371,15 +442,7 @@ export async function executeToolCommand(
371
442
  if (lastError === undefined) {
372
443
  throw new Error('Tool execution failed without error details.');
373
444
  }
374
- if (lastError instanceof Error) {
375
- throw lastError;
376
- }
377
- if (typeof lastError === 'string') {
378
- throw new Error(lastError);
379
- }
380
-
381
- const serializedError = JSON.stringify(lastError);
382
- throw new Error(serializedError ?? 'Unknown error');
445
+ await throwFinalToolError(lastError, projectRoot, shouldValidateProject);
383
446
  }
384
447
 
385
448
  export async function listAvailableTools(globalOptions: GlobalOptions): Promise<void> {
@@ -391,6 +454,7 @@ export async function listAvailableTools(globalOptions: GlobalOptions): Promise<
391
454
  }
392
455
  portNumber = parsed;
393
456
  }
457
+ checkUnityBusyStateBeforeProjectResolution(globalOptions);
394
458
  const port = await resolveUnityPort(portNumber, globalOptions.projectPath);
395
459
  const projectRoot =
396
460
  globalOptions.projectPath !== undefined
@@ -403,7 +467,7 @@ export async function listAvailableTools(globalOptions: GlobalOptions): Promise<
403
467
 
404
468
  let lastError: unknown;
405
469
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
406
- checkUnityBusyState(globalOptions.projectPath);
470
+ checkUnityBusyStateBeforeProjectResolution(globalOptions);
407
471
 
408
472
  const client = new DirectUnityClient(port);
409
473
  try {
@@ -445,7 +509,7 @@ export async function listAvailableTools(globalOptions: GlobalOptions): Promise<
445
509
 
446
510
  spinner.stop();
447
511
  restoreStdin();
448
- throw lastError;
512
+ await throwFinalToolError(lastError, projectRoot, shouldValidateProject);
449
513
  }
450
514
 
451
515
  interface UnityToolInfo {
@@ -488,6 +552,7 @@ export async function syncTools(globalOptions: GlobalOptions): Promise<void> {
488
552
  }
489
553
  portNumber = parsed;
490
554
  }
555
+ checkUnityBusyStateBeforeProjectResolution(globalOptions);
491
556
  const port = await resolveUnityPort(portNumber, globalOptions.projectPath);
492
557
  const projectRoot =
493
558
  globalOptions.projectPath !== undefined
@@ -500,7 +565,7 @@ export async function syncTools(globalOptions: GlobalOptions): Promise<void> {
500
565
 
501
566
  let lastError: unknown;
502
567
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
503
- checkUnityBusyState(globalOptions.projectPath);
568
+ checkUnityBusyStateBeforeProjectResolution(globalOptions);
504
569
 
505
570
  const client = new DirectUnityClient(port);
506
571
  try {
@@ -562,5 +627,5 @@ export async function syncTools(globalOptions: GlobalOptions): Promise<void> {
562
627
 
563
628
  spinner.stop();
564
629
  restoreStdin();
565
- throw lastError;
630
+ await throwFinalToolError(lastError, projectRoot, shouldValidateProject);
566
631
  }
@@ -23,6 +23,12 @@ export class UnityNotRunningError extends Error {
23
23
  }
24
24
  }
25
25
 
26
+ export class UnityServerNotRunningError extends Error {
27
+ constructor(public readonly projectRoot: string) {
28
+ super('UNITY_SERVER_NOT_RUNNING');
29
+ }
30
+ }
31
+
26
32
  interface UnityMcpSettings {
27
33
  isServerRunning?: boolean;
28
34
  customPort?: number;
@@ -130,12 +136,6 @@ async function readPortFromSettingsOrThrow(projectRoot: string): Promise<number>
130
136
  }
131
137
  const settings = parsed as UnityMcpSettings;
132
138
 
133
- // Only block when isServerRunning is explicitly false (Unity clean shutdown).
134
- // undefined/missing means old settings format — proceed to next validation stage.
135
- if (settings.isServerRunning === false) {
136
- throw new UnityNotRunningError(projectRoot);
137
- }
138
-
139
139
  const port = resolvePortFromUnitySettings(settings);
140
140
  if (port !== null) {
141
141
  return port;
@@ -34,7 +34,7 @@ export function getUnitySettingsCandidatePaths(dirPath: string): string[] {
34
34
  }
35
35
 
36
36
  export function hasUloopInstalled(dirPath: string): boolean {
37
- return getUnitySettingsCandidatePaths(dirPath).some(path => existsSync(path));
37
+ return getUnitySettingsCandidatePaths(dirPath).some((path) => existsSync(path));
38
38
  }
39
39
 
40
40
  function isUnityProjectWithUloop(dirPath: string): boolean {