uloop-cli 0.64.1 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,11 +12,18 @@ import { existsSync } from 'fs';
12
12
  import { join } from 'path';
13
13
  import * as semver from 'semver';
14
14
  import { DirectUnityClient } from './direct-unity-client.js';
15
- import { resolveUnityPort } from './port-resolver.js';
15
+ import { resolveUnityPort, validateProjectPath } from './port-resolver.js';
16
16
  import { saveToolsCache, getCacheFilePath, ToolsCache, ToolDefinition } from './tool-cache.js';
17
17
  import { VERSION } from './version.js';
18
18
  import { createSpinner } from './spinner.js';
19
19
  import { findUnityProjectRoot } from './project-root.js';
20
+ import {
21
+ type CompileExecutionOptions,
22
+ ensureCompileRequestId,
23
+ resolveCompileExecutionOptions,
24
+ sleep,
25
+ waitForCompileCompletion,
26
+ } from './compile-helpers.js';
20
27
 
21
28
  /**
22
29
  * Suppress stdin echo during async operation to prevent escape sequences from being displayed.
@@ -54,13 +61,32 @@ function suppressStdinEcho(): () => void {
54
61
 
55
62
  export interface GlobalOptions {
56
63
  port?: string;
64
+ projectPath?: string;
65
+ }
66
+
67
+ function stripInternalFields(result: Record<string, unknown>): Record<string, unknown> {
68
+ const cleaned = { ...result };
69
+ delete cleaned['ProjectRoot'];
70
+ return cleaned;
57
71
  }
58
72
 
59
73
  const RETRY_DELAY_MS = 500;
60
74
  const MAX_RETRIES = 3;
75
+ const COMPILE_WAIT_TIMEOUT_MS = 90000;
76
+ const COMPILE_WAIT_POLL_INTERVAL_MS = 100;
61
77
 
62
- function sleep(ms: number): Promise<void> {
63
- return new Promise((resolve) => setTimeout(resolve, ms));
78
+ function getCompileExecutionOptions(
79
+ toolName: string,
80
+ params: Record<string, unknown>,
81
+ ): CompileExecutionOptions {
82
+ if (toolName !== 'compile') {
83
+ return {
84
+ forceRecompile: false,
85
+ waitForDomainReload: false,
86
+ };
87
+ }
88
+
89
+ return resolveCompileExecutionOptions(params);
64
90
  }
65
91
 
66
92
  function isRetryableError(error: unknown): boolean {
@@ -75,6 +101,18 @@ function isRetryableError(error: unknown): boolean {
75
101
  );
76
102
  }
77
103
 
104
+ // Distinct from isRetryableError(): that function covers pre-connection failures
105
+ // (ECONNREFUSED, EADDRNOTAVAIL) which cannot occur after dispatch.
106
+ // This function covers post-dispatch TCP failures where Unity may have received
107
+ // the request but the response was lost — file-based recovery is appropriate.
108
+ export function isTransportDisconnectError(error: unknown): boolean {
109
+ if (!(error instanceof Error)) {
110
+ return false;
111
+ }
112
+ const message: string = error.message;
113
+ return message === 'UNITY_NO_RESPONSE' || message.startsWith('Connection lost:');
114
+ }
115
+
78
116
  /**
79
117
  * Compare two semantic versions safely.
80
118
  * Returns true if v1 < v2, false otherwise.
@@ -125,8 +163,9 @@ function checkServerVersion(result: Record<string, unknown>): void {
125
163
  * Check if Unity is in a busy state (compiling, reloading, or server starting).
126
164
  * Throws an error with appropriate message if busy.
127
165
  */
