diff --git a/src/cli/commands/listen.ts b/src/cli/commands/listen.ts index 6582981..7c9bc74 100644 --- a/src/cli/commands/listen.ts +++ b/src/cli/commands/listen.ts @@ -7,6 +7,7 @@ import { hostname } from "node:os"; import { getServerUrl } from "../../agent/client"; import { settingsManager } from "../../settings-manager"; import { getErrorMessage } from "../../utils/error"; +import { registerWithCloud } from "../../websocket/listen-register"; import type { Buffers, Line } from "../helpers/accumulator"; // tiny helper for unique ids @@ -221,44 +222,14 @@ export async function handleListen( throw new Error("Missing LETTA_API_KEY"); } - // Call register endpoint - const registerUrl = `${serverUrl}/v1/environments/register`; - const registerResponse = await fetch(registerUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - "X-Letta-Source": "letta-code", - }, - body: JSON.stringify({ - deviceId, - connectionName, - }), + // Register with cloud + const { connectionId, wsUrl } = await registerWithCloud({ + serverUrl, + apiKey, + deviceId, + connectionName, }); - if (!registerResponse.ok) { - let errorMessage = `Registration failed (HTTP ${registerResponse.status})`; - try { - const error = (await registerResponse.json()) as { message?: string }; - if (error.message) errorMessage = error.message; - } catch { - // Response body is not JSON (e.g. HTML error page from proxy) - const text = await registerResponse.text().catch(() => ""); - if (text) errorMessage += `: ${text.slice(0, 200)}`; - } - throw new Error(errorMessage); - } - - let registerBody: { connectionId: string; wsUrl: string }; - try { - registerBody = (await registerResponse.json()) as typeof registerBody; - } catch { - throw new Error( - "Registration endpoint returned non-JSON response — is the server running?", - ); - } - const { connectionId, wsUrl } = registerBody; - updateCommandResult( ctx.buffersRef, ctx.refreshDerived, @@ -352,48 +323,17 @@ export async function handleListen( ); try { - // Re-register to get new connectionId - const reregisterResponse = await fetch(registerUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - "X-Letta-Source": "letta-code", - }, - body: JSON.stringify({ - deviceId, - connectionName, - }), + const reregisterResult = await registerWithCloud({ + serverUrl, + apiKey, + deviceId, + connectionName, }); - if (!reregisterResponse.ok) { - let errorMessage = `Re-registration failed (HTTP ${reregisterResponse.status})`; - try { - const error = (await reregisterResponse.json()) as { - message?: string; - }; - if (error.message) errorMessage = error.message; - } catch { - const text = await reregisterResponse.text().catch(() => ""); - if (text) errorMessage += `: ${text.slice(0, 200)}`; - } - throw new Error(errorMessage); - } - - let reregisterData: { connectionId: string; wsUrl: string }; - try { - reregisterData = - (await reregisterResponse.json()) as typeof reregisterData; - } catch { - throw new Error( - "Re-registration endpoint returned non-JSON response — is the server running?", - ); - } - // Restart client with new connectionId await startClient( - reregisterData.connectionId, - reregisterData.wsUrl, + reregisterResult.connectionId, + reregisterResult.wsUrl, ); } catch (error) { updateCommandResult( diff --git a/src/cli/subcommands/listen.tsx b/src/cli/subcommands/listen.tsx index 9db275d..cd787c2 100644 --- a/src/cli/subcommands/listen.tsx +++ b/src/cli/subcommands/listen.tsx @@ -11,6 +11,7 @@ import type React from "react"; import { useState } from "react"; import { getServerUrl } from "../../agent/client"; import { settingsManager } from "../../settings-manager"; +import { registerWithCloud } from "../../websocket/listen-register"; import { ListenerStatusUI } from "../components/ListenerStatusUI"; /** @@ -154,50 +155,22 @@ export async function runListenSubcommand(argv: string[]): Promise { // Register with cloud const serverUrl = getServerUrl(); - const registerUrl = `${serverUrl}/v1/environments/register`; if (debugMode) { - console.log(`[${formatTimestamp()}] Registering with ${registerUrl}`); + console.log( + `[${formatTimestamp()}] Registering with ${serverUrl}/v1/environments/register`, + ); console.log(`[${formatTimestamp()}] deviceId: ${deviceId}`); console.log(`[${formatTimestamp()}] connectionName: ${connectionName}`); } - const registerResponse = await fetch(registerUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - "X-Letta-Source": "letta-code", - }, - body: JSON.stringify({ - deviceId, - connectionName, - }), + const { connectionId, wsUrl } = await registerWithCloud({ + serverUrl, + apiKey, + deviceId, + connectionName, }); - if (!registerResponse.ok) { - let errorMessage = `Registration failed (HTTP ${registerResponse.status})`; - try { - const error = (await registerResponse.json()) as { message?: string }; - if (error.message) errorMessage = error.message; - } catch { - const text = await registerResponse.text().catch(() => ""); - if (text) errorMessage += `: ${text.slice(0, 200)}`; - } - console.error(errorMessage); - return 1; - } - - let registerBody: { connectionId: string; wsUrl: string }; - try { - registerBody = (await registerResponse.json()) as typeof registerBody; - } catch { - throw new Error( - "Registration endpoint returned non-JSON response — is the server running?", - ); - } - const { connectionId, wsUrl } = registerBody; - if (debugMode) { console.log(`[${formatTimestamp()}] Registered successfully`); console.log(`[${formatTimestamp()}] connectionId: ${connectionId}`); diff --git a/src/tests/websocket/listen-register.test.ts b/src/tests/websocket/listen-register.test.ts new file mode 100644 index 0000000..22982e9 --- /dev/null +++ b/src/tests/websocket/listen-register.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { registerWithCloud } from "../../websocket/listen-register"; + +const defaultOpts = { + serverUrl: "https://api.example.com", + apiKey: "sk-test-key", + deviceId: "device-123", + connectionName: "test-machine", +}; + +const mockFetch = mock(() => { + throw new Error("fetch not mocked for this test"); +}); + +beforeEach(() => { + mockFetch.mockReset(); + globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch; +}); + +describe("registerWithCloud", () => { + it("returns connectionId and wsUrl on successful JSON response", async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ connectionId: "conn-1", wsUrl: "wss://example.com" }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + const result = await registerWithCloud(defaultOpts); + + expect(result).toEqual({ + connectionId: "conn-1", + wsUrl: "wss://example.com", + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + + const [url, init] = mockFetch.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + expect(url).toBe("https://api.example.com/v1/environments/register"); + expect(init.method).toBe("POST"); + expect((init.headers as Record).Authorization).toBe( + "Bearer sk-test-key", + ); + expect((init.headers as Record)["X-Letta-Source"]).toBe( + "letta-code", + ); + const body = JSON.parse(init.body as string); + expect(body).toEqual({ + deviceId: "device-123", + connectionName: "test-machine", + }); + }); + + it("throws with body message on non-OK response with JSON error", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ message: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + ); + + await expect(registerWithCloud(defaultOpts)).rejects.toThrow( + "Unauthorized", + ); + }); + + it("throws with HTTP status and truncated body on non-OK non-JSON response", async () => { + mockFetch.mockResolvedValueOnce( + new Response("Bad Gateway", { status: 502 }), + ); + + await expect(registerWithCloud(defaultOpts)).rejects.toThrow( + "HTTP 502: Bad Gateway", + ); + }); + + it("throws actionable message on 200 with non-JSON body", async () => { + mockFetch.mockResolvedValueOnce(new Response("OK", { status: 200 })); + + await expect(registerWithCloud(defaultOpts)).rejects.toThrow( + "is the server running?", + ); + }); + + it("throws on unexpected response shape (missing fields)", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ connectionId: "conn-1" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + await expect(registerWithCloud(defaultOpts)).rejects.toThrow( + "missing connectionId or wsUrl", + ); + }); +}); diff --git a/src/websocket/listen-register.ts b/src/websocket/listen-register.ts new file mode 100644 index 0000000..5b941ac --- /dev/null +++ b/src/websocket/listen-register.ts @@ -0,0 +1,81 @@ +/** + * Shared registration helper for letta remote / /remote command. + * Owns the HTTP request contract and error handling; callers own UX strings and logging. + */ + +export interface RegisterResult { + connectionId: string; + wsUrl: string; +} + +export interface RegisterOptions { + serverUrl: string; + apiKey: string; + deviceId: string; + connectionName: string; +} + +/** + * Register this device with the Letta Cloud environments endpoint. + * Throws on any failure with an error message suitable for wrapping in caller-specific context. + */ +export async function registerWithCloud( + opts: RegisterOptions, +): Promise { + const registerUrl = `${opts.serverUrl}/v1/environments/register`; + + const response = await fetch(registerUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${opts.apiKey}`, + "X-Letta-Source": "letta-code", + }, + body: JSON.stringify({ + deviceId: opts.deviceId, + connectionName: opts.connectionName, + }), + }); + + if (!response.ok) { + let detail = `HTTP ${response.status}`; + const text = await response.text().catch(() => ""); + if (text) { + try { + const parsed = JSON.parse(text) as { message?: string }; + if (parsed.message) { + detail = parsed.message; + } else { + detail += `: ${text.slice(0, 200)}`; + } + } catch { + detail += `: ${text.slice(0, 200)}`; + } + } + throw new Error(detail); + } + + let body: unknown; + try { + body = await response.json(); + } catch { + throw new Error( + "Server returned non-JSON response — is the server running?", + ); + } + + const result = body as Record; + if ( + typeof result.connectionId !== "string" || + typeof result.wsUrl !== "string" + ) { + throw new Error( + "Server returned unexpected response shape (missing connectionId or wsUrl)", + ); + } + + return { + connectionId: result.connectionId, + wsUrl: result.wsUrl, + }; +}