uloop-cli 1.6.4 → 1.7.2

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,6 +1,6 @@
1
1
  {
2
2
  "name": "uloop-cli",
3
- "version": "1.6.4",
3
+ "version": "1.7.2",
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",
@@ -49,19 +49,19 @@
49
49
  "devDependencies": {
50
50
  "@eslint/js": "10.0.1",
51
51
  "@types/jest": "30.0.0",
52
- "@types/node": "25.5.0",
52
+ "@types/node": "25.5.2",
53
53
  "@types/semver": "7.7.1",
54
54
  "esbuild": "0.28.0",
55
- "eslint": "10.1.0",
55
+ "eslint": "10.2.0",
56
56
  "eslint-config-prettier": "10.1.8",
57
57
  "eslint-plugin-prettier": "5.5.5",
58
58
  "eslint-plugin-security": "4.0.0",
59
59
  "jest": "30.3.0",
60
- "knip": "6.3.0",
60
+ "knip": "6.3.1",
61
61
  "prettier": "3.8.1",
62
62
  "ts-jest": "29.4.9",
63
63
  "typescript": "5.9.3",
64
- "typescript-eslint": "8.58.0"
64
+ "typescript-eslint": "8.58.1"
65
65
  },
66
66
  "overrides": {
67
67
  "minimatch": "10.2.4"
@@ -0,0 +1,120 @@
1
+ const mockResolveUnityPort = jest.fn<Promise<number>, [number | undefined, string | undefined]>();
2
+ const mockValidateProjectPath = jest.fn<string, [string]>();
3
+ const mockFindUnityProjectRoot = jest.fn<string | null, []>();
4
+ const mockExistsSync = jest.fn<boolean, [string]>();
5
+
6
+ jest.mock('../port-resolver.js', () => ({
7
+ resolveUnityPort: (explicitPort?: number, projectPath?: string): Promise<number> =>
8
+ mockResolveUnityPort(explicitPort, projectPath),
9
+ validateProjectPath: (projectPath: string): string => mockValidateProjectPath(projectPath),
10
+ }));
11
+
12
+ jest.mock('../project-root.js', () => ({
13
+ findUnityProjectRoot: (): string | null => mockFindUnityProjectRoot(),
14
+ }));
15
+
16
+ jest.mock('fs', () => ({
17
+ existsSync: (path: string): boolean => mockExistsSync(path),
18
+ }));
19
+
20
+ import { executeToolCommand, listAvailableTools, syncTools } from '../execute-tool.js';
21
+
22
+ describe('busy state detection order', () => {
23
+ beforeEach(() => {
24
+ mockResolveUnityPort.mockReset();
25
+ mockResolveUnityPort.mockRejectedValue(new Error('RESOLVE_CALLED_BEFORE_BUSY_CHECK'));
26
+
27
+ mockValidateProjectPath.mockReset();
28
+ mockValidateProjectPath.mockReturnValue('/project');
29
+
30
+ mockFindUnityProjectRoot.mockReset();
31
+ mockFindUnityProjectRoot.mockReturnValue('/project');
32
+
33
+ mockExistsSync.mockReset();
34
+ mockExistsSync.mockImplementation((path: string) => path.endsWith('serverstarting.lock'));
35
+ });
36
+
37
+ it('checks busy state before resolving port for tool execution', async () => {
38
+ await expect(executeToolCommand('get-logs', {}, { projectPath: '/project' })).rejects.toThrow(
39
+ 'UNITY_SERVER_STARTING',
40
+ );
41
+
42
+ expect(mockResolveUnityPort).not.toHaveBeenCalled();
43
+ });
44
+
45
+ it('checks busy state before resolving port for list', async () => {
46
+ await expect(listAvailableTools({ projectPath: '/project' })).rejects.toThrow(
47
+ 'UNITY_SERVER_STARTING',
48
+ );
49
+
50
+ expect(mockResolveUnityPort).not.toHaveBeenCalled();
51
+ });
52
+
53
+ it('checks busy state before resolving port for sync', async () => {
54
+ await expect(syncTools({ projectPath: '/project' })).rejects.toThrow('UNITY_SERVER_STARTING');
55
+
56
+ expect(mockResolveUnityPort).not.toHaveBeenCalled();
57
+ });
58
+
59
+ it('skips busy state checks when an explicit port is provided for tool execution', async () => {
60
+ mockResolveUnityPort.mockRejectedValue(new Error('EXPLICIT_PORT_RESOLVED'));
61
+
62
+ await expect(executeToolCommand('get-logs', {}, { port: '8711' })).rejects.toThrow(
63
+ 'EXPLICIT_PORT_RESOLVED',
64
+ );
65
+
66
+ expect(mockResolveUnityPort).toHaveBeenCalledWith(8711, undefined);
67
+ });
68
+
69
+ it('preserves usage errors before busy state checks for tool execution', async () => {
70
+ mockResolveUnityPort.mockRejectedValue(
71
+ new Error('Cannot specify both --port and --project-path. Use one or the other.'),
72
+ );
73
+
74
+ await expect(
75
+ executeToolCommand('get-logs', {}, { port: '8711', projectPath: '/project' }),
76
+ ).rejects.toThrow('Cannot specify both --port and --project-path. Use one or the other.');
77
+
78
+ expect(mockResolveUnityPort).toHaveBeenCalledWith(8711, '/project');
79
+ });
80
+
81
+ it('skips busy state checks when an explicit port is provided for list', async () => {
82
+ mockResolveUnityPort.mockRejectedValue(new Error('EXPLICIT_PORT_RESOLVED'));
83
+
84
+ await expect(listAvailableTools({ port: '8711' })).rejects.toThrow('EXPLICIT_PORT_RESOLVED');
85
+
86
+ expect(mockResolveUnityPort).toHaveBeenCalledWith(8711, undefined);
87
+ });
88
+
89
+ it('preserves usage errors before busy state checks for list', async () => {
90
+ mockResolveUnityPort.mockRejectedValue(
91
+ new Error('Cannot specify both --port and --project-path. Use one or the other.'),
92
+ );
93
+
94
+ await expect(listAvailableTools({ port: '8711', projectPath: '/project' })).rejects.toThrow(
95
+ 'Cannot specify both --port and --project-path. Use one or the other.',
96
+ );
97
+
98
+ expect(mockResolveUnityPort).toHaveBeenCalledWith(8711, '/project');
99
+ });
100
+
101
+ it('skips busy state checks when an explicit port is provided for sync', async () => {
102
+ mockResolveUnityPort.mockRejectedValue(new Error('EXPLICIT_PORT_RESOLVED'));
103
+
104
+ await expect(syncTools({ port: '8711' })).rejects.toThrow('EXPLICIT_PORT_RESOLVED');
105
+
106
+ expect(mockResolveUnityPort).toHaveBeenCalledWith(8711, undefined);
107
+ });
108
+
109
+ it('preserves usage errors before busy state checks for sync', async () => {
110
+ mockResolveUnityPort.mockRejectedValue(
111
+ new Error('Cannot specify both --port and --project-path. Use one or the other.'),
112
+ );
113
+
114
+ await expect(syncTools({ port: '8711', projectPath: '/project' })).rejects.toThrow(
115
+ 'Cannot specify both --port and --project-path. Use one or the other.',
116
+ );
117
+
118
+ expect(mockResolveUnityPort).toHaveBeenCalledWith(8711, '/project');
119
+ });
120
+ });
@@ -0,0 +1,33 @@
1
+ import { getProjectResolutionErrorLines } from '../cli-project-error.js';
2
+ import { UnityNotRunningError } from '../port-resolver.js';
3
+ import { ProjectMismatchError } from '../project-validator.js';
4
+
5
+ describe('getProjectResolutionErrorLines', () => {
6
+ it('returns not-running guidance for UnityNotRunningError', () => {
7
+ const lines = getProjectResolutionErrorLines(new UnityNotRunningError('/project/root'));
8
+
9
+ expect(lines).toEqual([
10
+ 'Error: Unity Editor for this project is not running.',
11
+ '',
12
+ ' Project: /project/root',
13
+ '',
14
+ 'Start the Unity Editor for this project and try again.',
15
+ ]);
16
+ });
17
+
18
+ it('returns mismatch guidance for ProjectMismatchError', () => {
19
+ const lines = getProjectResolutionErrorLines(
20
+ new ProjectMismatchError('/expected/project', '/connected/project'),
21
+ );
22
+
23
+ expect(lines).toEqual([
24
+ 'Error: Connected Unity instance belongs to a different project.',
25
+ '',
26
+ ' Project: /expected/project',
27
+ ' Connected to: /connected/project',
28
+ '',
29
+ 'Another Unity instance was found, but it belongs to a different project.',
30
+ 'Start the Unity Editor for this project, or use --project-path to specify the target.',
31
+ ]);
32
+ });
33
+ });
@@ -0,0 +1,129 @@
1
+ type SpawnArgs = [string, string[], Record<string, unknown>?];
2
+
3
+ const mockSpawn = jest.fn<unknown, SpawnArgs>();
4
+
5
+ jest.mock('child_process', () => ({
6
+ spawn: (...args: SpawnArgs): unknown => mockSpawn(...args),
7
+ }));
8
+
9
+ jest.mock(
10
+ 'launch-unity',
11
+ () => ({
12
+ orchestrateLaunch: jest.fn(),
13
+ }),
14
+ { virtual: true },
15
+ );
16
+
17
+ import { getInstalledVersion, updateCli } from '../cli.js';
18
+
19
+ type CloseHandler = (code: number | null) => void;
20
+ type ErrorHandler = (error: Error) => void;
21
+ type DataHandler = (chunk: Buffer) => void;
22
+
23
+ interface MockChildProcess {
24
+ stdout: {
25
+ on: jest.Mock<void, [string, DataHandler]>;
26
+ };
27
+ on: jest.Mock<void, [string, CloseHandler | ErrorHandler]>;
28
+ emitStdout: (chunk: string) => void;
29
+ emitClose: (code: number | null) => void;
30
+ emitError: (error: Error) => void;
31
+ }
32
+
33
+ function createMockChildProcess(): MockChildProcess {
34
+ let closeHandler: CloseHandler | undefined;
35
+ let errorHandler: ErrorHandler | undefined;
36
+ let dataHandler: DataHandler | undefined;
37
+
38
+ return {
39
+ stdout: {
40
+ on: jest.fn((event: string, handler: DataHandler) => {
41
+ if (event === 'data') {
42
+ dataHandler = handler;
43
+ }
44
+ }),
45
+ },
46
+ on: jest.fn((event: string, handler: CloseHandler | ErrorHandler) => {
47
+ if (event === 'close') {
48
+ closeHandler = handler as CloseHandler;
49
+ }
50
+
51
+ if (event === 'error') {
52
+ errorHandler = handler as ErrorHandler;
53
+ }
54
+ }),
55
+ emitStdout: (chunk: string): void => {
56
+ dataHandler?.(Buffer.from(chunk));
57
+ },
58
+ emitClose: (code: number | null): void => {
59
+ closeHandler?.(code);
60
+ },
61
+ emitError: (error: Error): void => {
62
+ errorHandler?.(error);
63
+ },
64
+ };
65
+ }
66
+
67
+ describe('CLI update npm invocation', () => {
68
+ const expectedNpmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
69
+
70
+ beforeEach(() => {
71
+ mockSpawn.mockReset();
72
+ jest.spyOn(console, 'log').mockImplementation(() => {});
73
+ });
74
+
75
+ afterEach(() => {
76
+ jest.restoreAllMocks();
77
+ });
78
+
79
+ it('gets installed version without enabling shell mode', () => {
80
+ const child = createMockChildProcess();
81
+ const callback = jest.fn();
82
+ mockSpawn.mockReturnValue(child);
83
+
84
+ getInstalledVersion(callback);
85
+
86
+ expect(mockSpawn).toHaveBeenCalledWith(expectedNpmCommand, [
87
+ 'list',
88
+ '-g',
89
+ 'uloop-cli',
90
+ '--json',
91
+ ]);
92
+ expect(mockSpawn.mock.calls[0]).toHaveLength(2);
93
+
94
+ child.emitStdout(JSON.stringify({ dependencies: { 'uloop-cli': { version: '1.8.0' } } }));
95
+ child.emitClose(0);
96
+
97
+ expect(callback).toHaveBeenCalledWith('1.8.0');
98
+ });
99
+
100
+ it('updates the CLI without enabling shell mode', () => {
101
+ const updateChild = createMockChildProcess();
102
+ const listChild = createMockChildProcess();
103
+ mockSpawn.mockReturnValueOnce(updateChild).mockReturnValueOnce(listChild);
104
+
105
+ updateCli();
106
+
107
+ expect(mockSpawn).toHaveBeenNthCalledWith(
108
+ 1,
109
+ expectedNpmCommand,
110
+ ['install', '-g', 'uloop-cli@latest'],
111
+ { stdio: 'inherit' },
112
+ );
113
+ const installOptions = mockSpawn.mock.calls[0]?.[2];
114
+ expect(installOptions?.['shell']).toBeUndefined();
115
+
116
+ updateChild.emitClose(0);
117
+
118
+ expect(mockSpawn).toHaveBeenNthCalledWith(2, expectedNpmCommand, [
119
+ 'list',
120
+ '-g',
121
+ 'uloop-cli',
122
+ '--json',
123
+ ]);
124
+ expect(mockSpawn.mock.calls[1]).toHaveLength(2);
125
+
126
+ listChild.emitStdout(JSON.stringify({ dependencies: { 'uloop-cli': { version: '1.7.1' } } }));
127
+ listChild.emitClose(0);
128
+ });
129
+ });
@@ -142,4 +142,49 @@ describe('resolveUnityPort with project settings', () => {
142
142
  'Could not read Unity server port from settings',
143
143
  );
144
144
  });
145
+
146
+ it('returns port when primary settings file is missing but backup exists', async () => {
147
+ writeFileSync(
148
+ join(tempProjectRoot, 'UserSettings/UnityMcpSettings.json.bak'),
149
+ JSON.stringify({ isServerRunning: true, customPort: 8722 }),
150
+ );
151
+
152
+ const port = await resolveUnityPort(undefined, tempProjectRoot);
153
+ expect(port).toBe(8722);
154
+ });
155
+
156
+ it('returns port when primary settings file is missing but temp exists', async () => {
157
+ writeFileSync(
158
+ join(tempProjectRoot, 'UserSettings/UnityMcpSettings.json.tmp'),
159
+ JSON.stringify({ isServerRunning: true, customPort: 8723 }),
160
+ );
161
+
162
+ const port = await resolveUnityPort(undefined, tempProjectRoot);
163
+ expect(port).toBe(8723);
164
+ });
165
+
166
+ it('returns port from temp when both temp and backup exist', async () => {
167
+ writeFileSync(
168
+ join(tempProjectRoot, 'UserSettings/UnityMcpSettings.json.tmp'),
169
+ JSON.stringify({ isServerRunning: true, customPort: 8724 }),
170
+ );
171
+ writeFileSync(
172
+ join(tempProjectRoot, 'UserSettings/UnityMcpSettings.json.bak'),
173
+ JSON.stringify({ isServerRunning: true, customPort: 8725 }),
174
+ );
175
+
176
+ const port = await resolveUnityPort(undefined, tempProjectRoot);
177
+ expect(port).toBe(8724);
178
+ });
179
+
180
+ it('falls back to temp when primary settings file contains invalid JSON', async () => {
181
+ writeFileSync(join(tempProjectRoot, 'UserSettings/UnityMcpSettings.json'), 'not valid json{{{');
182
+ writeFileSync(
183
+ join(tempProjectRoot, 'UserSettings/UnityMcpSettings.json.tmp'),
184
+ JSON.stringify({ isServerRunning: true, customPort: 8726 }),
185
+ );
186
+
187
+ const port = await resolveUnityPort(undefined, tempProjectRoot);
188
+ expect(port).toBe(8726);
189
+ });
145
190
  });
