Skip to content
Open
4 changes: 4 additions & 0 deletions src/browser/utils/commandIds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ export const CommandIds = {

// Help commands
helpKeybinds: () => "help:keybinds" as const,

// Debug commands
debugLaunchTestInstance: () => "debug:launch-test-instance" as const,
debugDeleteTestInstances: () => "debug:delete-test-instances" as const,
} as const;

/**
Expand Down
58 changes: 58 additions & 0 deletions src/browser/utils/commands/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export const COMMAND_SECTIONS = {
HELP: "Help",
PROJECTS: "Projects",
APPEARANCE: "Appearance",
DEBUG: "Debug",
SETTINGS: "Settings",
} as const;

Expand All @@ -92,6 +93,7 @@ const section = {
mode: COMMAND_SECTIONS.MODE,
help: COMMAND_SECTIONS.HELP,
projects: COMMAND_SECTIONS.PROJECTS,
debug: COMMAND_SECTIONS.DEBUG,
settings: COMMAND_SECTIONS.SETTINGS,
};

Expand Down Expand Up @@ -676,6 +678,62 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi
},
]);

// Debug
actions.push(() => {
const api = p.api;
if (!api || typeof window === "undefined" || !window.api) return [];

return [
{
id: CommandIds.debugLaunchTestInstance(),
title: "Debug: Launch Test Instance",
subtitle: "Launch a second mux instance under <muxHome>/instances/*",
section: section.debug,
run: async () => {
try {
const result = await api.debug.launchTestInstance();
if (result.success) {
alert(`Launched test instance at:\n${result.data.rootDir}`);
} else {
alert(`Failed to launch test instance:\n${result.error}`);
}
} catch (err) {
alert(
`Failed to launch test instance:\n${err instanceof Error ? err.message : String(err)}`
);
}
},
},
{
id: CommandIds.debugDeleteTestInstances(),
title: "Debug: Delete Test Instances…",
subtitle: "Delete <muxHome>/instances/*",
section: section.debug,
run: async () => {
const ok = confirm(
"Delete ALL test instances under <muxHome>/instances?\n\nAny running test instances may break."
);
if (!ok) return;

try {
const result = await api.debug.deleteTestInstances();
if (result.success) {
alert(
`Deleted ${result.data.deletedCount} test instance(s).\n\n${result.data.instancesDir}`
);
} else {
alert(`Failed to delete test instances:\n${result.error}`);
}
} catch (err) {
alert(
`Failed to delete test instances:\n${err instanceof Error ? err.message : String(err)}`
);
}
},
},
];
});

// Projects
actions.push(() => {
const list: CommandAction[] = [
Expand Down
57 changes: 57 additions & 0 deletions src/cli/argv.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";
import {
CLI_GLOBAL_FLAGS,
consumeMuxRootFromArgv,
detectCliEnvironment,
getParseOptions,
getSubcommand,
Expand Down Expand Up @@ -90,6 +91,62 @@ describe("getSubcommand", () => {
});
});

describe("consumeMuxRootFromArgv", () => {
test("bun/node: strips --mux-root before subcommand", () => {
const env = detectCliEnvironment({}, undefined);
const argv = ["bun", "script.ts", "--mux-root", "/tmp/mux-test", "server", "--help"];
const result = consumeMuxRootFromArgv(argv, env);

expect(result).toEqual({ muxRoot: "/tmp/mux-test" });
expect(argv).toEqual(["bun", "script.ts", "server", "--help"]);
});

test("bun/node: strips --root after subcommand", () => {
const env = detectCliEnvironment({}, undefined);
const argv = ["bun", "script.ts", "server", "--root", "/tmp/mux-test"];
const result = consumeMuxRootFromArgv(argv, env);

expect(result).toEqual({ muxRoot: "/tmp/mux-test" });
expect(argv).toEqual(["bun", "script.ts", "server"]);
});

test("packaged electron: strips --mux-root", () => {
const env = detectCliEnvironment({ electron: "33.0.0" }, undefined);
const argv = ["mux", "--mux-root", "/tmp/mux-test", "server", "--help"];
const result = consumeMuxRootFromArgv(argv, env);

expect(result).toEqual({ muxRoot: "/tmp/mux-test" });
expect(argv).toEqual(["mux", "server", "--help"]);
});

test("supports equals syntax", () => {
const env = detectCliEnvironment({ electron: "33.0.0" }, true);
const argv = ["electron", ".", "server", "--mux-root=/tmp/mux-test"];
const result = consumeMuxRootFromArgv(argv, env);

expect(result).toEqual({ muxRoot: "/tmp/mux-test" });
expect(argv).toEqual(["electron", ".", "server"]);
});

test("returns error when flag missing value", () => {
const env = detectCliEnvironment({}, undefined);
const argv = ["bun", "script.ts", "--mux-root"];
const result = consumeMuxRootFromArgv(argv, env);

expect(result.error).toBe("Missing value for --mux-root");
expect(argv).toEqual(["bun", "script.ts", "--mux-root"]);
});

test("returns error when flag value is another flag", () => {
const env = detectCliEnvironment({}, undefined);
const argv = ["bun", "script.ts", "--mux-root", "--help", "server"];
const result = consumeMuxRootFromArgv(argv, env);

expect(result.error).toBe("Missing value for --mux-root");
expect(argv).toEqual(["bun", "script.ts", "--mux-root", "--help", "server"]);
});
});

describe("getArgsAfterSplice", () => {
// These tests simulate what happens AFTER index.ts splices out the subcommand name
// Original argv: ["electron", ".", "api", "--help"]
Expand Down
52 changes: 52 additions & 0 deletions src/cli/argv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,58 @@ export function getSubcommand(
return argv[env.firstArgIndex];
}

/**
* Extract and consume a global --mux-root/--root flag.
*
* This must run before subcommand routing so that commands like:
* mux --mux-root /tmp/mux-test server
* correctly route to "server" (instead of treating --mux-root as a subcommand).
*
* Mutates argv in-place by splicing out the flag and its value.
*/
export function consumeMuxRootFromArgv(
argv: string[] = process.argv,
env: CliEnvironment = detectCliEnvironment()
): { muxRoot?: string; error?: string } {
let muxRoot: string | undefined;

// Matches:
// - --mux-root <path>
// - --root <path>
// - --mux-root=<path>
// - --root=<path>
const rootFlagRe = /^--(mux-root|root)(?:=(.*))?$/;

for (let i = env.firstArgIndex; i < argv.length; i++) {
const arg = argv[i];
const match = rootFlagRe.exec(arg);
if (!match) continue;

const equalsValue = match[2];
if (equalsValue !== undefined) {
if (!equalsValue) {
return { error: `Missing value for ${arg}` };
}

muxRoot = equalsValue;
argv.splice(i, 1);
i -= 1;
continue;
}

const value = argv[i + 1];
if (value === undefined || value.startsWith("-")) {
return { error: `Missing value for ${arg}` };
}

muxRoot = value;
argv.splice(i, 2);
i -= 1;
}

return { muxRoot };
}

