Skip to content

Commit 86489da

Browse files
committed
feat: unify Electron and CLI with shared oRPC API server
Electron now runs an HTTP/WS API server on 127.0.0.1 alongside the existing MessagePort transport. Both transports share the same oRPC router instance with auth middleware. - Add ServerLockfile to manage ~/.mux/server.lock for discovery - Extend ServerService with startServer/stopServer lifecycle - Move orpcServer.ts to src/node/orpc/server.ts (shared module) - Electron generates auth token, injects into both transports - CLI server checks lockfile to prevent conflicts - CLI api auto-discovers running server via lockfile Env vars: - MUX_SERVER_AUTH_TOKEN: Override auth token - MUX_SERVER_PORT: Fixed port (default: random) - MUX_NO_API_SERVER=1: Disable API server in Electron
1 parent 751dadd commit 86489da

File tree

12 files changed

+981
-33
lines changed

12 files changed

+981
-33
lines changed

src/cli/api.ts

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,63 @@
33
*
44
* This module is loaded lazily to avoid pulling in ESM-only dependencies
55
* (trpc-cli) when running other commands like the desktop app.
6+
*
7+
* Server discovery priority:
8+
* 1. MUX_SERVER_URL env var (explicit override)
9+
* 2. Lockfile at ~/.mux/server.lock (running Electron or mux server)
10+
* 3. Fallback to http://localhost:3000
611
*/
712

813
import { createCli } from "trpc-cli";
914
import { router } from "@/node/orpc/router";
1015
import { proxifyOrpc } from "./proxifyOrpc";
16+
import { ServerLockfile } from "@/node/services/serverLockfile";
17+
import { getMuxHome } from "@/common/constants/paths";
1118
import type { Command } from "commander";
1219

13-
const baseUrl = process.env.MUX_SERVER_URL ?? "http://localhost:3000";
14-
const authToken = process.env.MUX_SERVER_AUTH_TOKEN;
20+
interface ServerDiscovery {
21+
baseUrl: string;
22+
authToken: string | undefined;
23+
}
24+
25+
async function discoverServer(): Promise<ServerDiscovery> {
26+
// Priority 1: Explicit env vars override everything
27+
if (process.env.MUX_SERVER_URL) {
28+
return {
29+
baseUrl: process.env.MUX_SERVER_URL,
30+
authToken: process.env.MUX_SERVER_AUTH_TOKEN,
31+
};
32+
}
33+
34+
// Priority 2: Try lockfile discovery (running Electron or mux server)
35+
try {
36+
const lockfile = new ServerLockfile(getMuxHome());
37+
const data = await lockfile.read();
38+
if (data) {
39+
return {
40+
baseUrl: data.baseUrl,
41+
authToken: data.token,
42+
};
43+
}
44+
} catch {
45+
// Ignore lockfile errors
46+
}
47+
48+
// Priority 3: Default fallback (standalone server on default port)
49+
return {
50+
baseUrl: "http://localhost:3000",
51+
authToken: process.env.MUX_SERVER_AUTH_TOKEN,
52+
};
53+
}
54+
55+
// Run async discovery then start CLI
56+
(async () => {
57+
const { baseUrl, authToken } = await discoverServer();
1558

16-
const proxiedRouter = proxifyOrpc(router(), { baseUrl, authToken });
17-
const cli = createCli({ router: proxiedRouter }).buildProgram() as Command;
59+
const proxiedRouter = proxifyOrpc(router(), { baseUrl, authToken });
60+
const cli = createCli({ router: proxiedRouter }).buildProgram() as Command;
1861

19-
cli.name("mux api");
20-
cli.description("Interact with the mux API via a running server");
21-
cli.parse();
62+
cli.name("mux api");
63+
cli.description("Interact with the mux API via a running server");
64+
cli.parse();
65+
})();

