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": "0.30.0",
"version": "0.30.1",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
41 changes: 25 additions & 16 deletions packages/cli/src/__tests__/sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ describe("ensureDocker", () => {
spy.mockRestore();
});

it("attempts brew install on macOS when docker unavailable", async () => {
it("attempts brew install on macOS when docker not installed", async () => {
const origPlatform = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", {
value: "darwin",
Expand All @@ -82,19 +82,7 @@ describe("ensureDocker", () => {
let callCount = 0;
const spy = spyOn(Bun, "spawnSync").mockImplementation((..._args: unknown[]) => {
callCount++;
// First call: docker info → fail, second: brew install → succeed, third: docker info → succeed
if (callCount === 1) {
return {
exitCode: 1,
stdout: new Uint8Array(),
stderr: new Uint8Array(),
success: false,
signalCode: null,
resourceUsage: undefined,
pid: 1234,
} satisfies ReturnType<typeof Bun.spawnSync>;
}
return {
const ok = {
exitCode: 0,
stdout: new Uint8Array(),
stderr: new Uint8Array(),
Expand All @@ -103,16 +91,37 @@ describe("ensureDocker", () => {
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>;
// 1: docker info → fail, 2: which docker → fail (not installed),
// 3: brew install → ok, 4: open -a OrbStack → ok, 5: docker info → ok
if (callCount <= 2) {
return fail;
}
return ok;
});

await ensureDocker();

// Second call should be brew install orbstack
expect(spy.mock.calls[1][0]).toEqual([
// Call 1: docker info, 2: which docker, 3: brew install orbstack
expect(spy.mock.calls[2][0]).toEqual([
"brew",
"install",
"orbstack",
]);
// Call 4: open -a OrbStack (starts daemon)
expect(spy.mock.calls[3][0]).toEqual([
"open",
"-a",
"OrbStack",
]);

spy.mockRestore();
if (origPlatform) {
Expand Down
133 changes: 114 additions & 19 deletions packages/cli/src/local/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,31 +68,129 @@ export async function interactiveSession(cmd: string): Promise<number> {

// ─── Docker Sandbox ─────────────────────────────────────────────────────────

/** Check whether Docker (or OrbStack) is available on the host. */
/** Check whether the Docker daemon is running and responsive. */
export function isDockerAvailable(): boolean {
const result = Bun.spawnSync(
[
"docker",
"info",
],
{
stdio: [
"ignore",
"ignore",
"ignore",
return (
Bun.spawnSync(
[
"docker",
"info",
],
},
{
stdio: [
"ignore",
"ignore",
"ignore",
],
},
).exitCode === 0
);
return result.exitCode === 0;
}

/** Install Docker if not present, or exit with guidance if install fails. */
/** Check whether the docker binary exists (installed but daemon may be stopped). */
function isDockerInstalled(): boolean {
return (
Bun.spawnSync(
[
"which",
"docker",
],
{
stdio: [
"ignore",
"ignore",
"ignore",
],
},
).exitCode === 0
);
}

/** Try to start the Docker daemon and wait up to 30s for it to respond. */
function startAndWaitForDocker(isMac: boolean): void {
if (isMac) {
logStep("Starting OrbStack...");
Bun.spawnSync(
[
"open",
"-a",
"OrbStack",
],
{
stdio: [
"ignore",
"ignore",
"ignore",
],
},
);
} else {
logStep("Starting Docker daemon...");
const hasSudo =
Bun.spawnSync(
[
"which",
"sudo",
],
{
stdio: [
"ignore",
"ignore",
"ignore",
],
},
).exitCode === 0;
if (hasSudo) {
Bun.spawnSync(
[
"sudo",
"systemctl",
"start",
"docker",
],
{
stdio: [
"ignore",
"inherit",
"inherit",
],
},
);
}
}

// Wait up to 30s for the daemon to be ready
logStep("Waiting for Docker daemon...");
for (let i = 0; i < 30; i++) {
if (isDockerAvailable()) {
logInfo("Docker is ready");
return;
}
Bun.sleepSync(1000);
}
logInfo("Docker daemon did not start within 30s.");
if (isMac) {
logInfo("Open OrbStack.app manually, then retry.");
}
process.exit(1);
}

/** Ensure Docker is installed and the daemon is running. Installs and starts if needed. */
export async function ensureDocker(): Promise<void> {
// Fast path: daemon already running
if (isDockerAvailable()) {
return;
}

const isMac = process.platform === "darwin";

// Docker binary exists but daemon not running — just start it
if (isDockerInstalled()) {
startAndWaitForDocker(isMac);
return;
}

// Not installed at all — install first
if (isMac) {
logStep("Docker not found — installing OrbStack...");
const result = Bun.spawnSync(
Expand Down Expand Up @@ -150,11 +248,8 @@ export async function ensureDocker(): Promise<void> {
}
}

// Verify Docker works after install
if (!isDockerAvailable()) {
logInfo("Docker installed but not responding. You may need to start the Docker daemon.");
process.exit(1);
}
// Start the daemon after fresh install
startAndWaitForDocker(isMac);
}

/** Pull the agent Docker image and start a container. */
Expand Down
Loading