/**
* Get args for a subcommand after the subcommand name has been spliced out.
* This is what subcommand handlers (server.ts, api.ts, run.ts) use after
Expand Down
1 change: 1 addition & 0 deletions src/cli/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ async function createTestServer(authToken?: string): Promise<TestServerHandle> {
telemetryService: services.telemetryService,
sessionUsageService: services.sessionUsageService,
signingService: services.signingService,
testInstanceService: services.testInstanceService,
coderService: services.coderService,
};

Expand Down
11 changes: 11 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { Command } from "commander";
import { VERSION } from "../version";
import {
CLI_GLOBAL_FLAGS,
consumeMuxRootFromArgv,
detectCliEnvironment,
getParseOptions,
getSubcommand,
Expand All @@ -32,6 +33,16 @@ import {
} from "./argv";

const env = detectCliEnvironment();

const muxRootResult = consumeMuxRootFromArgv(process.argv, env);
if (muxRootResult.error) {
console.error(muxRootResult.error);
process.exit(1);
}
if (muxRootResult.muxRoot) {
process.env.MUX_ROOT = muxRootResult.muxRoot;
}

const subcommand = getSubcommand(process.argv, env);

function launchDesktop(): void {
Expand Down
1 change: 1 addition & 0 deletions src/cli/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ async function createTestServer(): Promise<TestServerHandle> {
voiceService: services.voiceService,
telemetryService: services.telemetryService,
sessionUsageService: services.sessionUsageService,
testInstanceService: services.testInstanceService,
signingService: services.signingService,
coderService: services.coderService,
};
Expand Down
1 change: 1 addition & 0 deletions src/cli/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ const mockWindow: BrowserWindow = {
voiceService: serviceContainer.voiceService,
telemetryService: serviceContainer.telemetryService,
experimentsService: serviceContainer.experimentsService,
testInstanceService: serviceContainer.testInstanceService,
sessionUsageService: serviceContainer.sessionUsageService,
signingService: serviceContainer.signingService,
coderService: serviceContainer.coderService,
Expand Down
12 changes: 11 additions & 1 deletion src/common/constants/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,19 @@ const MUX_DIR_NAME = ".mux";
* This ensures old scripts/tools referencing ~/.cmux continue working.
*/
export function migrateLegacyMuxHome(): void {
const oldPath = join(homedir(), LEGACY_MUX_DIR_NAME);
const newPath = join(homedir(), MUX_DIR_NAME);

// When running with a custom mux root (e.g. a test instance), avoid migrating or touching
// the user's real ~/.mux. Allow migration only when MUX_ROOT is unset or explicitly points
// at the default ~/.mux path.
// eslint-disable-next-line no-restricted-syntax, no-restricted-globals
const muxRoot = process.env.MUX_ROOT;
if (muxRoot && muxRoot !== newPath) {
return;
}

const oldPath = join(homedir(), LEGACY_MUX_DIR_NAME);

// If .mux exists, we're done (already migrated or fresh install)
if (existsSync(newPath)) {
return;
Expand Down
19 changes: 19 additions & 0 deletions src/common/orpc/schemas/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1080,4 +1080,23 @@ export const debug = {
}),
output: z.boolean(), // true if error was triggered on an active stream
},

