uloop-cli 0.64.0 → 0.65.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.
- package/README.md +3 -0
- package/dist/cli.bundle.cjs +476 -118
- package/dist/cli.bundle.cjs.map +4 -4
- package/package.json +4 -4
- package/src/__tests__/cli-e2e.test.ts +147 -3
- package/src/__tests__/execute-tool.test.ts +31 -0
- package/src/cli.ts +22 -2
- package/src/commands/launch.ts +3 -0
- package/src/compile-helpers.ts +291 -0
- package/src/default-tools.json +5 -1
- package/src/direct-unity-client.ts +22 -3
- package/src/execute-tool.ts +170 -10
- package/src/version.ts +1 -1
|
@@ -72,7 +72,16 @@ export class DirectUnityClient {
|
|
|
72
72
|
|
|
73
73
|
return new Promise((resolve, reject) => {
|
|
74
74
|
const socket = this.socket!;
|
|
75
|
+
|
|
76
|
+
const cleanup = (): void => {
|
|
77
|
+
clearTimeout(timeoutId);
|
|
78
|
+
socket.off('data', onData);
|
|
79
|
+
socket.off('error', onError);
|
|
80
|
+
socket.off('close', onClose);
|
|
81
|
+
};
|
|
82
|
+
|
|
75
83
|
const timeoutId = setTimeout(() => {
|
|
84
|
+
cleanup();
|
|
76
85
|
reject(
|
|
77
86
|
new Error(
|
|
78
87
|
`Request timed out after ${NETWORK_TIMEOUT_MS}ms. Unity may be frozen or busy. [For AI] Run 'uloop focus-window' to bring Unity to the front, then retry the tool. If the issue persists, report this to the user and ask how to proceed. Do NOT kill Unity processes without user permission.`,
|
|
@@ -98,9 +107,7 @@ export class DirectUnityClient {
|
|
|
98
107
|
return;
|
|
99
108
|
}
|
|
100
109
|
|
|
101
|
-
|
|
102
|
-
socket.off('data', onData);
|
|
103
|
-
|
|
110
|
+
cleanup();
|
|
104
111
|
this.receiveBuffer = extractResult.remainingData;
|
|
105
112
|
|
|
106
113
|
const response = JSON.parse(extractResult.jsonContent) as JsonRpcResponse;
|
|
@@ -113,7 +120,19 @@ export class DirectUnityClient {
|
|
|
113
120
|
resolve(response.result as T);
|
|
114
121
|
};
|
|
115
122
|
|
|
123
|
+
const onError = (error: Error): void => {
|
|
124
|
+
cleanup();
|
|
125
|
+
reject(new Error(`Connection lost: ${error.message}`));
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const onClose = (): void => {
|
|
129
|
+
cleanup();
|
|
130
|
+
reject(new Error('UNITY_NO_RESPONSE'));
|
|
131
|
+
};
|
|
132
|
+
|
|
116
133
|
socket.on('data', onData);
|
|
134
|
+
socket.on('error', onError);
|
|
135
|
+
socket.on('close', onClose);
|
|
117
136
|
socket.write(framedMessage);
|
|
118
137
|
});
|
|
119
138
|
}
|
package/src/execute-tool.ts
CHANGED
|
@@ -17,6 +17,13 @@ import { saveToolsCache, getCacheFilePath, ToolsCache, ToolDefinition } from './
|
|
|
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.
|
|
@@ -56,11 +63,29 @@ export interface GlobalOptions {
|
|
|
56
63
|
port?: string;
|
|
57
64
|
}
|
|
58
65
|
|
|
66
|
+
function stripInternalFields(result: Record<string, unknown>): Record<string, unknown> {
|
|
67
|
+
const cleaned = { ...result };
|
|
68
|
+
delete cleaned['ProjectRoot'];
|
|
69
|
+
return cleaned;
|
|
70
|
+
}
|
|
71
|
+
|
|
59
72
|
const RETRY_DELAY_MS = 500;
|
|
60
73
|
const MAX_RETRIES = 3;
|
|
74
|
+
const COMPILE_WAIT_TIMEOUT_MS = 90000;
|
|
75
|
+
const COMPILE_WAIT_POLL_INTERVAL_MS = 100;
|
|
76
|
+
|
|
77
|
+
function getCompileExecutionOptions(
|
|
78
|
+
toolName: string,
|
|
79
|
+
params: Record<string, unknown>,
|
|
80
|
+
): CompileExecutionOptions {
|
|
81
|
+
if (toolName !== 'compile') {
|
|
82
|
+
return {
|
|
83
|
+
forceRecompile: false,
|
|
84
|
+
waitForDomainReload: false,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
61
87
|
|
|
62
|
-
|
|
63
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
88
|
+
return resolveCompileExecutionOptions(params);
|
|
64
89
|
}
|
|
65
90
|
|
|
66
91
|
function isRetryableError(error: unknown): boolean {
|
|
@@ -75,6 +100,18 @@ function isRetryableError(error: unknown): boolean {
|
|
|
75
100
|
);
|
|
76
101
|
}
|
|
77
102
|
|
|
103
|
+
// Distinct from isRetryableError(): that function covers pre-connection failures
|
|
104
|
+
// (ECONNREFUSED, EADDRNOTAVAIL) which cannot occur after dispatch.
|
|
105
|
+
// This function covers post-dispatch TCP failures where Unity may have received
|
|
106
|
+
// the request but the response was lost — file-based recovery is appropriate.
|
|
107
|
+
export function isTransportDisconnectError(error: unknown): boolean {
|
|
108
|
+
if (!(error instanceof Error)) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
const message: string = error.message;
|
|
112
|
+
return message === 'UNITY_NO_RESPONSE' || message.startsWith('Connection lost:');
|
|
113
|
+
}
|
|
114
|
+
|
|
78
115
|
/**
|
|
79
116
|
* Compare two semantic versions safely.
|
|
80
117
|
* Returns true if v1 < v2, false otherwise.
|
|
@@ -161,11 +198,25 @@ export async function executeToolCommand(
|
|
|
161
198
|
portNumber = parsed;
|
|
162
199
|
}
|
|
163
200
|
const port = await resolveUnityPort(portNumber);
|
|
201
|
+
const compileOptions = getCompileExecutionOptions(toolName, params);
|
|
202
|
+
const shouldWaitForDomainReload = compileOptions.waitForDomainReload;
|
|
203
|
+
const compileRequestId = shouldWaitForDomainReload ? ensureCompileRequestId(params) : undefined;
|
|
164
204
|
|
|
165
205
|
const restoreStdin = suppressStdinEcho();
|
|
166
206
|
const spinner = createSpinner('Connecting to Unity...');
|
|
167
207
|
|
|
168
208
|
let lastError: unknown;
|
|
209
|
+
let immediateResult: Record<string, unknown> | undefined;
|
|
210
|
+
const projectRoot = findUnityProjectRoot();
|
|
211
|
+
|
|
212
|
+
// Monotonically-increasing flag: once true, retries cannot reset it to false.
|
|
213
|
+
// The retry loop overwrites `lastError` and `immediateResult` on each attempt,
|
|
214
|
+
// which destroys the evidence of whether an earlier attempt successfully dispatched
|
|
215
|
+
// the request to Unity. This flag preserves that information across retries.
|
|
216
|
+
// See: git log cb3d63e..HEAD for the history of oscillating fixes caused by
|
|
217
|
+
// inferring dispatch status from `immediateResult` alone.
|
|
218
|
+
let requestDispatched = false;
|
|
219
|
+
|
|
169
220
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
170
221
|
checkUnityBusyState();
|
|
171
222
|
|
|
@@ -174,25 +225,51 @@ export async function executeToolCommand(
|
|
|
174
225
|
await client.connect();
|
|
175
226
|
|
|
176
227
|
spinner.update(`Executing ${toolName}...`);
|
|
228
|
+
// connect() succeeded: socket is established. sendRequest() calls socket.write()
|
|
229
|
+
// synchronously (direct-unity-client.ts:136), so the data reaches the kernel
|
|
230
|
+
// send buffer before any async error can occur. Safe to mark as dispatched here.
|
|
231
|
+
requestDispatched = true;
|
|
177
232
|
const result = await client.sendRequest<Record<string, unknown>>(toolName, params);
|
|
178
233
|
|
|
179
234
|
if (result === undefined || result === null) {
|
|
180
235
|
throw new Error('UNITY_NO_RESPONSE');
|
|
181
236
|
}
|
|
182
237
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
238
|
+
immediateResult = result;
|
|
239
|
+
if (!shouldWaitForDomainReload) {
|
|
240
|
+
spinner.stop();
|
|
241
|
+
restoreStdin();
|
|
186
242
|
|
|
187
|
-
|
|
188
|
-
|
|
243
|
+
checkServerVersion(result);
|
|
244
|
+
console.log(JSON.stringify(stripInternalFields(result), null, 2));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
189
247
|
|
|
190
|
-
|
|
191
|
-
return;
|
|
248
|
+
break;
|
|
192
249
|
} catch (error) {
|
|
193
250
|
lastError = error;
|
|
194
251
|
client.disconnect();
|
|
195
252
|
|
|
253
|
+
// After a compile request has been dispatched, retrying is counterproductive:
|
|
254
|
+
// the next loop iteration calls checkUnityBusyState() OUTSIDE the try block,
|
|
255
|
+
// which throws UNITY_DOMAIN_RELOAD during domain reload and escapes the
|
|
256
|
+
// entire function — bypassing waitForCompileCompletion() recovery.
|
|
257
|
+
if (requestDispatched && shouldWaitForDomainReload) {
|
|
258
|
+
if (isTransportDisconnectError(error)) {
|
|
259
|
+
// Unity may have received the request before the TCP drop.
|
|
260
|
+
// Break out of retry loop → proceed to file-based recovery below.
|
|
261
|
+
spinner.update('Connection lost during compile. Waiting for result file...');
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
// JSON-RPC error (e.g. "Unity error: ..."): Unity processed the request
|
|
265
|
+
// and returned an explicit error. No result file will be written
|
|
266
|
+
// (confirmed: CompileUseCase.ExecuteAsync() is not reached when
|
|
267
|
+
// JSON-RPC error occurs at parameter validation / security check).
|
|
268
|
+
spinner.stop();
|
|
269
|
+
restoreStdin();
|
|
270
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
271
|
+
}
|
|
272
|
+
|
|
196
273
|
if (!isRetryableError(error) || attempt >= MAX_RETRIES) {
|
|
197
274
|
break;
|
|
198
275
|
}
|
|
@@ -203,9 +280,92 @@ export async function executeToolCommand(
|
|
|
203
280
|
}
|
|
204
281
|
}
|
|
205
282
|
|
|
283
|
+
if (shouldWaitForDomainReload && compileRequestId) {
|
|
284
|
+
// Fail fast when the compile request never reached Unity.
|
|
285
|
+
// Without this guard, unreachable Unity would cause a 90-second wait for a
|
|
286
|
+
// result file that will never be created.
|
|
287
|
+
// We check both conditions because:
|
|
288
|
+
// - immediateResult === undefined: no JSON-RPC response was received
|
|
289
|
+
// - !requestDispatched: no attempt ever successfully connected and called sendRequest()
|
|
290
|
+
// If requestDispatched is true but immediateResult is undefined, the request was sent
|
|
291
|
+
// but the TCP connection dropped before the response arrived (domain reload scenario).
|
|
292
|
+
// In that case, Unity may have already written the result file, so we proceed to
|
|
293
|
+
// file-based polling recovery.
|
|
294
|
+
if (immediateResult === undefined && !requestDispatched) {
|
|
295
|
+
spinner.stop();
|
|
296
|
+
restoreStdin();
|
|
297
|
+
if (lastError instanceof Error) {
|
|
298
|
+
throw lastError;
|
|
299
|
+
}
|
|
300
|
+
throw new Error(
|
|
301
|
+
'Compile request never reached Unity. Check that Unity is running and retry.',
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const projectRootFromUnity: string | undefined =
|
|
306
|
+
immediateResult !== undefined
|
|
307
|
+
? (immediateResult['ProjectRoot'] as string | undefined)
|
|
308
|
+
: undefined;
|
|
309
|
+
const effectiveProjectRoot: string | null = projectRootFromUnity ?? projectRoot;
|
|
310
|
+
|
|
311
|
+
// File-based polling requires a known project root
|
|
312
|
+
if (effectiveProjectRoot === null) {
|
|
313
|
+
spinner.stop();
|
|
314
|
+
restoreStdin();
|
|
315
|
+
if (immediateResult !== undefined) {
|
|
316
|
+
checkServerVersion(immediateResult);
|
|
317
|
+
console.log(JSON.stringify(stripInternalFields(immediateResult), null, 2));
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (lastError instanceof Error) {
|
|
321
|
+
throw lastError;
|
|
322
|
+
}
|
|
323
|
+
throw new Error(
|
|
324
|
+
'Compile request failed and project root is unknown. Check connection and retry.',
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
spinner.update('Waiting for domain reload to complete...');
|
|
329
|
+
const { outcome, result: storedResult } = await waitForCompileCompletion<
|
|
330
|
+
Record<string, unknown>
|
|
331
|
+
>({
|
|
332
|
+
projectRoot: effectiveProjectRoot,
|
|
333
|
+
requestId: compileRequestId,
|
|
334
|
+
timeoutMs: COMPILE_WAIT_TIMEOUT_MS,
|
|
335
|
+
pollIntervalMs: COMPILE_WAIT_POLL_INTERVAL_MS,
|
|
336
|
+
unityPort: port,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
if (outcome === 'timed_out') {
|
|
340
|
+
lastError = new Error(
|
|
341
|
+
`Compile wait timed out after ${COMPILE_WAIT_TIMEOUT_MS}ms. Run 'uloop fix' and retry.`,
|
|
342
|
+
);
|
|
343
|
+
} else {
|
|
344
|
+
const finalResult = storedResult ?? immediateResult;
|
|
345
|
+
if (finalResult !== undefined) {
|
|
346
|
+
spinner.stop();
|
|
347
|
+
restoreStdin();
|
|
348
|
+
checkServerVersion(finalResult);
|
|
349
|
+
console.log(JSON.stringify(stripInternalFields(finalResult), null, 2));
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
206
355
|
spinner.stop();
|
|
207
356
|
restoreStdin();
|
|
208
|
-
|
|
357
|
+
if (lastError === undefined) {
|
|
358
|
+
throw new Error('Tool execution failed without error details.');
|
|
359
|
+
}
|
|
360
|
+
if (lastError instanceof Error) {
|
|
361
|
+
throw lastError;
|
|
362
|
+
}
|
|
363
|
+
if (typeof lastError === 'string') {
|
|
364
|
+
throw new Error(lastError);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const serializedError = JSON.stringify(lastError);
|
|
368
|
+
throw new Error(serializedError ?? 'Unknown error');
|
|
209
369
|
}
|
|
210
370
|
|
|
211
371
|
export async function listAvailableTools(globalOptions: GlobalOptions): Promise<void> {
|
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.
|
|
7
|
+
export const VERSION = '0.65.0'; // x-release-please-version
|