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
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "1.0.26",
"version": "1.0.27",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
105 changes: 100 additions & 5 deletions packages/cli/src/__tests__/sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -112,7 +112,8 @@ describe("ensureDocker", () => {
pid: 1234,
} satisfies ReturnType<typeof Bun.spawnSync>;
// 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;
}
Expand All @@ -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",
Expand All @@ -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<typeof Bun.spawnSync>;
const fail = {
exitCode: 1,
stdout: new Uint8Array(),
stderr: new Uint8Array(),
success: false,
signalCode: null,
resourceUsage: undefined,
pid: 1234,
} satisfies ReturnType<typeof Bun.spawnSync>;

// 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 ──────────────────────────────────────────────────
Expand Down
215 changes: 197 additions & 18 deletions packages/cli/src/local/local.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -261,23 +432,31 @@ export async function ensureDocker(): Promise<void> {

// 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 {
Expand Down
Loading