128
- function checkUnityBusyState(): void {
129
- const projectRoot = findUnityProjectRoot();
166
+ function checkUnityBusyState(projectPath?: string): void {
167
+ const projectRoot =
168
+ projectPath !== undefined ? validateProjectPath(projectPath) : findUnityProjectRoot();
130
169
  if (projectRoot === null) {
131
170
  return;
132
171
  }
@@ -160,39 +199,82 @@ export async function executeToolCommand(
160
199
  }
161
200
  portNumber = parsed;
162
201
  }
163
- const port = await resolveUnityPort(portNumber);
202
+ const port = await resolveUnityPort(portNumber, globalOptions.projectPath);
203
+ const compileOptions = getCompileExecutionOptions(toolName, params);
204
+ const shouldWaitForDomainReload = compileOptions.waitForDomainReload;
205
+ const compileRequestId = shouldWaitForDomainReload ? ensureCompileRequestId(params) : undefined;
164
206
 
165
207
  const restoreStdin = suppressStdinEcho();
166
208
  const spinner = createSpinner('Connecting to Unity...');
167
209
 
168
210
  let lastError: unknown;
211
+ let immediateResult: Record<string, unknown> | undefined;
212
+ const projectRoot =
213
+ globalOptions.projectPath !== undefined
214
+ ? validateProjectPath(globalOptions.projectPath)
215
+ : findUnityProjectRoot();
216
+
217
+ // Monotonically-increasing flag: once true, retries cannot reset it to false.
218
+ // The retry loop overwrites `lastError` and `immediateResult` on each attempt,
219
+ // which destroys the evidence of whether an earlier attempt successfully dispatched
220
+ // the request to Unity. This flag preserves that information across retries.
221
+ // See: git log cb3d63e..HEAD for the history of oscillating fixes caused by
222
+ // inferring dispatch status from `immediateResult` alone.
223
+ let requestDispatched = false;
224
+
169
225
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
170
- checkUnityBusyState();
226
+ checkUnityBusyState(globalOptions.projectPath);
171
227
 
172
228
  const client = new DirectUnityClient(port);
173
229
  try {
174
230
  await client.connect();
175
231
 
176
232
  spinner.update(`Executing ${toolName}...`);
233
+ // connect() succeeded: socket is established. sendRequest() calls socket.write()
234
+ // synchronously (direct-unity-client.ts:136), so the data reaches the kernel
235
+ // send buffer before any async error can occur. Safe to mark as dispatched here.
236
+ requestDispatched = true;
177
237
  const result = await client.sendRequest<Record<string, unknown>>(toolName, params);
178
238
 
179
239
  if (result === undefined || result === null) {
180
240
  throw new Error('UNITY_NO_RESPONSE');
181
241
  }
182
242
 
183
- // Success - stop spinner and output result
184
- spinner.stop();
185
- restoreStdin();
243
+ immediateResult = result;
244
+ if (!shouldWaitForDomainReload) {
245
+ spinner.stop();
246
+ restoreStdin();
186
247
 
187
- // Check server version and warn if mismatched
188
- checkServerVersion(result);
248
+ checkServerVersion(result);
249
+ console.log(JSON.stringify(stripInternalFields(result), null, 2));
250
+ return;
251
+ }
189
252
 
190
- console.log(JSON.stringify(result, null, 2));
191
- return;
253
+ break;
192
254
  } catch (error) {
193
255
  lastError = error;
194
256
  client.disconnect();
195
257
 
258
+ // After a compile request has been dispatched, retrying is counterproductive:
259
+ // the next loop iteration calls checkUnityBusyState() OUTSIDE the try block,
260
+ // which throws UNITY_DOMAIN_RELOAD during domain reload and escapes the
261
+ // entire function — bypassing waitForCompileCompletion() recovery.
262
+ if (requestDispatched && shouldWaitForDomainReload) {
263
+ if (isTransportDisconnectError(error)) {
264
+ // Unity may have received the request before the TCP drop.
265
+ // Break out of retry loop → proceed to file-based recovery below.
266
+ spinner.update('Connection lost during compile. Waiting for result file...');
267
+ break;
268
+ }
269
+ // JSON-RPC error (e.g. "Unity error: ..."): Unity processed the request
270
+ // and returned an explicit error. No result file will be written
271
+ // (confirmed: CompileUseCase.ExecuteAsync() is not reached when
272
+ // JSON-RPC error occurs at parameter validation / security check).
273
+ spinner.stop();
274
+ restoreStdin();
275
+ throw error instanceof Error ? error : new Error(String(error));
276
+ }
277
+
196
278
  if (!isRetryableError(error) || attempt >= MAX_RETRIES) {
197
279
  break;
198
280
  }
@@ -203,9 +285,92 @@ export async function executeToolCommand(
203
285
  }
204
286
  }
205
287
 
288
+ if (shouldWaitForDomainReload && compileRequestId) {
289
+ // Fail fast when the compile request never reached Unity.
290
+ // Without this guard, unreachable Unity would cause a 90-second wait for a
291
+ // result file that will never be created.
292
+ // We check both conditions because:
293
+ // - immediateResult === undefined: no JSON-RPC response was received
294
+ // - !requestDispatched: no attempt ever successfully connected and called sendRequest()
295
+ // If requestDispatched is true but immediateResult is undefined, the request was sent
296
+ // but the TCP connection dropped before the response arrived (domain reload scenario).
297
+ // In that case, Unity may have already written the result file, so we proceed to
298
+ // file-based polling recovery.
299
+ if (immediateResult === undefined && !requestDispatched) {
300
+ spinner.stop();
301
+ restoreStdin();
302
+ if (lastError instanceof Error) {
303
+ throw lastError;
304
+ }
305
+ throw new Error(
306
+ 'Compile request never reached Unity. Check that Unity is running and retry.',
307
+ );
308
+ }
309
+
310
+ const projectRootFromUnity: string | undefined =
311
+ immediateResult !== undefined
312
+ ? (immediateResult['ProjectRoot'] as string | undefined)
313
+ : undefined;
314
+ const effectiveProjectRoot: string | null = projectRootFromUnity ?? projectRoot;
315
+
316
+ // File-based polling requires a known project root
317
+ if (effectiveProjectRoot === null) {
318
+ spinner.stop();
319
+ restoreStdin();
320
+ if (immediateResult !== undefined) {
321
+ checkServerVersion(immediateResult);
322
+ console.log(JSON.stringify(stripInternalFields(immediateResult), null, 2));
323
+ return;
324
+ }
325
+ if (lastError instanceof Error) {
326
+ throw lastError;
327
+ }
328
+ throw new Error(
329
+ 'Compile request failed and project root is unknown. Check connection and retry.',
330
+ );
331
+ }
332
+
333
+ spinner.update('Waiting for domain reload to complete...');
334
+ const { outcome, result: storedResult } = await waitForCompileCompletion<
335
+ Record<string, unknown>
336
+ >({
337
+ projectRoot: effectiveProjectRoot,
338
+ requestId: compileRequestId,
339
+ timeoutMs: COMPILE_WAIT_TIMEOUT_MS,
340
+ pollIntervalMs: COMPILE_WAIT_POLL_INTERVAL_MS,
341
+ unityPort: port,
342
+ });
343
+
344
+ if (outcome === 'timed_out') {
345
+ lastError = new Error(
346
+ `Compile wait timed out after ${COMPILE_WAIT_TIMEOUT_MS}ms. Run 'uloop fix' and retry.`,
347
+ );
348
+ } else {
349
+ const finalResult = storedResult ?? immediateResult;
350
+ if (finalResult !== undefined) {
351
+ spinner.stop();
352
+ restoreStdin();
353
+ checkServerVersion(finalResult);
354
+ console.log(JSON.stringify(stripInternalFields(finalResult), null, 2));
355
+ return;
356
+ }
357
+ }
358
+ }
359
+
206
360
  spinner.stop();
207
361
  restoreStdin();
208
- throw lastError;
362
+ if (lastError === undefined) {
363
+ throw new Error('Tool execution failed without error details.');
364
+ }
365
+ if (lastError instanceof Error) {
366
+ throw lastError;
367
+ }
368
+ if (typeof lastError === 'string') {
369
+ throw new Error(lastError);
370
+ }
371
+
372
+ const serializedError = JSON.stringify(lastError);
373
+ throw new Error(serializedError ?? 'Unknown error');
209
374
  }