@@ -4,7 +4,11 @@
4
4
  import { mkdirSync, writeFileSync, rmSync } from 'fs';
5
5
  import { join } from 'path';
6
6
  import { tmpdir } from 'os';
7
- import { findUnityProjectRoot, resetMultipleProjectsWarning } from '../project-root.js';
7
+ import {
8
+ findUnityProjectRoot,
9
+ hasUloopInstalled,
10
+ resetMultipleProjectsWarning,
11
+ } from '../project-root.js';
8
12
 
9
13
  function createUnityProject(basePath: string, name: string): string {
10
14
  const projectPath = join(basePath, name);
@@ -95,3 +99,33 @@ describe('findUnityProjectRoot', () => {
95
99
  expect(result).toBe(join(testDir, 'Alpha'));
96
100
  });
97
101
  });
102
+
103
+ describe('hasUloopInstalled', () => {
104
+ let testDir: string;
105
+
106
+ beforeEach(() => {
107
+ testDir = join(
108
+ tmpdir(),
109
+ `uloop-installed-${Date.now()}-${Math.random().toString(36).slice(2)}`,
110
+ );
111
+ mkdirSync(join(testDir, 'Assets'), { recursive: true });
112
+ mkdirSync(join(testDir, 'ProjectSettings'), { recursive: true });
113
+ mkdirSync(join(testDir, 'UserSettings'), { recursive: true });
114
+ });
115
+
116
+ afterEach(() => {
117
+ rmSync(testDir, { recursive: true, force: true });
118
+ });
119
+
120
+ it('returns true when only backup settings file exists', () => {
121
+ writeFileSync(join(testDir, 'UserSettings/UnityMcpSettings.json.bak'), '{}');
122
+
123
+ expect(hasUloopInstalled(testDir)).toBe(true);
124
+ });
125
+
126
+ it('returns true when only temp settings file exists', () => {
127
+ writeFileSync(join(testDir, 'UserSettings/UnityMcpSettings.json.tmp'), '{}');
128
+
129
+ expect(hasUloopInstalled(testDir)).toBe(true);
130
+ });
131
+ });
@@ -0,0 +1,26 @@
1
+ import { UnityNotRunningError } from './port-resolver.js';
2
+ import { ProjectMismatchError } from './project-validator.js';
3
+
4
+ export function getProjectResolutionErrorLines(
5
+ error: UnityNotRunningError | ProjectMismatchError,
6
+ ): string[] {
7
+ if (error instanceof UnityNotRunningError) {
8
+ return [
9
+ 'Error: Unity Editor for this project is not running.',
10
+ '',
11
+ ` Project: ${error.projectRoot}`,
12
+ '',
13
+ 'Start the Unity Editor for this project and try again.',
14
+ ];
15
+ }
16
+
17
+ return [
18
+ 'Error: Connected Unity instance belongs to a different project.',
19
+ '',
20
+ ` Project: ${error.expectedProjectRoot}`,
21
+ ` Connected to: ${error.connectedProjectRoot}`,
22
+ '',
23
+ 'Another Unity instance was found, but it belongs to a different project.',
24
+ 'Start the Unity Editor for this project, or use --project-path to specify the target.',
25
+ ];
26
+ }
package/src/cli.ts CHANGED
@@ -39,6 +39,7 @@ import { findUnityProjectRoot } from './project-root.js';
39
39
  import { validateProjectPath, UnityNotRunningError } from './port-resolver.js';
