From c821445084f4098eceb23f6dcac2c864a2dee6fe Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 13 Jan 2026 21:45:52 +0000 Subject: [PATCH 1/9] =?UTF-8?q?=F0=9F=A4=96=20feat:=20support=20--mux-root?= =?UTF-8?q?=20for=20isolated=20mux=20instances?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cli/argv.test.ts | 48 +++++++++++++++++++++++++++++++++ src/cli/argv.ts | 45 +++++++++++++++++++++++++++++++ src/cli/index.ts | 11 ++++++++ src/common/constants/paths.ts | 12 ++++++++- src/desktop/main.ts | 29 +++++++++++++------- src/desktop/userDataDir.test.ts | 31 +++++++++++++++++++++ src/desktop/userDataDir.ts | 20 ++++++++++++++ 7 files changed, 186 insertions(+), 10 deletions(-) create mode 100644 src/desktop/userDataDir.test.ts create mode 100644 src/desktop/userDataDir.ts diff --git a/src/cli/argv.test.ts b/src/cli/argv.test.ts index 821de62e5c..9ac8fadfdb 100644 --- a/src/cli/argv.test.ts +++ b/src/cli/argv.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { CLI_GLOBAL_FLAGS, + consumeMuxRootFromArgv, detectCliEnvironment, getParseOptions, getSubcommand, @@ -90,6 +91,53 @@ 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"]); + }); +}); + describe("getArgsAfterSplice", () => { // These tests simulate what happens AFTER index.ts splices out the subcommand name // Original argv: ["electron", ".", "api", "--help"] diff --git a/src/cli/argv.ts b/src/cli/argv.ts index de7b6f78ca..3f533251cf 100644 --- a/src/cli/argv.ts +++ b/src/cli/argv.ts @@ -53,6 +53,51 @@ 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; + + for (let i = env.firstArgIndex; i < argv.length; i++) { + const arg = argv[i]; + + if (arg === "--mux-root" || arg === "--root") { + const value = argv[i + 1]; + if (value === undefined) { + return { error: `Missing value for ${arg}` }; + } + + muxRoot = value; + argv.splice(i, 2); + i -= 1; + continue; + } + + if (arg.startsWith("--mux-root=") || arg.startsWith("--root=")) { + const [, value] = arg.split("=", 2); + if (!value) { + return { error: `Missing value for ${arg}` }; + } + + muxRoot = value; + argv.splice(i, 1); + 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 diff --git a/src/cli/index.ts b/src/cli/index.ts index 1193a8acfc..1a3b8f4cf6 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -24,6 +24,7 @@ import { Command } from "commander"; import { VERSION } from "../version"; import { CLI_GLOBAL_FLAGS, + consumeMuxRootFromArgv, detectCliEnvironment, getParseOptions, getSubcommand, @@ -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 { diff --git a/src/common/constants/paths.ts b/src/common/constants/paths.ts index 389d8c6a60..5b8a0536fc 100644 --- a/src/common/constants/paths.ts +++ b/src/common/constants/paths.ts @@ -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; diff --git a/src/desktop/main.ts b/src/desktop/main.ts index 2420f7569f..07fbd63fce 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -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"; @@ -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); } } diff --git a/src/desktop/userDataDir.test.ts b/src/desktop/userDataDir.test.ts new file mode 100644 index 0000000000..17f4cbfbc6 --- /dev/null +++ b/src/desktop/userDataDir.test.ts @@ -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 /user-data when muxRoot is set", () => { + const result = resolveMuxUserDataDir({ muxRoot: "/tmp/mux-root", muxHome: "/tmp/mux-root" }); + expect(result).toBe(path.join("/tmp/mux-root", "user-data")); + }); + + test("defaults to /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(); + }); +}); diff --git a/src/desktop/userDataDir.ts b/src/desktop/userDataDir.ts new file mode 100644 index 0000000000..22fba5ec23 --- /dev/null +++ b/src/desktop/userDataDir.ts @@ -0,0 +1,20 @@ +import * as path from "path"; +import { getMuxHome } from "@/common/constants/paths"; + +export function resolveMuxUserDataDir(options: { + muxUserDataDir?: string | undefined; + muxRoot?: string | undefined; + isE2E?: boolean | undefined; + muxHome?: string | undefined; +}): string | undefined { + if (options.muxUserDataDir) { + return options.muxUserDataDir; + } + + if (options.muxRoot || options.isE2E) { + const muxHome = options.muxHome ?? getMuxHome(); + return path.join(muxHome, "user-data"); + } + + return undefined; +} From 31dd966d63addb73c5a5fd1908f8be4f34873f47 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 14 Jan 2026 07:35:53 +0000 Subject: [PATCH 2/9] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20mux-testing-mu?= =?UTF-8?q?x=20built-in=20skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/builtinSkills/mux-testing-mux.md | 62 +++++++++++++++++++ .../agentSkills/agentSkillsService.test.ts | 2 +- 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 src/node/builtinSkills/mux-testing-mux.md diff --git a/src/node/builtinSkills/mux-testing-mux.md b/src/node/builtinSkills/mux-testing-mux.md new file mode 100644 index 0000000000..3c650dcb20 --- /dev/null +++ b/src/node/builtinSkills/mux-testing-mux.md @@ -0,0 +1,62 @@ +--- +name: mux-testing-mux +description: Launch a second mux desktop instance using an isolated root/profile. +--- + +# mux testing mux (isolated roots / profiles) + +Use this skill to launch a **second mux desktop instance on the same machine** for QA/validation. + +This works by setting a distinct mux home (`MUX_ROOT`) *and* a distinct Electron `userData` directory (derived automatically). + +## Quick start (packaged desktop) + +1. Pick an isolated root directory (do **not** use your real `~/.mux`): + + - macOS/Linux: `/tmp/mux-test-1` + - Windows: `%TEMP%\\mux-test-1` + +2. Launch mux pointing at that root: + + ```bash + mux --mux-root /tmp/mux-test-1 + # or + mux desktop --mux-root /tmp/mux-test-1 + ``` + +Mux will automatically use: + +- `muxHome = ` +- `userData = /user-data` + +So you can run multiple instances without collisions. + +## Dev build (electron .) + +If you’re running mux from the repo: + +```bash +# in one terminal +make dev + +# in another terminal +bunx electron . --mux-root /tmp/mux-test-1 +``` + +If your dev server is on a non-default host/port, pass through the values your dev instance uses (e.g. `MUX_DEVSERVER_HOST`, `MUX_DEVSERVER_PORT`). + +## Validate isolation + +In the test root you should see separate state, e.g.: + +- `/server.lock` +- `/user-data/` (Electron `userData`) +- `/config.json` + +## Cleanup + +Delete the test root when you’re done: + +```bash +rm -rf /tmp/mux-test-1 +``` diff --git a/src/node/services/agentSkills/agentSkillsService.test.ts b/src/node/services/agentSkills/agentSkillsService.test.ts index 9e640eb2e0..071b60b71c 100644 --- a/src/node/services/agentSkills/agentSkillsService.test.ts +++ b/src/node/services/agentSkills/agentSkillsService.test.ts @@ -38,7 +38,7 @@ describe("agentSkillsService", () => { const skills = await discoverAgentSkills(runtime, project.path, { roots }); // Should include project/global skills plus built-in skills - expect(skills.map((s) => s.name)).toEqual(["bar", "foo", "mux-docs"]); + expect(skills.map((s) => s.name)).toEqual(["bar", "foo", "mux-docs", "mux-testing-mux"]); const foo = skills.find((s) => s.name === "foo"); expect(foo).toBeDefined(); From 34a87f388a4a584be052e3ce2844dfb1331bf2ef Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 14 Jan 2026 08:07:28 +0000 Subject: [PATCH 3/9] =?UTF-8?q?=F0=9F=A4=96=20feat:=20launch=20isolated=20?= =?UTF-8?q?test=20instance=20from=20command=20palette?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/utils/commandIds.ts | 4 + src/browser/utils/commands/sources.ts | 58 ++++++ src/cli/cli.test.ts | 1 + src/cli/server.test.ts | 1 + src/cli/server.ts | 1 + src/common/orpc/schemas/api.ts | 19 ++ src/desktop/main.ts | 1 + src/node/orpc/context.ts | 2 + src/node/orpc/router.ts | 12 ++ src/node/services/serviceContainer.ts | 3 + src/node/services/testInstanceService.test.ts | 40 ++++ src/node/services/testInstanceService.ts | 171 ++++++++++++++++++ tests/ipc/setup.ts | 1 + 13 files changed, 314 insertions(+) create mode 100644 src/node/services/testInstanceService.test.ts create mode 100644 src/node/services/testInstanceService.ts diff --git a/src/browser/utils/commandIds.ts b/src/browser/utils/commandIds.ts index 400c705403..3814dfc63d 100644 --- a/src/browser/utils/commandIds.ts +++ b/src/browser/utils/commandIds.ts @@ -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; /** diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index d69a9d6dc2..10af4923f0 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -81,6 +81,7 @@ export const COMMAND_SECTIONS = { HELP: "Help", PROJECTS: "Projects", APPEARANCE: "Appearance", + DEBUG: "Debug", SETTINGS: "Settings", } as const; @@ -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, }; @@ -676,6 +678,62 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi }, ]); + // Debug + actions.push(() => { + const api = p.api; + if (!api || !window.api) return []; + + return [ + { + id: CommandIds.debugLaunchTestInstance(), + title: "Debug: Launch Test Instance", + subtitle: "Launch a second mux instance under /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 /instances/*", + section: section.debug, + run: async () => { + const ok = confirm( + "Delete ALL test instances under /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[] = [ diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index 102814c0ff..0908017a62 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -81,6 +81,7 @@ async function createTestServer(authToken?: string): Promise { telemetryService: services.telemetryService, sessionUsageService: services.sessionUsageService, signingService: services.signingService, + testInstanceService: services.testInstanceService, coderService: services.coderService, }; diff --git a/src/cli/server.test.ts b/src/cli/server.test.ts index c8ab80d6ed..82c1cce60e 100644 --- a/src/cli/server.test.ts +++ b/src/cli/server.test.ts @@ -83,6 +83,7 @@ async function createTestServer(): Promise { voiceService: services.voiceService, telemetryService: services.telemetryService, sessionUsageService: services.sessionUsageService, + testInstanceService: services.testInstanceService, signingService: services.signingService, coderService: services.coderService, }; diff --git a/src/cli/server.ts b/src/cli/server.ts index 0d47c99fe4..142c630492 100644 --- a/src/cli/server.ts +++ b/src/cli/server.ts @@ -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, diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 5e6bef6edd..87182f746f 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -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.object({}), + output: ResultSchema(z.object({ rootDir: z.string() }), z.string()), + }, + + /** + * Delete all test instance roots under /instances. + */ + deleteTestInstances: { + input: z.object({}), + output: ResultSchema( + z.object({ instancesDir: z.string(), deletedCount: z.number() }), + z.string() + ), + }, }; diff --git a/src/desktop/main.ts b/src/desktop/main.ts index 07fbd63fce..ff8ab5b150 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -377,6 +377,7 @@ async function loadServices(): Promise { voiceService: services.voiceService, telemetryService: services.telemetryService, experimentsService: services.experimentsService, + testInstanceService: services.testInstanceService, sessionUsageService: services.sessionUsageService, signingService: services.signingService, coderService: services.coderService, diff --git a/src/node/orpc/context.ts b/src/node/orpc/context.ts index 3cd5493476..70e5a2d512 100644 --- a/src/node/orpc/context.ts +++ b/src/node/orpc/context.ts @@ -22,6 +22,7 @@ import type { SigningService } from "@/node/services/signingService"; import type { FeatureFlagService } from "@/node/services/featureFlagService"; import type { SessionTimingService } from "@/node/services/sessionTimingService"; import type { SessionUsageService } from "@/node/services/sessionUsageService"; +import type { TestInstanceService } from "@/node/services/testInstanceService"; import type { TaskService } from "@/node/services/taskService"; import type { CoderService } from "@/node/services/coderService"; @@ -49,6 +50,7 @@ export interface ORPCContext { telemetryService: TelemetryService; experimentsService: ExperimentsService; sessionUsageService: SessionUsageService; + testInstanceService: TestInstanceService; signingService: SigningService; coderService: CoderService; headers?: IncomingHttpHeaders; diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index ad9c1a04f0..2cc5b39417 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -1931,6 +1931,18 @@ export const router = (authToken?: string) => { input.errorMessage ); }), + launchTestInstance: t + .input(schemas.debug.launchTestInstance.input) + .output(schemas.debug.launchTestInstance.output) + .handler(({ context }) => { + return context.testInstanceService.launchTestInstance(); + }), + deleteTestInstances: t + .input(schemas.debug.deleteTestInstances.input) + .output(schemas.debug.deleteTestInstances.output) + .handler(({ context }) => { + return context.testInstanceService.deleteTestInstances(); + }), }, telemetry: { track: t diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 2c56bcae0c..e54dd05a0f 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -44,6 +44,7 @@ import { TaskService } from "@/node/services/taskService"; import { getSigningService, type SigningService } from "@/node/services/signingService"; import { coderService, type CoderService } from "@/node/services/coderService"; import { setGlobalCoderService } from "@/node/runtime/runtimeFactory"; +import { TestInstanceService } from "@/node/services/testInstanceService"; /** * ServiceContainer - Central dependency container for all backend services. @@ -77,6 +78,7 @@ export class ServiceContainer { public readonly sessionTimingService: SessionTimingService; public readonly experimentsService: ExperimentsService; public readonly sessionUsageService: SessionUsageService; + public readonly testInstanceService: TestInstanceService; public readonly signingService: SigningService; public readonly coderService: CoderService; private readonly initStateManager: InitStateManager; @@ -87,6 +89,7 @@ export class ServiceContainer { constructor(config: Config) { this.config = config; + this.testInstanceService = new TestInstanceService(config); this.historyService = new HistoryService(config); this.partialService = new PartialService(config, this.historyService); this.projectService = new ProjectService(config); diff --git a/src/node/services/testInstanceService.test.ts b/src/node/services/testInstanceService.test.ts new file mode 100644 index 0000000000..95ab8d0c08 --- /dev/null +++ b/src/node/services/testInstanceService.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; + +import type { ProjectsConfig } from "@/common/types/project"; +import { createIsolatedConfigForTestInstance } from "./testInstanceService"; + +describe("createIsolatedConfigForTestInstance", () => { + test("clears workspace history but preserves project setup", () => { + const source: ProjectsConfig = { + projects: new Map([ + [ + "/repo/project-a", + { + workspaces: [{ path: "/repo/project-a/.mux/ws-1" }], + sections: [{ id: "deadbeef", name: "Section A", nextId: null }], + idleCompactionHours: 12, + }, + ], + ]), + apiServerPort: 1234, + }; + + const isolated = createIsolatedConfigForTestInstance(source); + + expect(isolated.apiServerPort).toBe(1234); + expect(Array.from(isolated.projects.keys())).toEqual(["/repo/project-a"]); + + const isolatedProject = isolated.projects.get("/repo/project-a"); + expect(isolatedProject).toBeDefined(); + expect(isolatedProject!.workspaces).toEqual([]); + expect(isolatedProject!.sections).toEqual([ + { id: "deadbeef", name: "Section A", nextId: null }, + ]); + expect(isolatedProject!.idleCompactionHours).toBe(12); + + // Ensure source is not mutated. + expect(source.projects.get("/repo/project-a")!.workspaces).toEqual([ + { path: "/repo/project-a/.mux/ws-1" }, + ]); + }); +}); diff --git a/src/node/services/testInstanceService.ts b/src/node/services/testInstanceService.ts new file mode 100644 index 0000000000..789c23997e --- /dev/null +++ b/src/node/services/testInstanceService.ts @@ -0,0 +1,171 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +import type { ProjectsConfig, ProjectConfig } from "@/common/types/project"; +import { Config } from "@/node/config"; + +function sanitizeProjectConfigForTestInstance(project: ProjectConfig): ProjectConfig { + return { + ...project, + // Drop workspace history so the test instance starts clean. + workspaces: [], + // Ensure arrays aren't shared between instances. + sections: project.sections ? [...project.sections] : undefined, + }; +} + +export function createIsolatedConfigForTestInstance(source: ProjectsConfig): ProjectsConfig { + const projects = new Map(); + for (const [projectPath, projectConfig] of source.projects.entries()) { + projects.set(projectPath, sanitizeProjectConfigForTestInstance(projectConfig)); + } + + return { + ...source, + projects, + }; +} + +function isMissingFileError(err: unknown): boolean { + if (!err || typeof err !== "object") return false; + if (!("code" in err)) return false; + return (err as { code?: unknown }).code === "ENOENT"; +} + +function stripArgWithOptionalValue(args: string[], matcher: (arg: string) => boolean): string[] { + const result: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (matcher(arg)) { + // Handle `--flag value` and `--flag=value`. + if (!arg.includes("=") && args[i + 1] && !args[i + 1].startsWith("-")) { + i += 1; + } + continue; + } + + result.push(arg); + } + + return result; +} + +function buildSpawnArgsForNewRoot(newRoot: string): string[] { + const baseArgs = process.argv.slice(1); + + const withoutMuxRoot = stripArgWithOptionalValue(baseArgs, (arg) => { + return ( + arg === "--mux-root" || + arg === "--root" || + arg.startsWith("--mux-root=") || + arg.startsWith("--root=") + ); + }); + + // Avoid debug port collisions when spawning a second Electron instance in dev. + const withoutRemoteDebugPort = stripArgWithOptionalValue(withoutMuxRoot, (arg) => { + return arg === "--remote-debugging-port" || arg.startsWith("--remote-debugging-port="); + }); + + return [...withoutRemoteDebugPort, "--mux-root", newRoot]; +} + +export class TestInstanceService { + constructor(private readonly config: Config) {} + + private async createInstanceRoot(): Promise { + const instancesDir = path.join(this.config.rootDir, "instances"); + await fs.mkdir(instancesDir, { recursive: true }); + + // Keep it Windows-safe (no ':'), but also readable. + const stamp = new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-"); + const rand = Math.random().toString(16).slice(2, 8); + const rootDir = path.join(instancesDir, `${stamp}-${rand}`); + + await fs.mkdir(rootDir, { recursive: true }); + return rootDir; + } + + private async copyIfExists(src: string, dest: string): Promise { + try { + await fs.copyFile(src, dest); + } catch (err) { + if (isMissingFileError(err)) return; + throw err; + } + } + + async launchTestInstance(): Promise< + { success: true; data: { rootDir: string } } | { success: false; error: string } + > { + if (!process.versions.electron) { + return { + success: false, + error: "Launch Test Instance is only available in the desktop app.", + }; + } + + const rootDir = await this.createInstanceRoot(); + + // Copy provider setup (API keys, endpoints), but not workspaces/sessions. + await this.copyIfExists( + path.join(this.config.rootDir, "providers.jsonc"), + path.join(rootDir, "providers.jsonc") + ); + await this.copyIfExists( + path.join(this.config.rootDir, "secrets.json"), + path.join(rootDir, "secrets.json") + ); + + const sourceConfig = this.config.loadConfigOrDefault(); + const isolatedConfig = createIsolatedConfigForTestInstance(sourceConfig); + + const instanceConfig = new Config(rootDir); + await instanceConfig.saveConfig(isolatedConfig); + + try { + // Intentionally lazy import (rare debug action). + // eslint-disable-next-line no-restricted-syntax -- main-process-only builtin + const { spawn } = await import("node:child_process"); + + const child = spawn(process.execPath, buildSpawnArgsForNewRoot(rootDir), { + detached: true, + stdio: "ignore", + windowsHide: true, + }); + child.unref(); + + return { success: true, data: { rootDir } }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } + } + + async deleteTestInstances(): Promise< + | { success: true; data: { instancesDir: string; deletedCount: number } } + | { success: false; error: string } + > { + const instancesDir = path.join(this.config.rootDir, "instances"); + + let entries: Array<{ name: string; isDirectory: () => boolean }>; + try { + entries = await fs.readdir(instancesDir, { withFileTypes: true }); + } catch (err) { + if (isMissingFileError(err)) { + return { success: true, data: { instancesDir, deletedCount: 0 } }; + } + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } + + const dirs = entries.filter((e) => e.isDirectory()).map((e) => path.join(instancesDir, e.name)); + + try { + await Promise.all(dirs.map((dir) => fs.rm(dir, { recursive: true, force: true }))); + return { success: true, data: { instancesDir, deletedCount: dirs.length } }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } + } +} diff --git a/tests/ipc/setup.ts b/tests/ipc/setup.ts index e6dd3d9b5a..d533d839c4 100644 --- a/tests/ipc/setup.ts +++ b/tests/ipc/setup.ts @@ -104,6 +104,7 @@ export async function createTestEnvironment(): Promise { telemetryService: services.telemetryService, sessionUsageService: services.sessionUsageService, signingService: services.signingService, + testInstanceService: services.testInstanceService, coderService: services.coderService, }; const orpc = createOrpcTestClient(orpcContext); From b0f4d5249ccbeb700309462f433d040958f21226 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 14 Jan 2026 10:01:14 +0000 Subject: [PATCH 4/9] =?UTF-8?q?=F0=9F=A4=96=20fix:=20guard=20debug=20comma?= =?UTF-8?q?nds=20when=20window.api=20missing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/utils/commands/sources.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index 10af4923f0..2ec8a2decc 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -681,7 +681,7 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi // Debug actions.push(() => { const api = p.api; - if (!api || !window.api) return []; + if (!api || typeof window === "undefined" || !window.api) return []; return [ { From 778c04ce1c86b333827427f690d816188e35291e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 14 Jan 2026 10:04:24 +0000 Subject: [PATCH 5/9] =?UTF-8?q?=F0=9F=A4=96=20fix:=20avoid=20API=20server?= =?UTF-8?q?=20port=20collisions=20for=20test=20instances?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/testInstanceService.test.ts | 2 +- src/node/services/testInstanceService.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/node/services/testInstanceService.test.ts b/src/node/services/testInstanceService.test.ts index 95ab8d0c08..9f9d7e0cea 100644 --- a/src/node/services/testInstanceService.test.ts +++ b/src/node/services/testInstanceService.test.ts @@ -21,7 +21,7 @@ describe("createIsolatedConfigForTestInstance", () => { const isolated = createIsolatedConfigForTestInstance(source); - expect(isolated.apiServerPort).toBe(1234); + expect(isolated.apiServerPort).toBeUndefined(); expect(Array.from(isolated.projects.keys())).toEqual(["/repo/project-a"]); const isolatedProject = isolated.projects.get("/repo/project-a"); diff --git a/src/node/services/testInstanceService.ts b/src/node/services/testInstanceService.ts index 789c23997e..397cde8b3c 100644 --- a/src/node/services/testInstanceService.ts +++ b/src/node/services/testInstanceService.ts @@ -23,6 +23,8 @@ export function createIsolatedConfigForTestInstance(source: ProjectsConfig): Pro return { ...source, projects, + // Avoid API server port collisions if the main instance is pinned to a fixed port. + apiServerPort: undefined, }; } From 880d1872ee5aeb32753f4086a28c5a44a3de221a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 14 Jan 2026 10:13:11 +0000 Subject: [PATCH 6/9] =?UTF-8?q?=F0=9F=A4=96=20fix:=20validate=20--mux-root?= =?UTF-8?q?=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cli/argv.test.ts | 9 +++++++++ src/cli/argv.ts | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/cli/argv.test.ts b/src/cli/argv.test.ts index 9ac8fadfdb..0f7a1797d8 100644 --- a/src/cli/argv.test.ts +++ b/src/cli/argv.test.ts @@ -136,6 +136,15 @@ describe("consumeMuxRootFromArgv", () => { 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", () => { diff --git a/src/cli/argv.ts b/src/cli/argv.ts index 3f533251cf..4b6905394a 100644 --- a/src/cli/argv.ts +++ b/src/cli/argv.ts @@ -73,7 +73,7 @@ export function consumeMuxRootFromArgv( if (arg === "--mux-root" || arg === "--root") { const value = argv[i + 1]; - if (value === undefined) { + if (value === undefined || value.startsWith("-")) { return { error: `Missing value for ${arg}` }; } From 8e8d500e4532bb750da6b1fb9409bb164aa77c70 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 14 Jan 2026 10:27:33 +0000 Subject: [PATCH 7/9] =?UTF-8?q?=F0=9F=A4=96=20fix:=20isolate=20test=20inst?= =?UTF-8?q?ance=20userData=20when=20MUX=5FUSER=5FDATA=5FDIR=20set?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/testInstanceService.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/node/services/testInstanceService.ts b/src/node/services/testInstanceService.ts index 397cde8b3c..b04ac2de9e 100644 --- a/src/node/services/testInstanceService.ts +++ b/src/node/services/testInstanceService.ts @@ -136,6 +136,12 @@ export class TestInstanceService { detached: true, stdio: "ignore", windowsHide: true, + env: { + ...process.env, + // Ensure test instances stay isolated even if the parent was launched with + // MUX_USER_DATA_DIR (which would otherwise override per-root userData). + MUX_USER_DATA_DIR: path.join(rootDir, "user-data"), + }, }); child.unref(); From 567a541cfbfe39a0bc796c6d6c233e665a911622 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 14 Jan 2026 11:46:55 +0000 Subject: [PATCH 8/9] =?UTF-8?q?=F0=9F=A4=96=20fix:=20honor=20muxRoot=20whe?= =?UTF-8?q?n=20resolving=20userData?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/desktop/userDataDir.test.ts | 4 ++-- src/desktop/userDataDir.ts | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/desktop/userDataDir.test.ts b/src/desktop/userDataDir.test.ts index 17f4cbfbc6..c419aa3bb2 100644 --- a/src/desktop/userDataDir.test.ts +++ b/src/desktop/userDataDir.test.ts @@ -14,8 +14,8 @@ describe("resolveMuxUserDataDir", () => { expect(result).toBe("/tmp/custom-user-data"); }); - test("defaults to /user-data when muxRoot is set", () => { - const result = resolveMuxUserDataDir({ muxRoot: "/tmp/mux-root", muxHome: "/tmp/mux-root" }); + test("defaults to /user-data when muxRoot is set", () => { + const result = resolveMuxUserDataDir({ muxRoot: "/tmp/mux-root" }); expect(result).toBe(path.join("/tmp/mux-root", "user-data")); }); diff --git a/src/desktop/userDataDir.ts b/src/desktop/userDataDir.ts index 22fba5ec23..4677c8d54c 100644 --- a/src/desktop/userDataDir.ts +++ b/src/desktop/userDataDir.ts @@ -12,7 +12,9 @@ export function resolveMuxUserDataDir(options: { } if (options.muxRoot || options.isE2E) { - const muxHome = options.muxHome ?? getMuxHome(); + // Prefer explicit inputs (muxHome / muxRoot) so callers can compute a path + // without mutating process.env before calling this helper. + const muxHome = options.muxHome ?? options.muxRoot ?? getMuxHome(); return path.join(muxHome, "user-data"); } From f2a478a7c808f45c8129b0cd7d10c0bd458ea812 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 16 Jan 2026 17:46:51 +0000 Subject: [PATCH 9/9] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20simplify=20mux?= =?UTF-8?q?=20test=20instance=20plumbing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/utils/commands/sources.ts | 4 +- src/cli/argv.ts | 35 +++++--- src/common/orpc/schemas/api.ts | 4 +- src/node/services/testInstanceService.ts | 104 +++++++++-------------- 4 files changed, 65 insertions(+), 82 deletions(-) diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index 2ec8a2decc..b48ffd723d 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -691,7 +691,7 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi section: section.debug, run: async () => { try { - const result = await api.debug.launchTestInstance({}); + const result = await api.debug.launchTestInstance(); if (result.success) { alert(`Launched test instance at:\n${result.data.rootDir}`); } else { @@ -716,7 +716,7 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi if (!ok) return; try { - const result = await api.debug.deleteTestInstances({}); + const result = await api.debug.deleteTestInstances(); if (result.success) { alert( `Deleted ${result.data.deletedCount} test instance(s).\n\n${result.data.instancesDir}` diff --git a/src/cli/argv.ts b/src/cli/argv.ts index 4b6905394a..ba8830d71d 100644 --- a/src/cli/argv.ts +++ b/src/cli/argv.ts @@ -68,31 +68,38 @@ export function consumeMuxRootFromArgv( ): { muxRoot?: string; error?: string } { let muxRoot: string | undefined; + // Matches: + // - --mux-root + // - --root + // - --mux-root= + // - --root= + 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; - if (arg === "--mux-root" || arg === "--root") { - const value = argv[i + 1]; - if (value === undefined || value.startsWith("-")) { + const equalsValue = match[2]; + if (equalsValue !== undefined) { + if (!equalsValue) { return { error: `Missing value for ${arg}` }; } - muxRoot = value; - argv.splice(i, 2); + muxRoot = equalsValue; + argv.splice(i, 1); i -= 1; continue; } - if (arg.startsWith("--mux-root=") || arg.startsWith("--root=")) { - const [, value] = arg.split("=", 2); - if (!value) { - return { error: `Missing value for ${arg}` }; - } - - muxRoot = value; - argv.splice(i, 1); - i -= 1; + 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 }; diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 87182f746f..0bc1537302 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -1085,7 +1085,7 @@ export const debug = { * Launch a new mux desktop instance under an isolated root (for QA). */ launchTestInstance: { - input: z.object({}), + input: z.void(), output: ResultSchema(z.object({ rootDir: z.string() }), z.string()), }, @@ -1093,7 +1093,7 @@ export const debug = { * Delete all test instance roots under /instances. */ deleteTestInstances: { - input: z.object({}), + input: z.void(), output: ResultSchema( z.object({ instancesDir: z.string(), deletedCount: z.number() }), z.string() diff --git a/src/node/services/testInstanceService.ts b/src/node/services/testInstanceService.ts index b04ac2de9e..0ea47a09cf 100644 --- a/src/node/services/testInstanceService.ts +++ b/src/node/services/testInstanceService.ts @@ -2,6 +2,7 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import type { ProjectsConfig, ProjectConfig } from "@/common/types/project"; +import { Err, Ok, type Result } from "@/common/types/result"; import { Config } from "@/node/config"; function sanitizeProjectConfigForTestInstance(project: ProjectConfig): ProjectConfig { @@ -34,44 +35,38 @@ function isMissingFileError(err: unknown): boolean { return (err as { code?: unknown }).code === "ENOENT"; } -function stripArgWithOptionalValue(args: string[], matcher: (arg: string) => boolean): string[] { - const result: string[] = []; +function buildSpawnArgsForNewRoot(newRoot: string): string[] { + const baseArgs = process.argv.slice(1); + const filtered: string[] = []; - for (let i = 0; i < args.length; i++) { - const arg = args[i]; + for (let i = 0; i < baseArgs.length; i++) { + const arg = baseArgs[i]; - if (matcher(arg)) { - // Handle `--flag value` and `--flag=value`. - if (!arg.includes("=") && args[i + 1] && !args[i + 1].startsWith("-")) { + const isMuxRootFlag = arg === "--mux-root" || arg === "--root"; + const isRemoteDebuggingFlag = arg === "--remote-debugging-port"; + + if (isMuxRootFlag || isRemoteDebuggingFlag) { + // Handle `--flag value`. + const maybeValue = baseArgs[i + 1]; + if (maybeValue && !maybeValue.startsWith("-")) { i += 1; } continue; } - result.push(arg); - } - - return result; -} - -function buildSpawnArgsForNewRoot(newRoot: string): string[] { - const baseArgs = process.argv.slice(1); - - const withoutMuxRoot = stripArgWithOptionalValue(baseArgs, (arg) => { - return ( - arg === "--mux-root" || - arg === "--root" || + // Handle `--flag=value`. + if ( arg.startsWith("--mux-root=") || - arg.startsWith("--root=") - ); - }); + arg.startsWith("--root=") || + arg.startsWith("--remote-debugging-port=") + ) { + continue; + } - // Avoid debug port collisions when spawning a second Electron instance in dev. - const withoutRemoteDebugPort = stripArgWithOptionalValue(withoutMuxRoot, (arg) => { - return arg === "--remote-debugging-port" || arg.startsWith("--remote-debugging-port="); - }); + filtered.push(arg); + } - return [...withoutRemoteDebugPort, "--mux-root", newRoot]; + return [...filtered, "--mux-root", newRoot]; } export class TestInstanceService { @@ -81,13 +76,9 @@ export class TestInstanceService { const instancesDir = path.join(this.config.rootDir, "instances"); await fs.mkdir(instancesDir, { recursive: true }); - // Keep it Windows-safe (no ':'), but also readable. - const stamp = new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-"); - const rand = Math.random().toString(16).slice(2, 8); - const rootDir = path.join(instancesDir, `${stamp}-${rand}`); - - await fs.mkdir(rootDir, { recursive: true }); - return rootDir; + // node:fs mkdtemp always appends 6 random characters. + // Keep the prefix Windows-safe (no ':'), but human-readable. + return fs.mkdtemp(path.join(instancesDir, "test-instance-")); } private async copyIfExists(src: string, dest: string): Promise { @@ -99,33 +90,21 @@ export class TestInstanceService { } } - async launchTestInstance(): Promise< - { success: true; data: { rootDir: string } } | { success: false; error: string } - > { + async launchTestInstance(): Promise> { if (!process.versions.electron) { - return { - success: false, - error: "Launch Test Instance is only available in the desktop app.", - }; + return Err("Launch Test Instance is only available in the desktop app."); } const rootDir = await this.createInstanceRoot(); // Copy provider setup (API keys, endpoints), but not workspaces/sessions. - await this.copyIfExists( - path.join(this.config.rootDir, "providers.jsonc"), - path.join(rootDir, "providers.jsonc") - ); - await this.copyIfExists( - path.join(this.config.rootDir, "secrets.json"), - path.join(rootDir, "secrets.json") - ); + for (const file of ["providers.jsonc", "secrets.json"] as const) { + await this.copyIfExists(path.join(this.config.rootDir, file), path.join(rootDir, file)); + } const sourceConfig = this.config.loadConfigOrDefault(); - const isolatedConfig = createIsolatedConfigForTestInstance(sourceConfig); - const instanceConfig = new Config(rootDir); - await instanceConfig.saveConfig(isolatedConfig); + await instanceConfig.saveConfig(createIsolatedConfigForTestInstance(sourceConfig)); try { // Intentionally lazy import (rare debug action). @@ -145,35 +124,32 @@ export class TestInstanceService { }); child.unref(); - return { success: true, data: { rootDir } }; + return Ok({ rootDir }); } catch (err) { - return { success: false, error: err instanceof Error ? err.message : String(err) }; + return Err(err instanceof Error ? err.message : String(err)); } } - async deleteTestInstances(): Promise< - | { success: true; data: { instancesDir: string; deletedCount: number } } - | { success: false; error: string } - > { + async deleteTestInstances(): Promise> { const instancesDir = path.join(this.config.rootDir, "instances"); - let entries: Array<{ name: string; isDirectory: () => boolean }>; + let entries; try { entries = await fs.readdir(instancesDir, { withFileTypes: true }); } catch (err) { if (isMissingFileError(err)) { - return { success: true, data: { instancesDir, deletedCount: 0 } }; + return Ok({ instancesDir, deletedCount: 0 }); } - return { success: false, error: err instanceof Error ? err.message : String(err) }; + return Err(err instanceof Error ? err.message : String(err)); } const dirs = entries.filter((e) => e.isDirectory()).map((e) => path.join(instancesDir, e.name)); try { await Promise.all(dirs.map((dir) => fs.rm(dir, { recursive: true, force: true }))); - return { success: true, data: { instancesDir, deletedCount: dirs.length } }; + return Ok({ instancesDir, deletedCount: dirs.length }); } catch (err) { - return { success: false, error: err instanceof Error ? err.message : String(err) }; + return Err(err instanceof Error ? err.message : String(err)); } } }