210
375
 
211
376
  export async function listAvailableTools(globalOptions: GlobalOptions): Promise<void> {
@@ -217,14 +382,14 @@ export async function listAvailableTools(globalOptions: GlobalOptions): Promise<
217
382
  }
218
383
  portNumber = parsed;
219
384
  }
220
- const port = await resolveUnityPort(portNumber);
385
+ const port = await resolveUnityPort(portNumber, globalOptions.projectPath);
221
386
 
222
387
  const restoreStdin = suppressStdinEcho();
223
388
  const spinner = createSpinner('Connecting to Unity...');
224
389
 
225
390
  let lastError: unknown;
226
391
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
227
- checkUnityBusyState();
392
+ checkUnityBusyState(globalOptions.projectPath);
228
393
 
229
394
  const client = new DirectUnityClient(port);
230
395
  try {
@@ -305,14 +470,14 @@ export async function syncTools(globalOptions: GlobalOptions): Promise<void> {
305
470
  }
306
471
  portNumber = parsed;
307
472
  }
308
- const port = await resolveUnityPort(portNumber);
473
+ const port = await resolveUnityPort(portNumber, globalOptions.projectPath);
309
474
 
310
475
  const restoreStdin = suppressStdinEcho();
311
476
  const spinner = createSpinner('Connecting to Unity...');
312
477
 
313
478
  let lastError: unknown;
314
479
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
315
- checkUnityBusyState();
480
+ checkUnityBusyState(globalOptions.projectPath);
316
481
 
