Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 51 additions & 7 deletions src/cli/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,63 @@
*
* This module is loaded lazily to avoid pulling in ESM-only dependencies
* (trpc-cli) when running other commands like the desktop app.
*
* Server discovery priority:
* 1. MUX_SERVER_URL env var (explicit override)
* 2. Lockfile at ~/.mux/server.lock (running Electron or mux server)
* 3. Fallback to http://localhost:3000
*/

import { createCli } from "trpc-cli";
import { router } from "@/node/orpc/router";
import { proxifyOrpc } from "./proxifyOrpc";
import { ServerLockfile } from "@/node/services/serverLockfile";
import { getMuxHome } from "@/common/constants/paths";
import type { Command } from "commander";

const baseUrl = process.env.MUX_SERVER_URL ?? "http://localhost:3000";
const authToken = process.env.MUX_SERVER_AUTH_TOKEN;
interface ServerDiscovery {
baseUrl: string;
authToken: string | undefined;
}

async function discoverServer(): Promise<ServerDiscovery> {
// Priority 1: Explicit env vars override everything
if (process.env.MUX_SERVER_URL) {
return {
baseUrl: process.env.MUX_SERVER_URL,
authToken: process.env.MUX_SERVER_AUTH_TOKEN,
};
}

// Priority 2: Try lockfile discovery (running Electron or mux server)
try {
const lockfile = new ServerLockfile(getMuxHome());
const data = await lockfile.read();
if (data) {
return {
baseUrl: data.baseUrl,
authToken: data.token,
};
}
} catch {
// Ignore lockfile errors
}

// Priority 3: Default fallback (standalone server on default port)
return {
baseUrl: "http://localhost:3000",
authToken: process.env.MUX_SERVER_AUTH_TOKEN,
};
}

// Run async discovery then start CLI
(async () => {
const { baseUrl, authToken } = await discoverServer();

const proxiedRouter = proxifyOrpc(router(), { baseUrl, authToken });
const cli = createCli({ router: proxiedRouter }).buildProgram() as Command;
const proxiedRouter = proxifyOrpc(router(), { baseUrl, authToken });
const cli = createCli({ router: proxiedRouter }).buildProgram() as Command;

cli.name("mux api");
cli.description("Interact with the mux API via a running server");
cli.parse();
cli.name("mux api");
cli.description("Interact with the mux API via a running server");
cli.parse();
})();
262 changes: 262 additions & 0 deletions src/cli/cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
/**
* E2E tests for the CLI layer (mux api commands).
*
* These tests verify that:
* 1. CLI commands work correctly via HTTP to a real server
* 2. Input schema transformations (proxifyOrpc) are correct
* 3. Authentication flows work as expected
*
* Uses bun:test and the same server setup pattern as server.test.ts.
* Tests the full flow: CLI args → trpc-cli → proxifyOrpc → HTTP → oRPC server
*/
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import * as os from "os";
import * as path from "path";
import * as fs from "fs/promises";
import type { BrowserWindow, WebContents } from "electron";

import { createCli, FailedToExitError } from "trpc-cli";
import { router } from "@/node/orpc/router";
import { proxifyOrpc } from "./proxifyOrpc";
import type { ORPCContext } from "@/node/orpc/context";
import { Config } from "@/node/config";
import { ServiceContainer } from "@/node/services/serviceContainer";
import { createOrpcServer, type OrpcServer } from "@/node/orpc/server";

// --- Test Server Factory ---

interface TestServerHandle {
server: OrpcServer;
tempDir: string;
close: () => Promise<void>;
}

/**
* Create a test server using the actual createOrpcServer function.
* Sets up services and config in a temp directory.
*/
async function createTestServer(authToken?: string): Promise<TestServerHandle> {
// Create temp dir for config
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-cli-test-"));
const config = new Config(tempDir);

// Mock BrowserWindow
const mockWindow: BrowserWindow = {
isDestroyed: () => false,
setTitle: () => undefined,
webContents: {
send: () => undefined,
openDevTools: () => undefined,
} as unknown as WebContents,
} as unknown as BrowserWindow;

// Initialize services
const services = new ServiceContainer(config);
await services.initialize();
services.windowService.setMainWindow(mockWindow);

// Build context
const context: ORPCContext = {
projectService: services.projectService,
workspaceService: services.workspaceService,
providerService: services.providerService,
terminalService: services.terminalService,
windowService: services.windowService,
updateService: services.updateService,
tokenizerService: services.tokenizerService,
serverService: services.serverService,
menuEventService: services.menuEventService,
voiceService: services.voiceService,
};

// Use the actual createOrpcServer function
const server = await createOrpcServer({
context,
authToken,
// port 0 = random available port
onOrpcError: () => undefined, // Silence errors in tests
});

return {
server,
tempDir,
close: async () => {
await server.close();
// Cleanup temp directory
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
},
};
}

// --- CLI Runner Factory ---