src/cli/cli.test.ts

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
/**
2+
* E2E tests for the CLI layer (mux api commands).
3+
*
4+
* These tests verify that:
5+
* 1. CLI commands work correctly via HTTP to a real server
6+
* 2. Input schema transformations (proxifyOrpc) are correct
7+
* 3. Authentication flows work as expected
8+
*
9+
* Uses bun:test and the same server setup pattern as server.test.ts.
10+
* Tests the full flow: CLI args → trpc-cli → proxifyOrpc → HTTP → oRPC server
11+
*/
12+
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
13+
import * as os from "os";
14+
import * as path from "path";
15+
import * as fs from "fs/promises";
16+
import type { BrowserWindow, WebContents } from "electron";
17+
18+
import { createCli, FailedToExitError } from "trpc-cli";
19+
import { router } from "@/node/orpc/router";
20+
import { proxifyOrpc } from "./proxifyOrpc";
21+
import type { ORPCContext } from "@/node/orpc/context";
22+
import { Config } from "@/node/config";
23+
import { ServiceContainer } from "@/node/services/serviceContainer";
24+
import { createOrpcServer, type OrpcServer } from "@/node/orpc/server";
25+
26+
// --- Test Server Factory ---
27+
28+
interface TestServerHandle {
29+
server: OrpcServer;
30+
tempDir: string;
31+
close: () => Promise<void>;
32+
}
33+
34+
/**
35+
* Create a test server using the actual createOrpcServer function.
36+
* Sets up services and config in a temp directory.
37+
*/
38+
async function createTestServer(authToken?: string): Promise<TestServerHandle> {
39+
// Create temp dir for config
40+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-cli-test-"));
41+
const config = new Config(tempDir);
42+
43+
// Mock BrowserWindow
44+
const mockWindow: BrowserWindow = {
45+
isDestroyed: () => false,
46+
setTitle: () => undefined,
47+
webContents: {
48+
send: () => undefined,
49+
openDevTools: () => undefined,
50+
} as unknown as WebContents,
51+
} as unknown as BrowserWindow;
52+
53+
// Initialize services
54+
const services = new ServiceContainer(config);
55+
await services.initialize();
56+
services.windowService.setMainWindow(mockWindow);
57+
58+
// Build context
59+
const context: ORPCContext = {
60+
projectService: services.projectService,
61+
workspaceService: services.workspaceService,
62+
providerService: services.providerService,
63+
terminalService: services.terminalService,
64+
windowService: services.windowService,
65+
updateService: services.updateService,
66+
tokenizerService: services.tokenizerService,
67+
serverService: services.serverService,
68+
menuEventService: services.menuEventService,
69+
voiceService: services.voiceService,
70+
};
71+
72+
// Use the actual createOrpcServer function
73+
const server = await createOrpcServer({
74+
context,
75+
authToken,
76+
// port 0 = random available port
77+
onOrpcError: () => undefined, // Silence errors in tests
78+
});
79+
80+
return {
81+
server,
82+
tempDir,
83+
close: async () => {
84+
await server.close();
85+
// Cleanup temp directory
86+
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
87+
},
88+
};
89+
}
90+
91+
// --- CLI Runner Factory ---
92+
93+
/**
94+
* Create a CLI runner that executes commands against a running server.
95+
* Uses trpc-cli's programmatic API to avoid subprocess overhead.
96+
*/
97+
function createCliRunner(baseUrl: string, authToken?: string) {
98+
const proxiedRouter = proxifyOrpc(router(), { baseUrl, authToken });
99+
const cli = createCli({ router: proxiedRouter });
100+
101+
return async (args: string[]): Promise<unknown> => {
102+
return cli
103+
.run({
104+
argv: args,
105+
process: { exit: () => void 0 as never },
106+
// eslint-disable-next-line @typescript-eslint/no-empty-function
107+
logger: { info: () => {}, error: () => {} },
108+
})
109+
.catch((err) => {
110+
// Extract the result or re-throw the actual error
111+
while (err instanceof FailedToExitError) {
112+
if (err.exitCode === 0) {
113+
return err.cause; // This is the return value of the procedure
114+
}
115+
err = err.cause; // Use the underlying error
116+
}
117+
throw err;
118+
});
119+
};
120+
}
121+
122+
// --- Tests ---
123+
124+
describe("CLI via HTTP", () => {
125+
let serverHandle: TestServerHandle;
126+
let runCli: (args: string[]) => Promise<unknown>;
127+
128+
beforeAll(async () => {
129+
serverHandle = await createTestServer();
130+
runCli = createCliRunner(serverHandle.server.baseUrl);
131+
});
132+
133+
afterAll(async () => {
134+
await serverHandle.close();
135+
});
136+
137+
describe("void input schemas (regression for proxifyOrpc fix)", () => {
138+
// These tests verify the fix in proxifyOrpc.ts that transforms {} to undefined
139+
// for z.void() inputs. Without the fix, these would fail with BAD_REQUEST.
140+
141+
test("workspace list works with void input", async () => {
142+
const result = await runCli(["workspace", "list"]);
143+
expect(Array.isArray(result)).toBe(true);
144+
});
145+
146+
test("providers list works with void input", async () => {
147+
const result = (await runCli(["providers", "list"])) as string[];
148+
expect(Array.isArray(result)).toBe(true);
149+
expect(result).toContain("anthropic");
150+
});
151+
152+
test("projects list works with void input", async () => {
153+
const result = await runCli(["projects", "list"]);
154+
expect(Array.isArray(result)).toBe(true);
155+
});
156+
157+
test("providers get-config works with void input", async () => {
158+
const result = await runCli(["providers", "get-config"]);
159+
expect(typeof result).toBe("object");
160+
expect(result).not.toBeNull();
161+
});
162+
163+
test("workspace activity list works with void input", async () => {
164+
const result = await runCli(["workspace", "activity", "list"]);
165+
expect(typeof result).toBe("object");
166+
expect(result).not.toBeNull();
167+
});
168+
});
169+
170+
describe("string input schemas", () => {
171+
test("general ping with string argument", async () => {
172+
const result = await runCli(["general", "ping", "hello"]);
173+
expect(result).toBe("Pong: hello");
174+
});
175+
176+
test("general ping with empty string", async () => {
177+
const result = await runCli(["general", "ping", ""]);
178+
expect(result).toBe("Pong: ");
179+
});
180+
181+
test("general ping with special characters", async () => {
182+
const result = await runCli(["general", "ping", "hello world!"]);
183+
expect(result).toBe("Pong: hello world!");
184+
});
185+
});
186+
187+
describe("object input schemas", () => {
188+
test("workspace get-info with workspace-id option", async () => {
189+
const result = await runCli(["workspace", "get-info", "--workspace-id", "nonexistent"]);
190+
expect(result).toBeNull(); // Non-existent workspace returns null
191+
});
192+
193+
test("general tick with object options", async () => {
194+
const result = await runCli(["general", "tick", "--count", "2", "--interval-ms", "10"]);
195+
// tick returns an async generator, so result should be the generator
196+
expect(result).toBeDefined();
197+
});
198+
});
199+
});
200+
201+
describe("CLI Authentication", () => {
202+
test("valid auth token allows requests", async () => {
203+
const authToken = "test-secret-token";
204+
const serverHandle = await createTestServer(authToken);
205+
const runCli = createCliRunner(serverHandle.server.baseUrl, authToken);
206+
207+
try {
208+
const result = await runCli(["workspace", "list"]);
209+
expect(Array.isArray(result)).toBe(true);
210+
} finally {
211+
await serverHandle.close();
212+
}
213+
});
214+
215+
test("invalid auth token rejects requests", async () => {
216+
const authToken = "correct-token";
217+
const serverHandle = await createTestServer(authToken);
218+
const runCli = createCliRunner(serverHandle.server.baseUrl, "wrong-token");
219+
220+
try {
221+
let threw = false;
222+
try {
223+
await runCli(["workspace", "list"]);
224+
} catch {
225+
threw = true;
226+
}
227+
expect(threw).toBe(true);
228+
} finally {
229+
await serverHandle.close();
230+
}
231+
});
232+
233+
test("missing auth token when required rejects requests", async () => {
234+
const authToken = "required-token";
235+
const serverHandle = await createTestServer(authToken);
236+
const runCli = createCliRunner(serverHandle.server.baseUrl); // No token
237+
238+
try {
239+
let threw = false;
240+
try {
241+
await runCli(["workspace", "list"]);
242+
} catch {
243+
threw = true;
244+
}
245+
expect(threw).toBe(true);
246+
} finally {
247+
await serverHandle.close();
248+
}
249+
});
250+
251+
test("no auth token required when server has none", async () => {
252+
const serverHandle = await createTestServer(); // No auth token on server
253+
const runCli = createCliRunner(serverHandle.server.baseUrl); // No token
254+
255+
try {
256+
const result = await runCli(["workspace", "list"]);
257+
expect(Array.isArray(result)).toBe(true);
258+
} finally {
259+
await serverHandle.close();
260+
}
261+
});
262+
});

src/cli/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,9 @@ if (subcommand === "run") {
4040
require("./server");
4141
} else if (subcommand === "api") {
4242
process.argv.splice(2, 1);
43-
// eslint-disable-next-line @typescript-eslint/no-require-imports
44-
require("./api");
43+
// Dynamic import required: trpc-cli is ESM-only and can't be require()'d
44+
// eslint-disable-next-line no-restricted-syntax
45+
void import("./api");
4546
} else if (
4647
subcommand === "desktop" ||
4748
(isElectron && (subcommand === undefined || isElectronLaunchArg))

0 commit comments

Comments
 (0)