317
482
  const client = new DirectUnityClient(port);
318
483
  try {
@@ -8,8 +8,8 @@
8
8
 
9
9
  import { readFile } from 'fs/promises';
10
10
  import { existsSync } from 'fs';
11
- import { join } from 'path';
12
- import { findUnityProjectRoot } from './project-root.js';
11
+ import { join, resolve } from 'path';
12
+ import { findUnityProjectRoot, isUnityProject, hasUloopInstalled } from './project-root.js';
13
13
 
14
14
  const DEFAULT_PORT = 8700;
15
15
 
@@ -54,14 +54,52 @@ export function resolvePortFromUnitySettings(settings: UnityMcpSettings): number
54
54
  return null;
55
55
  }
56
56
 
57
- export async function resolveUnityPort(explicitPort?: number): Promise<number> {
57
+ export function validateProjectPath(projectPath: string): string {
58
+ const resolved = resolve(projectPath);
59
+
60
+ if (!existsSync(resolved)) {
61
+ throw new Error(`Path does not exist: ${resolved}`);
62
+ }
63
+
64
+ if (!isUnityProject(resolved)) {
65
+ throw new Error(`Not a Unity project (Assets/ or ProjectSettings/ not found): ${resolved}`);
66
+ }
67
+
68
+ if (!hasUloopInstalled(resolved)) {
69
+ throw new Error(
70
+ `uLoopMCP is not installed in this project (UserSettings/UnityMcpSettings.json not found): ${resolved}`,
71
+ );
72
+ }
73
+
74
+ return resolved;
75
+ }
76
+
77
+ export async function resolveUnityPort(
78
+ explicitPort?: number,
79
+ projectPath?: string,
80
+ ): Promise<number> {
81
+ if (explicitPort !== undefined && projectPath !== undefined) {
82
+ throw new Error('Cannot specify both --port and --project-path. Use one or the other.');
83
+ }
84
+
58
85
  if (explicitPort !== undefined) {
59
86
  return explicitPort;
60
87
  }
61
88
 
89
+ if (projectPath !== undefined) {
90
+ const resolved = validateProjectPath(projectPath);
91
+ const settingsPort = await readPortFromSettings(resolved);
92
+ if (settingsPort !== null) {
93
+ return settingsPort;
94
+ }
95
+ return DEFAULT_PORT;
96
+ }
97
+
62
98
  const projectRoot = findUnityProjectRoot();
63
99
  if (projectRoot === null) {
64
- throw new Error('Unity project not found. Use --port option to specify the port explicitly.');
100
+ throw new Error(
101
+ 'Unity project not found. Use --port or --project-path option to specify the target.',
102
+ );
65
103
  }
66
104
 
67
105
  const settingsPort = await readPortFromSettings(projectRoot);
@@ -22,13 +22,13 @@ const EXCLUDED_DIRS = new Set([
22
22
  'Library',
23
23
  ]);
24
24
 
25
- function isUnityProject(dirPath: string): boolean {
25
+ export function isUnityProject(dirPath: string): boolean {
26
26
  const hasAssets = existsSync(join(dirPath, 'Assets'));
27
27
  const hasProjectSettings = existsSync(join(dirPath, 'ProjectSettings'));
28
28
  return hasAssets && hasProjectSettings;
29
29
  }
30
30
 
31
- function hasUloopInstalled(dirPath: string): boolean {
31
+ export function hasUloopInstalled(dirPath: string): boolean {
32
32
  return existsSync(join(dirPath, 'UserSettings/UnityMcpSettings.json'));
33
33
  }
34
34
 
@@ -17,6 +17,13 @@ uloop focus-window
17
17
 
18
18
  None.
19
19
 
20
+ ## Global Options
21
+
22
+ | Option | Description |
23
+ |--------|-------------|
24
+ | `--project-path <path>` | Target a specific Unity project (mutually exclusive with `--port`). Path resolution follows the same rules as `cd` — absolute paths are used as-is, relative paths are resolved from cwd. |
25
+ | `-p, --port <port>` | Specify Unity TCP port directly (mutually exclusive with `--project-path`). |
26
+
20
27
  ## Examples
21
28
 
22
29
  ```bash
package/src/version.ts CHANGED
@@ -4,4 +4,4 @@
4
4
  * This file exists to avoid bundling the entire package.json into the CLI bundle.
5
5
  * This version is automatically updated by release-please.
6
6
  */
7
- export const VERSION = '0.64.1'; // x-release-please-version
7
+ export const VERSION = '0.66.0'; // x-release-please-version