/**
* Create a CLI runner that executes commands against a running server.
* Uses trpc-cli's programmatic API to avoid subprocess overhead.
*/
function createCliRunner(baseUrl: string, authToken?: string) {
const proxiedRouter = proxifyOrpc(router(), { baseUrl, authToken });
const cli = createCli({ router: proxiedRouter });

return async (args: string[]): Promise<unknown> => {
return cli
.run({
argv: args,
process: { exit: () => void 0 as never },
// eslint-disable-next-line @typescript-eslint/no-empty-function
logger: { info: () => {}, error: () => {} },
})
.catch((err) => {
// Extract the result or re-throw the actual error
while (err instanceof FailedToExitError) {
if (err.exitCode === 0) {
return err.cause; // This is the return value of the procedure
}
err = err.cause; // Use the underlying error
}
throw err;
});
};
}

// --- Tests ---

describe("CLI via HTTP", () => {
let serverHandle: TestServerHandle;
let runCli: (args: string[]) => Promise<unknown>;

beforeAll(async () => {
serverHandle = await createTestServer();
runCli = createCliRunner(serverHandle.server.baseUrl);
});

afterAll(async () => {
await serverHandle.close();
});

describe("void input schemas (regression for proxifyOrpc fix)", () => {
// These tests verify the fix in proxifyOrpc.ts that transforms {} to undefined
// for z.void() inputs. Without the fix, these would fail with BAD_REQUEST.

test("workspace list works with void input", async () => {
const result = await runCli(["workspace", "list"]);
expect(Array.isArray(result)).toBe(true);
});

test("providers list works with void input", async () => {
const result = (await runCli(["providers", "list"])) as string[];
expect(Array.isArray(result)).toBe(true);
expect(result).toContain("anthropic");
});

test("projects list works with void input", async () => {
const result = await runCli(["projects", "list"]);
expect(Array.isArray(result)).toBe(true);
});

test("providers get-config works with void input", async () => {
const result = await runCli(["providers", "get-config"]);
expect(typeof result).toBe("object");
expect(result).not.toBeNull();
});

test("workspace activity list works with void input", async () => {
const result = await runCli(["workspace", "activity", "list"]);
expect(typeof result).toBe("object");
expect(result).not.toBeNull();
});
});

describe("string input schemas", () => {
test("general ping with string argument", async () => {
const result = await runCli(["general", "ping", "hello"]);
expect(result).toBe("Pong: hello");
});

test("general ping with empty string", async () => {
const result = await runCli(["general", "ping", ""]);
expect(result).toBe("Pong: ");
});

test("general ping with special characters", async () => {
const result = await runCli(["general", "ping", "hello world!"]);
expect(result).toBe("Pong: hello world!");
});
});

describe("object input schemas", () => {
test("workspace get-info with workspace-id option", async () => {
const result = await runCli(["workspace", "get-info", "--workspace-id", "nonexistent"]);
expect(result).toBeNull(); // Non-existent workspace returns null
});

test("general tick with object options", async () => {
const result = await runCli(["general", "tick", "--count", "2", "--interval-ms", "10"]);
// tick returns an async generator, so result should be the generator
expect(result).toBeDefined();
});
});
});

describe("CLI Authentication", () => {
test("valid auth token allows requests", async () => {
const authToken = "test-secret-token";
const serverHandle = await createTestServer(authToken);
const runCli = createCliRunner(serverHandle.server.baseUrl, authToken);

try {
const result = await runCli(["workspace", "list"]);
expect(Array.isArray(result)).toBe(true);
} finally {
await serverHandle.close();
}
});

test("invalid auth token rejects requests", async () => {
const authToken = "correct-token";
const serverHandle = await createTestServer(authToken);
const runCli = createCliRunner(serverHandle.server.baseUrl, "wrong-token");

try {
let threw = false;
try {
await runCli(["workspace", "list"]);
} catch {
threw = true;
}
expect(threw).toBe(true);
} finally {
await serverHandle.close();
}
});

test("missing auth token when required rejects requests", async () => {
const authToken = "required-token";
const serverHandle = await createTestServer(authToken);
const runCli = createCliRunner(serverHandle.server.baseUrl); // No token

try {
let threw = false;
try {
await runCli(["workspace", "list"]);
} catch {
threw = true;
}
expect(threw).toBe(true);
} finally {
await serverHandle.close();
}
});

test("no auth token required when server has none", async () => {
const serverHandle = await createTestServer(); // No auth token on server
const runCli = createCliRunner(serverHandle.server.baseUrl); // No token

try {
const result = await runCli(["workspace", "list"]);
expect(Array.isArray(result)).toBe(true);
} finally {
await serverHandle.close();
}
});
});
5 changes: 3 additions & 2 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ if (subcommand === "run") {
require("./server");
} else if (subcommand === "api") {
process.argv.splice(2, 1);
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("./api");
// Dynamic import required: trpc-cli is ESM-only and can't be require()'d
// eslint-disable-next-line no-restricted-syntax
void import("./api");
} else if (
subcommand === "desktop" ||
(isElectron && (subcommand === undefined || isElectronLaunchArg))
Expand Down
Loading