fix: prefer a runnable opencode binary for cli run
This commit is contained in:
parent
7d2c798ff0
commit
d9f21da026
52
src/cli/run/opencode-bin-path.test.ts
Normal file
52
src/cli/run/opencode-bin-path.test.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
|
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<string, string | undefined> = {
|
||||||
|
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<string, string | undefined> = {
|
||||||
|
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<string, string | undefined> = {
|
||||||
|
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")
|
||||||
|
})
|
||||||
|
})
|
||||||
30
src/cli/run/opencode-bin-path.ts
Normal file
30
src/cli/run/opencode-bin-path.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { delimiter, dirname } from "node:path"
|
||||||
|
import { createRequire } from "node:module"
|
||||||
|
|
||||||
|
type EnvLike = Record<string, string | undefined>
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
102
src/cli/run/opencode-binary-resolver.test.ts
Normal file
102
src/cli/run/opencode-binary-resolver.test.ts
Normal file
@ -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<boolean> =>
|
||||||
|
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<string | null> => "/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<string | null> => 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
|
||||||
|
})
|
||||||
|
})
|
||||||
95
src/cli/run/opencode-binary-resolver.ts
Normal file
95
src/cli/run/opencode-binary-resolver.ts
Normal file
@ -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<string>()
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> = canExecuteBinary,
|
||||||
|
which: (command: string) => string | null | undefined = Bun.which,
|
||||||
|
platform: NodeJS.Platform = process.platform,
|
||||||
|
): Promise<string | null> {
|
||||||
|
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<T>(
|
||||||
|
startServer: () => Promise<T>,
|
||||||
|
finder: (pathEnv: string | undefined) => Promise<string | null> = findWorkingOpencodeBinary,
|
||||||
|
): Promise<T> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun
|
|||||||
|
|
||||||
import * as originalSdk from "@opencode-ai/sdk"
|
import * as originalSdk from "@opencode-ai/sdk"
|
||||||
import * as originalPortUtils from "../../shared/port-utils"
|
import * as originalPortUtils from "../../shared/port-utils"
|
||||||
|
import * as originalBinaryResolver from "./opencode-binary-resolver"
|
||||||
|
|
||||||
const originalConsole = globalThis.console
|
const originalConsole = globalThis.console
|
||||||
|
|
||||||
@ -16,6 +17,7 @@ const mockCreateOpencodeClient = mock(() => ({ session: {} }))
|
|||||||
const mockIsPortAvailable = mock(() => Promise.resolve(true))
|
const mockIsPortAvailable = mock(() => Promise.resolve(true))
|
||||||
const mockGetAvailableServerPort = mock(() => Promise.resolve({ port: 4096, wasAutoSelected: false }))
|
const mockGetAvailableServerPort = mock(() => Promise.resolve({ port: 4096, wasAutoSelected: false }))
|
||||||
const mockConsoleLog = mock(() => {})
|
const mockConsoleLog = mock(() => {})
|
||||||
|
const mockWithWorkingOpencodePath = mock((startServer: () => Promise<unknown>) => startServer())
|
||||||
|
|
||||||
mock.module("@opencode-ai/sdk", () => ({
|
mock.module("@opencode-ai/sdk", () => ({
|
||||||
createOpencode: mockCreateOpencode,
|
createOpencode: mockCreateOpencode,
|
||||||
@ -28,9 +30,14 @@ mock.module("../../shared/port-utils", () => ({
|
|||||||
DEFAULT_SERVER_PORT: 4096,
|
DEFAULT_SERVER_PORT: 4096,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
mock.module("./opencode-binary-resolver", () => ({
|
||||||
|
withWorkingOpencodePath: mockWithWorkingOpencodePath,
|
||||||
|
}))
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
mock.module("@opencode-ai/sdk", () => originalSdk)
|
mock.module("@opencode-ai/sdk", () => originalSdk)
|
||||||
mock.module("../../shared/port-utils", () => originalPortUtils)
|
mock.module("../../shared/port-utils", () => originalPortUtils)
|
||||||
|
mock.module("./opencode-binary-resolver", () => originalBinaryResolver)
|
||||||
})
|
})
|
||||||
|
|
||||||
const { createServerConnection } = await import("./server-connection")
|
const { createServerConnection } = await import("./server-connection")
|
||||||
@ -43,6 +50,7 @@ describe("createServerConnection", () => {
|
|||||||
mockGetAvailableServerPort.mockClear()
|
mockGetAvailableServerPort.mockClear()
|
||||||
mockServerClose.mockClear()
|
mockServerClose.mockClear()
|
||||||
mockConsoleLog.mockClear()
|
mockConsoleLog.mockClear()
|
||||||
|
mockWithWorkingOpencodePath.mockClear()
|
||||||
globalThis.console = { ...console, log: mockConsoleLog } as typeof console
|
globalThis.console = { ...console, log: mockConsoleLog } as typeof console
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -60,6 +68,7 @@ describe("createServerConnection", () => {
|
|||||||
|
|
||||||
// then
|
// then
|
||||||
expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: attachUrl })
|
expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: attachUrl })
|
||||||
|
expect(mockWithWorkingOpencodePath).not.toHaveBeenCalled()
|
||||||
expect(result.client).toBeDefined()
|
expect(result.client).toBeDefined()
|
||||||
expect(result.cleanup).toBeDefined()
|
expect(result.cleanup).toBeDefined()
|
||||||
result.cleanup()
|
result.cleanup()
|
||||||
@ -77,6 +86,7 @@ describe("createServerConnection", () => {
|
|||||||
|
|
||||||
// then
|
// then
|
||||||
expect(mockIsPortAvailable).toHaveBeenCalledWith(8080, "127.0.0.1")
|
expect(mockIsPortAvailable).toHaveBeenCalledWith(8080, "127.0.0.1")
|
||||||
|
expect(mockWithWorkingOpencodePath).toHaveBeenCalledTimes(1)
|
||||||
expect(mockCreateOpencode).toHaveBeenCalledWith({ signal, port: 8080, hostname: "127.0.0.1" })
|
expect(mockCreateOpencode).toHaveBeenCalledWith({ signal, port: 8080, hostname: "127.0.0.1" })
|
||||||
expect(mockCreateOpencodeClient).not.toHaveBeenCalled()
|
expect(mockCreateOpencodeClient).not.toHaveBeenCalled()
|
||||||
expect(result.client).toBeDefined()
|
expect(result.client).toBeDefined()
|
||||||
@ -114,6 +124,7 @@ describe("createServerConnection", () => {
|
|||||||
|
|
||||||
// then
|
// then
|
||||||
expect(mockGetAvailableServerPort).toHaveBeenCalledWith(4096, "127.0.0.1")
|
expect(mockGetAvailableServerPort).toHaveBeenCalledWith(4096, "127.0.0.1")
|
||||||
|
expect(mockWithWorkingOpencodePath).toHaveBeenCalledTimes(1)
|
||||||
expect(mockCreateOpencode).toHaveBeenCalledWith({ signal, port: 4100, hostname: "127.0.0.1" })
|
expect(mockCreateOpencode).toHaveBeenCalledWith({ signal, port: 4100, hostname: "127.0.0.1" })
|
||||||
expect(mockCreateOpencodeClient).not.toHaveBeenCalled()
|
expect(mockCreateOpencodeClient).not.toHaveBeenCalled()
|
||||||
expect(result.client).toBeDefined()
|
expect(result.client).toBeDefined()
|
||||||
|
|||||||
@ -2,12 +2,16 @@ import { createOpencode, createOpencodeClient } from "@opencode-ai/sdk"
|
|||||||
import pc from "picocolors"
|
import pc from "picocolors"
|
||||||
import type { ServerConnection } from "./types"
|
import type { ServerConnection } from "./types"
|
||||||
import { getAvailableServerPort, isPortAvailable, DEFAULT_SERVER_PORT } from "../../shared/port-utils"
|
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: {
|
export async function createServerConnection(options: {
|
||||||
port?: number
|
port?: number
|
||||||
attach?: string
|
attach?: string
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
}): Promise<ServerConnection> {
|
}): Promise<ServerConnection> {
|
||||||
|
prependResolvedOpencodeBinToPath()
|
||||||
|
|
||||||
const { port, attach, signal } = options
|
const { port, attach, signal } = options
|
||||||
|
|
||||||
if (attach !== undefined) {
|
if (attach !== undefined) {
|
||||||
@ -25,7 +29,9 @@ export async function createServerConnection(options: {
|
|||||||
|
|
||||||
if (available) {
|
if (available) {
|
||||||
console.log(pc.dim("Starting server on port"), pc.cyan(port.toString()))
|
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))
|
console.log(pc.dim("Server listening at"), pc.cyan(server.url))
|
||||||
return { client, cleanup: () => server.close() }
|
return { client, cleanup: () => server.close() }
|
||||||
}
|
}
|
||||||
@ -41,7 +47,9 @@ export async function createServerConnection(options: {
|
|||||||
} else {
|
} else {
|
||||||
console.log(pc.dim("Starting server on port"), pc.cyan(selectedPort.toString()))
|
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))
|
console.log(pc.dim("Server listening at"), pc.cyan(server.url))
|
||||||
return { client, cleanup: () => server.close() }
|
return { client, cleanup: () => server.close() }
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user