/**
* Launch a new mux desktop instance under an isolated root (for QA).
*/
launchTestInstance: {
input: z.void(),
output: ResultSchema(z.object({ rootDir: z.string() }), z.string()),
},

/**
* Delete all test instance roots under <muxHome>/instances.
*/
deleteTestInstances: {
input: z.void(),
output: ResultSchema(
z.object({ instancesDir: z.string(), deletedCount: z.number() }),
z.string()
),
},
};
30 changes: 21 additions & 9 deletions src/desktop/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ import * as path from "path";
import type { Config } from "@/node/config";
import type { ServiceContainer } from "@/node/services/serviceContainer";
import { VERSION } from "@/version";
import { getMuxHome, migrateLegacyMuxHome } from "@/common/constants/paths";
import { migrateLegacyMuxHome } from "@/common/constants/paths";

import { resolveMuxUserDataDir } from "@/desktop/userDataDir";

import assert from "@/common/utils/assert";
import { loadTokenizerModules } from "@/node/utils/main/tokenizer";
Expand Down Expand Up @@ -64,16 +66,25 @@ let services: ServiceContainer | null = null;
const isE2ETest = process.env.MUX_E2E === "1";
const forceDistLoad = process.env.MUX_E2E_LOAD_DIST === "1";

if (isE2ETest) {
// For e2e tests, use a test-specific userData directory
// Note: getMuxHome() already respects MUX_ROOT for test isolation
const e2eUserData = path.join(getMuxHome(), "user-data");
const userDataDir = resolveMuxUserDataDir({
muxUserDataDir: process.env.MUX_USER_DATA_DIR,
muxRoot: process.env.MUX_ROOT,
isE2E: isE2ETest,
});

if (userDataDir) {
// Use a root-scoped userData directory so we can run multiple mux instances
// side-by-side without Electron singleton/localStorage collisions.
try {
fs.mkdirSync(e2eUserData, { recursive: true });
app.setPath("userData", e2eUserData);
console.log("Using test userData directory:", e2eUserData);
fs.mkdirSync(userDataDir, { recursive: true });
app.setPath("userData", userDataDir);
if (isE2ETest) {
console.log("Using test userData directory:", userDataDir);
} else {
console.log("Using userData directory:", userDataDir);
}
} catch (error) {
console.warn("Failed to prepare test userData directory:", error);
console.warn("Failed to prepare userData directory:", error);
}
}

Expand Down Expand Up @@ -366,6 +377,7 @@ async function loadServices(): Promise<void> {
voiceService: services.voiceService,
telemetryService: services.telemetryService,
experimentsService: services.experimentsService,
testInstanceService: services.testInstanceService,
sessionUsageService: services.sessionUsageService,
signingService: services.signingService,
coderService: services.coderService,
Expand Down
31 changes: 31 additions & 0 deletions src/desktop/userDataDir.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, test } from "bun:test";
import * as path from "path";
import { resolveMuxUserDataDir } from "./userDataDir";

describe("resolveMuxUserDataDir", () => {
test("prefers explicit MUX_USER_DATA_DIR", () => {
const result = resolveMuxUserDataDir({
muxUserDataDir: "/tmp/custom-user-data",
muxRoot: "/tmp/mux-root",
isE2E: true,
muxHome: "/tmp/mux-home",
});

expect(result).toBe("/tmp/custom-user-data");
});

test("defaults to <muxRoot>/user-data when muxRoot is set", () => {
const result = resolveMuxUserDataDir({ muxRoot: "/tmp/mux-root" });
expect(result).toBe(path.join("/tmp/mux-root", "user-data"));
});

test("defaults to <muxHome>/user-data when running E2E", () => {
const result = resolveMuxUserDataDir({ isE2E: true, muxHome: "/tmp/mux-e2e" });
expect(result).toBe(path.join("/tmp/mux-e2e", "user-data"));
});

test("returns undefined when no overrides", () => {
const result = resolveMuxUserDataDir({});
expect(result).toBeUndefined();
});
});
Loading
Loading