diff --git a/cli/src/server/services/backend.ts b/cli/src/server/services/backend.ts index a32ee17..454ab88 100644 --- a/cli/src/server/services/backend.ts +++ b/cli/src/server/services/backend.ts @@ -67,10 +67,16 @@ export function createBackends(opts: { macos?: BackendType; dockerSocket?: string; }): BackendMap { + let macos = opts.macos; + + if (macos === "tart" && !TartBackend.isAvailable()) { + macos = undefined; + } + return { linux: createBackend(opts.linux, { dockerSocket: opts.dockerSocket }), - macos: opts.macos - ? createBackend(opts.macos, { dockerSocket: opts.dockerSocket }) + macos: macos + ? createBackend(macos, { dockerSocket: opts.dockerSocket }) : undefined, }; } diff --git a/cli/src/server/services/tart.ts b/cli/src/server/services/tart.ts index f33a53e..166b311 100644 --- a/cli/src/server/services/tart.ts +++ b/cli/src/server/services/tart.ts @@ -27,6 +27,7 @@ function tartExec(args: string[], timeoutMs = 10_000): string { return execSync(`tart ${args.join(" ")}`, { encoding: "utf-8", timeout: timeoutMs, + stdio: ["ignore", "pipe", "pipe"], }).trim(); } @@ -92,6 +93,15 @@ export class TartBackend implements ContainerBackend { readonly supportsPause = true; private instances = new Map(); + static isAvailable(): boolean { + try { + execSync("tart --version", { stdio: "ignore", timeout: 5_000 }); + return true; + } catch { + return false; + } + } + private vmIsSuspended(vmName: string): boolean { const vms = tartList(); const vm = vms.find((v) => v.name === vmName); diff --git a/cli/test/unit/server/services/backend.spec.ts b/cli/test/unit/server/services/backend.spec.ts index 8c4d1c3..42a67b6 100644 --- a/cli/test/unit/server/services/backend.spec.ts +++ b/cli/test/unit/server/services/backend.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; vi.mock("../../../../src/server/config.ts", () => ({ config: { @@ -8,9 +8,13 @@ vi.mock("../../../../src/server/config.ts", () => ({ }, })); -import { createBackend } from "../../../../src/server/services/backend.ts"; +import { + createBackend, + createBackends, +} from "../../../../src/server/services/backend.ts"; import { DockerService } from "../../../../src/server/services/docker.ts"; import { ShuruBackend } from "../../../../src/server/services/shuru.ts"; +import { TartBackend } from "../../../../src/server/services/tart.ts"; describe("createBackend", () => { it("returns DockerService for 'docker'", () => { @@ -34,3 +38,38 @@ describe("createBackend", () => { ); }); }); + +describe("createBackends", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("creates the tart macOS backend when tart is installed", () => { + vi.spyOn(TartBackend, "isAvailable").mockReturnValue(true); + + const backends = createBackends({ linux: "docker", macos: "tart" }); + + expect(backends.linux).toBeInstanceOf(DockerService); + expect(backends.macos).toBeInstanceOf(TartBackend); + }); + + it("disables the tart macOS backend when tart is not installed", () => { + vi.spyOn(TartBackend, "isAvailable").mockReturnValue(false); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const backends = createBackends({ linux: "docker", macos: "tart" }); + + expect(backends.macos).toBeUndefined(); + expect(backends.linux).toBeInstanceOf(DockerService); + expect(warn).not.toHaveBeenCalled(); + }); + + it("never probes for tart when no macOS backend is requested", () => { + const isAvailable = vi.spyOn(TartBackend, "isAvailable"); + + const backends = createBackends({ linux: "docker" }); + + expect(backends.macos).toBeUndefined(); + expect(isAvailable).not.toHaveBeenCalled(); + }); +}); diff --git a/cli/test/unit/server/services/tart.spec.ts b/cli/test/unit/server/services/tart.spec.ts index fef43aa..3a37e89 100644 --- a/cli/test/unit/server/services/tart.spec.ts +++ b/cli/test/unit/server/services/tart.spec.ts @@ -164,4 +164,40 @@ describe("TartBackend", () => { expect(list).toHaveLength(0); }); }); + + describe("isAvailable", () => { + it("returns true when the tart CLI runs", () => { + execSyncMock.mockReturnValue("tart 2.0.0"); + expect(TartBackend.isAvailable()).toBe(true); + }); + + it("returns false when the tart CLI is not installed", () => { + execSyncMock.mockImplementation(() => { + throw new Error("/bin/sh: tart: command not found"); + }); + expect(TartBackend.isAvailable()).toBe(false); + }); + + it("probes without leaking output to the console", () => { + execSyncMock.mockReturnValue(""); + TartBackend.isAvailable(); + expect(execSyncMock).toHaveBeenCalledWith( + "tart --version", + expect.objectContaining({ stdio: "ignore" }), + ); + }); + }); + + describe("tart command execution", () => { + it("captures stderr instead of leaking it to the parent console", async () => { + execSyncMock.mockReturnValue(JSON.stringify([])); + + await backend.listSandboxes(); + + expect(execSyncMock).toHaveBeenCalledWith( + "tart list --format json", + expect.objectContaining({ stdio: ["ignore", "pipe", "pipe"] }), + ); + }); + }); });