diff --git a/src/cli/run/opencode-bin-path.test.ts b/src/cli/run/opencode-bin-path.test.ts new file mode 100644 index 00000000..ad3350da --- /dev/null +++ b/src/cli/run/opencode-bin-path.test.ts @@ -0,0 +1,52 @@ +/// + +import { describe, expect, it } from "bun:test" +import { prependResolvedOpencodeBinToPath } from "./opencode-bin-path" + +describe("prependResolvedOpencodeBinToPath", () => { + it("prepends resolved opencode-ai bin path to PATH", () => { + //#given + const env: Record = { + PATH: "/Users/yeongyu/node_modules/.bin:/usr/bin", + } + const resolver = () => "/tmp/bunx-123/node_modules/opencode-ai/bin/opencode" + + //#when + prependResolvedOpencodeBinToPath(env, resolver) + + //#then + expect(env.PATH).toBe( + "/tmp/bunx-123/node_modules/opencode-ai/bin:/Users/yeongyu/node_modules/.bin:/usr/bin", + ) + }) + + it("does not duplicate an existing opencode-ai bin path", () => { + //#given + const env: Record = { + PATH: "/tmp/bunx-123/node_modules/opencode-ai/bin:/usr/bin", + } + const resolver = () => "/tmp/bunx-123/node_modules/opencode-ai/bin/opencode" + + //#when + prependResolvedOpencodeBinToPath(env, resolver) + + //#then + expect(env.PATH).toBe("/tmp/bunx-123/node_modules/opencode-ai/bin:/usr/bin") + }) + + it("keeps PATH unchanged when opencode-ai cannot be resolved", () => { + //#given + const env: Record = { + PATH: "/Users/yeongyu/node_modules/.bin:/usr/bin", + } + const resolver = () => { + throw new Error("module not found") + } + + //#when + prependResolvedOpencodeBinToPath(env, resolver) + + //#then + expect(env.PATH).toBe("/Users/yeongyu/node_modules/.bin:/usr/bin") + }) +}) diff --git a/src/cli/run/opencode-bin-path.ts b/src/cli/run/opencode-bin-path.ts new file mode 100644 index 00000000..1a625cd9 --- /dev/null +++ b/src/cli/run/opencode-bin-path.ts @@ -0,0 +1,30 @@ +import { delimiter, dirname } from "node:path" +import { createRequire } from "node:module" + +type EnvLike = Record + +const resolveFromCurrentModule = createRequire(import.meta.url).resolve + +export function prependResolvedOpencodeBinToPath( + env: EnvLike = process.env, + resolve: (id: string) => string = resolveFromCurrentModule, +): void { + let resolvedPath: string + try { + resolvedPath = resolve("opencode-ai/bin/opencode") + } catch { + return + } + + const opencodeBinDir = dirname(resolvedPath) + const currentPath = env.PATH ?? "" + const pathSegments = currentPath ? currentPath.split(delimiter) : [] + + if (pathSegments.includes(opencodeBinDir)) { + return + } + + env.PATH = currentPath + ? `${opencodeBinDir}${delimiter}${currentPath}` + : opencodeBinDir +} diff --git a/src/cli/run/opencode-binary-resolver.test.ts b/src/cli/run/opencode-binary-resolver.test.ts new file mode 100644 index 00000000..9cd55b7d --- /dev/null +++ b/src/cli/run/opencode-binary-resolver.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "bun:test" +import { delimiter, join } from "node:path" +import { + buildPathWithBinaryFirst, + collectCandidateBinaryPaths, + findWorkingOpencodeBinary, + withWorkingOpencodePath, +} from "./opencode-binary-resolver" + +describe("collectCandidateBinaryPaths", () => { + it("includes Bun.which results first and removes duplicates", () => { + // given + const pathEnv = ["/bad", "/good"].join(delimiter) + const which = (command: string): string | undefined => { + if (command === "opencode") return "/bad/opencode" + return undefined + } + + // when + const candidates = collectCandidateBinaryPaths(pathEnv, which, "darwin") + + // then + expect(candidates[0]).toBe("/bad/opencode") + expect(candidates).toContain("/good/opencode") + expect(candidates.filter((candidate) => candidate === "/bad/opencode")).toHaveLength(1) + }) +}) + +describe("findWorkingOpencodeBinary", () => { + it("returns the first runnable candidate", async () => { + // given + const pathEnv = ["/bad", "/good"].join(delimiter) + const which = (command: string): string | undefined => { + if (command === "opencode") return "/bad/opencode" + return undefined + } + const probe = async (binaryPath: string): Promise => + binaryPath === "/good/opencode" + + // when + const resolved = await findWorkingOpencodeBinary(pathEnv, probe, which, "darwin") + + // then + expect(resolved).toBe("/good/opencode") + }) +}) + +describe("buildPathWithBinaryFirst", () => { + it("prepends the binary directory and avoids duplicate entries", () => { + // given + const binaryPath = "/good/opencode" + const pathEnv = ["/bad", "/good", "/other"].join(delimiter) + + // when + const updated = buildPathWithBinaryFirst(pathEnv, binaryPath) + + // then + expect(updated).toBe(["/good", "/bad", "/other"].join(delimiter)) + }) +}) + +describe("withWorkingOpencodePath", () => { + it("temporarily updates PATH while starting the server", async () => { + // given + const originalPath = process.env.PATH + process.env.PATH = ["/bad", "/other"].join(delimiter) + const finder = async (): Promise => "/good/opencode" + let observedPath = "" + + // when + await withWorkingOpencodePath( + async () => { + observedPath = process.env.PATH ?? "" + }, + finder, + ) + + // then + expect(observedPath).toBe(["/good", "/bad", "/other"].join(delimiter)) + expect(process.env.PATH).toBe(["/bad", "/other"].join(delimiter)) + process.env.PATH = originalPath + }) + + it("restores PATH when server startup fails", async () => { + // given + const originalPath = process.env.PATH + process.env.PATH = ["/bad", "/other"].join(delimiter) + const finder = async (): Promise => join("/good", "opencode") + + // when & then + await expect( + withWorkingOpencodePath( + async () => { + throw new Error("boom") + }, + finder, + ), + ).rejects.toThrow("boom") + expect(process.env.PATH).toBe(["/bad", "/other"].join(delimiter)) + process.env.PATH = originalPath + }) +}) diff --git a/src/cli/run/opencode-binary-resolver.ts b/src/cli/run/opencode-binary-resolver.ts new file mode 100644 index 00000000..a4bbc60c --- /dev/null +++ b/src/cli/run/opencode-binary-resolver.ts @@ -0,0 +1,95 @@ +import { delimiter, dirname, join } from "node:path" + +const OPENCODE_COMMANDS = ["opencode", "opencode-desktop"] as const +const WINDOWS_SUFFIXES = ["", ".exe", ".cmd", ".bat", ".ps1"] as const + +function getCommandCandidates(platform: NodeJS.Platform): string[] { + if (platform !== "win32") return [...OPENCODE_COMMANDS] + + return OPENCODE_COMMANDS.flatMap((command) => + WINDOWS_SUFFIXES.map((suffix) => `${command}${suffix}`), + ) +} + +export function collectCandidateBinaryPaths( + pathEnv: string | undefined, + which: (command: string) => string | null | undefined = Bun.which, + platform: NodeJS.Platform = process.platform, +): string[] { + const seen = new Set() + const candidates: string[] = [] + const commandCandidates = getCommandCandidates(platform) + + const addCandidate = (binaryPath: string | undefined | null): void => { + if (!binaryPath || seen.has(binaryPath)) return + seen.add(binaryPath) + candidates.push(binaryPath) + } + + for (const command of commandCandidates) { + addCandidate(which(command)) + } + + for (const entry of (pathEnv ?? "").split(delimiter).filter(Boolean)) { + for (const command of commandCandidates) { + addCandidate(join(entry, command)) + } + } + + return candidates +} + +export async function canExecuteBinary(binaryPath: string): Promise { + try { + const proc = Bun.spawn([binaryPath, "--version"], { + stdout: "pipe", + stderr: "pipe", + }) + await proc.exited + return proc.exitCode === 0 + } catch { + return false + } +} + +export async function findWorkingOpencodeBinary( + pathEnv: string | undefined = process.env.PATH, + probe: (binaryPath: string) => Promise = canExecuteBinary, + which: (command: string) => string | null | undefined = Bun.which, + platform: NodeJS.Platform = process.platform, +): Promise { + const candidates = collectCandidateBinaryPaths(pathEnv, which, platform) + for (const candidate of candidates) { + if (await probe(candidate)) { + return candidate + } + } + return null +} + +export function buildPathWithBinaryFirst(pathEnv: string | undefined, binaryPath: string): string { + const preferredDir = dirname(binaryPath) + const existing = (pathEnv ?? "").split(delimiter).filter( + (entry) => entry.length > 0 && entry !== preferredDir, + ) + return [preferredDir, ...existing].join(delimiter) +} + +export async function withWorkingOpencodePath( + startServer: () => Promise, + finder: (pathEnv: string | undefined) => Promise = findWorkingOpencodeBinary, +): Promise { + const originalPath = process.env.PATH + const binaryPath = await finder(originalPath) + + if (!binaryPath) { + return startServer() + } + + process.env.PATH = buildPathWithBinaryFirst(originalPath, binaryPath) + try { + return await startServer() + } finally { + process.env.PATH = originalPath + } +} diff --git a/src/cli/run/server-connection.test.ts b/src/cli/run/server-connection.test.ts index 9dc94587..ad5e1aa9 100644 --- a/src/cli/run/server-connection.test.ts +++ b/src/cli/run/server-connection.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun import * as originalSdk from "@opencode-ai/sdk" import * as originalPortUtils from "../../shared/port-utils" +import * as originalBinaryResolver from "./opencode-binary-resolver" const originalConsole = globalThis.console @@ -16,6 +17,7 @@ const mockCreateOpencodeClient = mock(() => ({ session: {} })) const mockIsPortAvailable = mock(() => Promise.resolve(true)) const mockGetAvailableServerPort = mock(() => Promise.resolve({ port: 4096, wasAutoSelected: false })) const mockConsoleLog = mock(() => {}) +const mockWithWorkingOpencodePath = mock((startServer: () => Promise) => startServer()) mock.module("@opencode-ai/sdk", () => ({ createOpencode: mockCreateOpencode, @@ -28,9 +30,14 @@ mock.module("../../shared/port-utils", () => ({ DEFAULT_SERVER_PORT: 4096, })) +mock.module("./opencode-binary-resolver", () => ({ + withWorkingOpencodePath: mockWithWorkingOpencodePath, +})) + afterAll(() => { mock.module("@opencode-ai/sdk", () => originalSdk) mock.module("../../shared/port-utils", () => originalPortUtils) + mock.module("./opencode-binary-resolver", () => originalBinaryResolver) }) const { createServerConnection } = await import("./server-connection") @@ -43,6 +50,7 @@ describe("createServerConnection", () => { mockGetAvailableServerPort.mockClear() mockServerClose.mockClear() mockConsoleLog.mockClear() + mockWithWorkingOpencodePath.mockClear() globalThis.console = { ...console, log: mockConsoleLog } as typeof console }) @@ -60,6 +68,7 @@ describe("createServerConnection", () => { // then expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: attachUrl }) + expect(mockWithWorkingOpencodePath).not.toHaveBeenCalled() expect(result.client).toBeDefined() expect(result.cleanup).toBeDefined() result.cleanup() @@ -77,6 +86,7 @@ describe("createServerConnection", () => { // then expect(mockIsPortAvailable).toHaveBeenCalledWith(8080, "127.0.0.1") + expect(mockWithWorkingOpencodePath).toHaveBeenCalledTimes(1) expect(mockCreateOpencode).toHaveBeenCalledWith({ signal, port: 8080, hostname: "127.0.0.1" }) expect(mockCreateOpencodeClient).not.toHaveBeenCalled() expect(result.client).toBeDefined() @@ -114,6 +124,7 @@ describe("createServerConnection", () => { // then expect(mockGetAvailableServerPort).toHaveBeenCalledWith(4096, "127.0.0.1") + expect(mockWithWorkingOpencodePath).toHaveBeenCalledTimes(1) expect(mockCreateOpencode).toHaveBeenCalledWith({ signal, port: 4100, hostname: "127.0.0.1" }) expect(mockCreateOpencodeClient).not.toHaveBeenCalled() expect(result.client).toBeDefined() diff --git a/src/cli/run/server-connection.ts b/src/cli/run/server-connection.ts index 55b63b74..934d469d 100644 --- a/src/cli/run/server-connection.ts +++ b/src/cli/run/server-connection.ts @@ -2,12 +2,16 @@ import { createOpencode, createOpencodeClient } from "@opencode-ai/sdk" import pc from "picocolors" import type { ServerConnection } from "./types" import { getAvailableServerPort, isPortAvailable, DEFAULT_SERVER_PORT } from "../../shared/port-utils" +import { withWorkingOpencodePath } from "./opencode-binary-resolver" +import { prependResolvedOpencodeBinToPath } from "./opencode-bin-path" export async function createServerConnection(options: { port?: number attach?: string signal: AbortSignal }): Promise { + prependResolvedOpencodeBinToPath() + const { port, attach, signal } = options if (attach !== undefined) { @@ -25,7 +29,9 @@ export async function createServerConnection(options: { if (available) { console.log(pc.dim("Starting server on port"), pc.cyan(port.toString())) - const { client, server } = await createOpencode({ signal, port, hostname: "127.0.0.1" }) + const { client, server } = await withWorkingOpencodePath(() => + createOpencode({ signal, port, hostname: "127.0.0.1" }), + ) console.log(pc.dim("Server listening at"), pc.cyan(server.url)) return { client, cleanup: () => server.close() } } @@ -41,7 +47,9 @@ export async function createServerConnection(options: { } else { console.log(pc.dim("Starting server on port"), pc.cyan(selectedPort.toString())) } - const { client, server } = await createOpencode({ signal, port: selectedPort, hostname: "127.0.0.1" }) + const { client, server } = await withWorkingOpencodePath(() => + createOpencode({ signal, port: selectedPort, hostname: "127.0.0.1" }), + ) console.log(pc.dim("Server listening at"), pc.cyan(server.url)) return { client, cleanup: () => server.close() } }