40
40
  import { ProjectMismatchError } from './project-validator.js';
41
41
  import { filterEnabledTools, isToolEnabled } from './tool-settings-loader.js';
42
+ import { getProjectResolutionErrorLines } from './cli-project-error.js';
42
43
 
43
44
  interface CliOptions extends GlobalOptions {
44
45
  [key: string]: unknown;
@@ -430,24 +431,16 @@ async function runWithErrorHandling(fn: () => Promise<void>): Promise<void> {
430
431
  await fn();
431
432
  } catch (error) {
432
433
  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.');
434
+ for (const line of getProjectResolutionErrorLines(error)) {
435
+ console.error(line.startsWith('Error: ') ? `\x1b[31m${line}\x1b[0m` : line);
436
+ }
438
437
  process.exit(1);
439
438
  }
440
439
 
441
440
  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
- );
441
+ for (const line of getProjectResolutionErrorLines(error)) {
442
+ console.error(line.startsWith('Error: ') ? `\x1b[31m${line}\x1b[0m` : line);
443
+ }
451
444
  process.exit(1);
452
445
  }
453
446
 
@@ -604,11 +597,9 @@ compdef _uloop uloop`;
604
597
  /**
605
598
  * Get the currently installed version of uloop-cli from npm.
606
599
  */
607
- function getInstalledVersion(callback: (version: string | null) => void): void {
600
+ export function getInstalledVersion(callback: (version: string | null) => void): void {
608
601
  const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
609
- const child = spawn(npmCommand, ['list', '-g', 'uloop-cli', '--json'], {
610
- shell: true,
611
- });
602
+ const child = spawn(npmCommand, ['list', '-g', 'uloop-cli', '--json']);
612
603
 
613
604
  let stdout = '';
614
605
  child.stdout.on('data', (data: Buffer) => {
@@ -663,14 +654,13 @@ function getInstalledVersion(callback: (version: string | null) => void): void {
663
654
  /**
664
655
  * Update uloop CLI to the latest version using npm.
665
656
  */
666
- function updateCli(): void {
657
+ export function updateCli(): void {
667
658
  const previousVersion = VERSION;
668
659
  console.log('Updating uloop-cli to the latest version...');
669
660
 
670
661
  const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
671
662
  const child = spawn(npmCommand, ['install', '-g', 'uloop-cli@latest'], {
672
663
  stdio: 'inherit',
673
- shell: true,
674
664
  });
675
665
 
676
666
  child.on('close', (code) => {
@@ -1039,4 +1029,6 @@ async function main(): Promise<void> {
1039
1029
  program.parse();
1040
1030
  }
1041
1031
 
1042
- void main();
1032
+ if (process.env.JEST_WORKER_ID === undefined) {
1033
+ void main();
1034
+ }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.6.4",
2
+ "version": "1.7.2",
3
3
  "tools": [
4
4
  {
5
5
  "name": "compile",
@@ -188,6 +188,14 @@ function checkUnityBusyState(projectPath?: string): void {
188
188
  }
189
189
  }
190
190
 
191
+ function checkUnityBusyStateBeforeProjectResolution(globalOptions: GlobalOptions): void {
192
+ if (globalOptions.port !== undefined) {
193
+ return;
194
+ }
195
+
196
+ checkUnityBusyState(globalOptions.projectPath);
197
+ }
198
+
191
199
  export async function executeToolCommand(
192
200
  toolName: string,
193
201
  params: Record<string, unknown>,
@@ -201,6 +209,7 @@ export async function executeToolCommand(
201
209
  }
202
210
  portNumber = parsed;
203
211
  }
212
+ checkUnityBusyStateBeforeProjectResolution(globalOptions);
204
213
  const port = await resolveUnityPort(portNumber, globalOptions.projectPath);
205
214
  const compileOptions = getCompileExecutionOptions(toolName, params);
206
215
  const shouldWaitForDomainReload = compileOptions.waitForDomainReload;
@@ -228,7 +237,7 @@ export async function executeToolCommand(
228
237
  let requestDispatched = false;
229
238
 
230
239
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
231
- checkUnityBusyState(globalOptions.projectPath);
240
+ checkUnityBusyStateBeforeProjectResolution(globalOptions);
232
241
 
233
242
  const client = new DirectUnityClient(port);
234
243
  try {
@@ -391,6 +400,7 @@ export async function listAvailableTools(globalOptions: GlobalOptions): Promise<
391
400
  }
392
401
  portNumber = parsed;
393
402
  }
403
+ checkUnityBusyStateBeforeProjectResolution(globalOptions);
394
404
  const port = await resolveUnityPort(portNumber, globalOptions.projectPath);
395
405
  const projectRoot =
396
406
  globalOptions.projectPath !== undefined
@@ -403,7 +413,7 @@ export async function listAvailableTools(globalOptions: GlobalOptions): Promise<
403
413
 
404
414
  let lastError: unknown;
405
415
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
406
- checkUnityBusyState(globalOptions.projectPath);
416
+ checkUnityBusyStateBeforeProjectResolution(globalOptions);
407
417
 
408
418
  const client = new DirectUnityClient(port);
409
419
  try {
@@ -488,6 +498,7 @@ export async function syncTools(globalOptions: GlobalOptions): Promise<void> {
488
498
  }
489
499
  portNumber = parsed;
490
500
  }
501
+ checkUnityBusyStateBeforeProjectResolution(globalOptions);
491
502
  const port = await resolveUnityPort(portNumber, globalOptions.projectPath);
492
503
  const projectRoot =
493
504
  globalOptions.projectPath !== undefined
@@ -500,7 +511,7 @@ export async function syncTools(globalOptions: GlobalOptions): Promise<void> {
500
511
 
501
512
  let lastError: unknown;
502
513
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
503
- checkUnityBusyState(globalOptions.projectPath);
514
+ checkUnityBusyStateBeforeProjectResolution(globalOptions);
504
515
 
505
516
  const client = new DirectUnityClient(port);
506
517
  try {
@@ -10,7 +10,12 @@ import { PRODUCT_DISPLAY_NAME } from './cli-constants';
10
10
  import { readFile } from 'fs/promises';
11
11
  import { existsSync } from 'fs';
12
12
  import { join, resolve } from 'path';
13
- import { findUnityProjectRoot, isUnityProject, hasUloopInstalled } from './project-root.js';
13
+ import {
14
+ findUnityProjectRoot,
15
+ getUnitySettingsCandidatePaths,
16
+ isUnityProject,
17
+ hasUloopInstalled,
18
+ } from './project-root.js';
14
19
 
15
20
  export class UnityNotRunningError extends Error {
16
21
  constructor(public readonly projectRoot: string) {
@@ -105,41 +110,37 @@ function createSettingsReadError(projectRoot: string): Error {
105
110
 
106
111
  // File I/O and JSON parsing can fail for external reasons (permissions, corruption, concurrent writes)
107
112
  async function readPortFromSettingsOrThrow(projectRoot: string): Promise<number> {
108
- const settingsPath = join(projectRoot, 'UserSettings/UnityMcpSettings.json');
109
-
110
- if (!existsSync(settingsPath)) {
111
- throw createSettingsReadError(projectRoot);
112
- }
113
-
114
- let content: string;
115
- try {
116
- content = await readFile(settingsPath, 'utf-8');
117
- } catch {
118
- throw createSettingsReadError(projectRoot);
119
- }
120
-
121
- let parsed: unknown;
122
- try {
123
- parsed = JSON.parse(content);
124
- } catch {
125
- throw createSettingsReadError(projectRoot);
126
- }
127
-
128
- if (typeof parsed !== 'object' || parsed === null) {
129
- throw createSettingsReadError(projectRoot);
130
- }
131
- const settings = parsed as UnityMcpSettings;
132
-
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
- const port = resolvePortFromUnitySettings(settings);
140
- if (port === null) {
141
- throw createSettingsReadError(projectRoot);
142
- }
143
-
144
- return port;
113
+ for (const settingsPath of getUnitySettingsCandidatePaths(projectRoot)) {
114
+ let content: string;
115
+ try {
116
+ content = await readFile(settingsPath, 'utf-8');
117
+ } catch {
118
+ continue;
119
+ }
120
+
121
+ let parsed: unknown;
122
+ try {
123
+ parsed = JSON.parse(content);
124
+ } catch {
125
+ continue;
126
+ }
127
+
128
+ if (typeof parsed !== 'object' || parsed === null) {
129
+ continue;
130
+ }
131
+ const settings = parsed as UnityMcpSettings;
132
+
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
+ const port = resolvePortFromUnitySettings(settings);
140
+ if (port !== null) {
141
+ return port;
142
+ }
143
+ }
144
+
145
+ throw createSettingsReadError(projectRoot);
145
146
  }