From 37f59c6649b3420ccc0b9ce80cc9cd57dff6fcec Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Wed, 29 Apr 2026 15:14:41 -0700 Subject: [PATCH] fix(local): install OrbStack from DMG when Homebrew is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensureDocker() on macOS unconditionally shelled out to `brew install orbstack`, then on failure printed "install OrbStack manually: brew install orbstack" — circular dead-end for Macs without Homebrew. Now: - Probe `which brew`. If present, keep using brew (existing happy path). - If brew is missing, download the official OrbStack DMG over HTTPS from orbstack.dev (arch-aware: arm64 vs amd64), mount it via `hdiutil`, copy OrbStack.app into /Applications, and clear the quarantine xattr so it launches cleanly. - If both paths fail, the new error message points users at the OrbStack download page (not back at brew). DMG handling uses tryCatch + sequential cleanup (no try/finally, per the `lint/plugin` rule in this repo). Adds a test for the brew-missing fallback and updates the existing brew-present test to account for the new `which brew` probe. Bumps version to 1.0.24. --- packages/cli/package.json | 2 +- packages/cli/src/__tests__/sandbox.test.ts | 105 +++++++++- packages/cli/src/local/local.ts | 215 +++++++++++++++++++-- 3 files changed, 298 insertions(+), 24 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index d747cda70..8b2c002aa 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "1.0.26", + "version": "1.0.27", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/sandbox.test.ts b/packages/cli/src/__tests__/sandbox.test.ts index 603bc27b8..7b622b336 100644 --- a/packages/cli/src/__tests__/sandbox.test.ts +++ b/packages/cli/src/__tests__/sandbox.test.ts @@ -83,7 +83,7 @@ describe("ensureDocker", () => { spy.mockRestore(); }); - it("attempts brew install on macOS when docker not installed", async () => { + it("attempts brew install on macOS when docker not installed and brew is present", async () => { const origPlatform = Object.getOwnPropertyDescriptor(process, "platform"); Object.defineProperty(process, "platform", { value: "darwin", @@ -112,7 +112,8 @@ describe("ensureDocker", () => { pid: 1234, } satisfies ReturnType; // 1: docker info → fail, 2: which docker → fail (not installed), - // 3: brew install → ok, 4: open -a OrbStack → ok, 5: docker info → ok + // 3: which brew → ok, 4: brew install → ok, + // 5: open -a OrbStack → ok, 6: docker info → ok (waitForReady loop) if (callCount <= 2) { return fail; } @@ -121,14 +122,19 @@ describe("ensureDocker", () => { await ensureDocker(); - // Call 1: docker info, 2: which docker, 3: brew install orbstack + // Call 3: which brew (probe) expect(spy.mock.calls[2][0]).toEqual([ + "which", + "brew", + ]); + // Call 4: brew install orbstack + expect(spy.mock.calls[3][0]).toEqual([ "brew", "install", "orbstack", ]); - // Call 4: open -a OrbStack (starts daemon) - expect(spy.mock.calls[3][0]).toEqual([ + // Call 5: open -a OrbStack (starts daemon) + expect(spy.mock.calls[4][0]).toEqual([ "open", "-a", "OrbStack", @@ -139,6 +145,95 @@ describe("ensureDocker", () => { Object.defineProperty(process, "platform", origPlatform); } }); + + it("falls back to DMG download on macOS when brew is missing", async () => { + const origPlatform = Object.getOwnPropertyDescriptor(process, "platform"); + Object.defineProperty(process, "platform", { + value: "darwin", + configurable: true, + }); + + // The DMG installer size-checks the downloaded file; have the curl mock + // write a real fake-DMG large enough to pass the threshold. + const { writeFileSync } = await import("node:fs"); + const { isString } = await import("@openrouter/spawn-shared"); + + let callCount = 0; + const sawCurl = { + hit: false, + }; + const sawHdiutilAttach = { + hit: false, + }; + const sawCp = { + hit: false, + }; + const sawHdiutilDetach = { + hit: false, + }; + + const spy = spyOn(Bun, "spawnSync").mockImplementation((...args: unknown[]) => { + callCount++; + const argv = Array.isArray(args[0]) ? args[0] : []; + const ok = { + exitCode: 0, + stdout: new TextEncoder().encode(argv[0] === "uname" ? "arm64\n" : ""), + stderr: new Uint8Array(), + success: true, + signalCode: null, + resourceUsage: undefined, + pid: 1234, + } satisfies ReturnType; + const fail = { + exitCode: 1, + stdout: new Uint8Array(), + stderr: new Uint8Array(), + success: false, + signalCode: null, + resourceUsage: undefined, + pid: 1234, + } satisfies ReturnType; + + // Track which steps of the DMG installer ran. + if (argv[0] === "curl") { + sawCurl.hit = true; + // Write a fake DMG large enough to pass the >1MB sanity check. + const outIdx = argv.indexOf("-o"); + const outPath = outIdx >= 0 ? argv[outIdx + 1] : undefined; + if (isString(outPath)) { + writeFileSync(outPath, Buffer.alloc(2_000_000)); + } + } + if (argv[0] === "hdiutil" && argv[1] === "attach") { + sawHdiutilAttach.hit = true; + } + if (argv[0] === "cp") { + sawCp.hit = true; + } + if (argv[0] === "hdiutil" && argv[1] === "detach") { + sawHdiutilDetach.hit = true; + } + + // 1: docker info → fail, 2: which docker → fail, 3: which brew → fail. + if (callCount <= 3) { + return fail; + } + // Everything else (uname, curl, hdiutil, cp, xattr, open, docker info) → ok. + return ok; + }); + + await ensureDocker(); + + expect(sawCurl.hit).toBe(true); + expect(sawHdiutilAttach.hit).toBe(true); + expect(sawCp.hit).toBe(true); + expect(sawHdiutilDetach.hit).toBe(true); + + spy.mockRestore(); + if (origPlatform) { + Object.defineProperty(process, "platform", origPlatform); + } + }); }); // ─── pullAndStartContainer ────────────────────────────────────────────────── diff --git a/packages/cli/src/local/local.ts b/packages/cli/src/local/local.ts index 98d048d5b..4183bff53 100644 --- a/packages/cli/src/local/local.ts +++ b/packages/cli/src/local/local.ts @@ -1,7 +1,9 @@ // local/local.ts — Core local provider: runs commands on the user's machine -import { copyFileSync, mkdirSync } from "node:fs"; -import { dirname, resolve } from "node:path"; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { tryCatch } from "@openrouter/spawn-shared"; import { DOCKER_CONTAINER_NAME, DOCKER_REGISTRY } from "../shared/orchestrate.js"; import { getUserHome } from "../shared/paths.js"; import { getLocalShell } from "../shared/shell.js"; @@ -175,6 +177,175 @@ function isDockerInstalled(): boolean { ); } +/** Check whether Homebrew is on PATH. */ +function hasBrew(): boolean { + return ( + Bun.spawnSync( + [ + "which", + "brew", + ], + { + stdio: [ + "ignore", + "ignore", + "ignore", + ], + }, + ).exitCode === 0 + ); +} + +/** + * Install OrbStack on macOS by downloading the official DMG over HTTPS, + * mounting it, copying OrbStack.app into /Applications, and unmounting. + * + * Why: Homebrew may not be installed, and our previous fallback message + * (`brew install orbstack`) would also fail on those machines. The DMG + * is the same artifact OrbStack publishes for manual install. + * + * Returns true on success, false if any step fails (caller falls back + * to printed instructions). + */ +function installOrbStackViaDmg(): boolean { + // Pick the right architecture build. OrbStack labels Apple Silicon as + // `arm64` and Intel as `amd64`. + const uname = Bun.spawnSync([ + "uname", + "-m", + ]); + const arch = uname.stdout.toString().trim() === "arm64" ? "arm64" : "amd64"; + const dmgUrl = `https://orbstack.dev/download/stable/latest/${arch}`; + + const tempDir = mkdtempSync(join(tmpdir(), "spawn-orbstack-")); + const dmgPath = join(tempDir, "OrbStack.dmg"); + const mountPoint = join(tempDir, "mnt"); + let attached = false; + + // Wrap all the work; cleanup runs unconditionally afterwards. + const work = tryCatch((): boolean => { + logStep(`Downloading OrbStack (${arch})...`); + const dl = Bun.spawnSync( + [ + "curl", + "-fsSL", + "-o", + dmgPath, + dmgUrl, + ], + { + stdio: [ + "ignore", + "inherit", + "inherit", + ], + }, + ); + if (dl.exitCode !== 0) { + return false; + } + + // Sanity-check: a real DMG is at least a few megabytes; any HTML error + // page or truncated download will be tiny. + if (statSync(dmgPath).size < 1_000_000) { + return false; + } + + logStep("Mounting OrbStack disk image..."); + mkdirSync(mountPoint, { + recursive: true, + }); + const attach = Bun.spawnSync( + [ + "hdiutil", + "attach", + "-nobrowse", + "-quiet", + "-mountpoint", + mountPoint, + dmgPath, + ], + { + stdio: [ + "ignore", + "ignore", + "inherit", + ], + }, + ); + if (attach.exitCode !== 0) { + return false; + } + attached = true; + + logStep("Copying OrbStack.app to /Applications..."); + const cp = Bun.spawnSync( + [ + "cp", + "-R", + join(mountPoint, "OrbStack.app"), + "/Applications/", + ], + { + stdio: [ + "ignore", + "ignore", + "inherit", + ], + }, + ); + if (cp.exitCode !== 0) { + return false; + } + + // Clear the quarantine xattr — curl downloads have no Safari attribution + // but some macOS versions still flag the unpacked .app. The user opted + // in by running spawn, so remove it explicitly. + Bun.spawnSync( + [ + "xattr", + "-dr", + "com.apple.quarantine", + "/Applications/OrbStack.app", + ], + { + stdio: [ + "ignore", + "ignore", + "ignore", + ], + }, + ); + + logInfo("OrbStack installed to /Applications/OrbStack.app"); + return true; + }); + + if (attached) { + Bun.spawnSync( + [ + "hdiutil", + "detach", + "-quiet", + mountPoint, + ], + { + stdio: [ + "ignore", + "ignore", + "ignore", + ], + }, + ); + } + rmSync(tempDir, { + recursive: true, + force: true, + }); + + return work.ok && work.data === true; +} + /** Try to start the Docker daemon and wait up to 30s for it to respond. */ function startAndWaitForDocker(isMac: boolean): void { if (isMac) { @@ -261,23 +432,31 @@ export async function ensureDocker(): Promise { // Not installed at all — install first if (isMac) { - logStep("Docker not found — installing OrbStack..."); - const result = Bun.spawnSync( - [ - "brew", - "install", - "orbstack", - ], - { - stdio: [ - "ignore", - "inherit", - "inherit", + let installed = false; + if (hasBrew()) { + logStep("Docker not found — installing OrbStack via Homebrew..."); + const result = Bun.spawnSync( + [ + "brew", + "install", + "orbstack", ], - }, - ); - if (result.exitCode !== 0) { - logInfo("Auto-install failed. Install OrbStack manually: brew install orbstack"); + { + stdio: [ + "ignore", + "inherit", + "inherit", + ], + }, + ); + installed = result.exitCode === 0; + } else { + logStep("Docker not found — installing OrbStack from orbstack.dev..."); + installed = installOrbStackViaDmg(); + } + if (!installed) { + logInfo("OrbStack auto-install failed. Install it manually from https://orbstack.dev/download"); + logInfo("(or, if you have Homebrew: brew install orbstack), then rerun this command."); process.exit(1); } } else {