diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts
index 5d244ba02..8787b70f5 100644
--- a/packages/app/e2e/actions.ts
+++ b/packages/app/e2e/actions.ts
@@ -3,7 +3,7 @@ import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { execSync } from "node:child_process"
-import { modKey, serverUrl } from "./utils"
+import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
import {
dropdownMenuTriggerSelector,
dropdownMenuContentSelector,
@@ -18,7 +18,6 @@ import {
workspaceItemSelector,
workspaceMenuTriggerSelector,
} from "./selectors"
-import type { createSdk } from "./utils"
export async function defocus(page: Page) {
await page
@@ -190,7 +189,7 @@ export async function createTestProject() {
stdio: "ignore",
})
- return root
+ return resolveDirectory(root)
}
export async function cleanupTestProject(directory: string) {
diff --git a/packages/app/e2e/projects/workspace-new-session.spec.ts b/packages/app/e2e/projects/workspace-new-session.spec.ts
index f33972cc3..cb1294259 100644
--- a/packages/app/e2e/projects/workspace-new-session.spec.ts
+++ b/packages/app/e2e/projects/workspace-new-session.spec.ts
@@ -9,6 +9,26 @@ function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
}
+async function waitSlug(page: Page, skip: string[] = []) {
+ let prev = ""
+ await expect
+ .poll(
+ () => {
+ const slug = slugFromUrl(page.url())
+ if (!slug) return ""
+ if (skip.includes(slug)) return ""
+ if (slug !== prev) {
+ prev = slug
+ return ""
+ }
+ return slug
+ },
+ { timeout: 45_000 },
+ )
+ .not.toBe("")
+ return slugFromUrl(page.url())
+}
+
async function waitWorkspaceReady(page: Page, slug: string) {
await openSidebar(page)
await expect
@@ -31,20 +51,7 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
await openSidebar(page)
await page.getByRole("button", { name: "New workspace" }).first().click()
- await expect
- .poll(
- () => {
- const slug = slugFromUrl(page.url())
- if (!slug) return ""
- if (slug === root) return ""
- if (seen.includes(slug)) return ""
- return slug
- },
- { timeout: 45_000 },
- )
- .not.toBe("")
-
- const slug = slugFromUrl(page.url())
+ const slug = await waitSlug(page, [root, ...seen])
const directory = base64Decode(slug)
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
return { slug, directory }
@@ -60,12 +67,13 @@ async function openWorkspaceNewSession(page: Page, slug: string) {
await expect(button).toBeVisible()
await button.click({ force: true })
- await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
- await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:[/?#]|$)`))
+ const next = await waitSlug(page)
+ await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
+ return next
}
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
- await openWorkspaceNewSession(page, slug)
+ const next = await openWorkspaceNewSession(page, slug)
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
@@ -76,13 +84,13 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
await prompt.press("Enter")
- await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
+ await expect.poll(() => slugFromUrl(page.url())).toBe(next)
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
const sessionID = sessionIDFromUrl(page.url())
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
- await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:[/?#]|$)`))
- return sessionID
+ await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`))
+ return { sessionID, slug: next }
}
async function sessionDirectory(directory: string, sessionID: string) {
@@ -114,17 +122,17 @@ test("new sessions from sidebar workspace actions stay in selected workspace", a
await waitWorkspaceReady(page, second.slug)
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
- sessions.push(firstSession)
+ sessions.push(firstSession.sessionID)
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
- sessions.push(secondSession)
+ sessions.push(secondSession.sessionID)
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
- sessions.push(thirdSession)
+ sessions.push(thirdSession.sessionID)
- await expect.poll(() => sessionDirectory(first.directory, firstSession)).toBe(first.directory)
- await expect.poll(() => sessionDirectory(second.directory, secondSession)).toBe(second.directory)
- await expect.poll(() => sessionDirectory(first.directory, thirdSession)).toBe(first.directory)
+ await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
+ await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
+ await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
} finally {
const dirs = [directory, ...workspaces.map((workspace) => workspace.directory)]
await Promise.all(
diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts
index 41c6bea8f..805b45e98 100644
--- a/packages/app/e2e/projects/workspaces.spec.ts
+++ b/packages/app/e2e/projects/workspaces.spec.ts
@@ -22,6 +22,26 @@ function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
}
+async function waitSlug(page: Page, skip: string[] = []) {
+ let prev = ""
+ await expect
+ .poll(
+ () => {
+ const slug = slugFromUrl(page.url())
+ if (!slug) return ""
+ if (skip.includes(slug)) return ""
+ if (slug !== prev) {
+ prev = slug
+ return ""
+ }
+ return slug
+ },
+ { timeout: 45_000 },
+ )
+ .not.toBe("")
+ return slugFromUrl(page.url())
+}
+
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
const rootSlug = project.slug
await openSidebar(page)
@@ -29,17 +49,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
await setWorkspacesEnabled(page, rootSlug, true)
await page.getByRole("button", { name: "New workspace" }).first().click()
- await expect
- .poll(
- () => {
- const slug = slugFromUrl(page.url())
- return slug.length > 0 && slug !== rootSlug
- },
- { timeout: 45_000 },
- )
- .toBe(true)
-
- const slug = slugFromUrl(page.url())
+ const slug = await waitSlug(page, [rootSlug])
const dir = base64Decode(slug)
await openSidebar(page)
@@ -91,18 +101,7 @@ test("can create a workspace", async ({ page, withProject }) => {
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await page.getByRole("button", { name: "New workspace" }).first().click()
-
- await expect
- .poll(
- () => {
- const currentSlug = slugFromUrl(page.url())
- return currentSlug.length > 0 && currentSlug !== slug
- },
- { timeout: 45_000 },
- )
- .toBe(true)
-
- const workspaceSlug = slugFromUrl(page.url())
+ const workspaceSlug = await waitSlug(page, [slug])
const workspaceDir = base64Decode(workspaceSlug)
await openSidebar(page)
@@ -279,7 +278,7 @@ test("can delete a workspace", async ({ page, withProject }) => {
await clickMenuItem(menu, /^Delete$/i, { force: true })
await confirmDialog(page, /^Delete workspace$/i)
- await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
+ await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
await expect
.poll(
diff --git a/packages/app/e2e/utils.ts b/packages/app/e2e/utils.ts
index e2d61984d..c5bbba9d8 100644
--- a/packages/app/e2e/utils.ts
+++ b/packages/app/e2e/utils.ts
@@ -14,6 +14,12 @@ export function createSdk(directory?: string) {
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
}
+export async function resolveDirectory(directory: string) {
+ return createSdk(directory)
+ .path.get()
+ .then((x) => x.data?.directory ?? directory)
+}
+
export async function getWorktree() {
const sdk = createSdk()
const result = await sdk.path.get()
diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx
index 71b52180f..fdf321f2d 100644
--- a/packages/app/src/pages/directory-layout.tsx
+++ b/packages/app/src/pages/directory-layout.tsx
@@ -1,26 +1,27 @@
-import { createEffect, createMemo, Show, type ParentProps } from "solid-js"
+import { batch, createEffect, createMemo, Show, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
-import { useNavigate, useParams } from "@solidjs/router"
+import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { SDKProvider } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync"
import { LocalProvider } from "@/context/local"
+import { useGlobalSDK } from "@/context/global-sdk"
import { DataProvider } from "@opencode-ai/ui/context"
+import { base64Encode } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64"
import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language"
-
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
- const params = useParams()
const navigate = useNavigate()
const sync = useSync()
+ const slug = createMemo(() => base64Encode(props.directory))
return (
navigate(`/${params.dir}/session/${sessionID}`)}
- onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`}
+ onNavigateToSession={(sessionID: string) => navigate(`/${slug()}/session/${sessionID}`)}
+ onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`}
>
{props.children}
@@ -30,31 +31,63 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
export default function Layout(props: ParentProps) {
const params = useParams()
const navigate = useNavigate()
+ const location = useLocation()
const language = useLanguage()
- const [store, setStore] = createStore({ invalid: "" })
- const directory = createMemo(() => {
- return decode64(params.dir) ?? ""
- })
+ const globalSDK = useGlobalSDK()
+ const directory = createMemo(() => decode64(params.dir) ?? "")
+ const [state, setState] = createStore({ invalid: "", resolved: "" })
createEffect(() => {
if (!params.dir) return
- if (directory()) return
- if (store.invalid === params.dir) return
- setStore("invalid", params.dir)
- showToast({
- variant: "error",
- title: language.t("common.requestFailed"),
- description: language.t("directory.error.invalidUrl"),
- })
- navigate("/", { replace: true })
+ const raw = directory()
+ if (!raw) {
+ if (state.invalid === params.dir) return
+ setState("invalid", params.dir)
+ showToast({
+ variant: "error",
+ title: language.t("common.requestFailed"),
+ description: language.t("directory.error.invalidUrl"),
+ })
+ navigate("/", { replace: true })
+ return
+ }
+
+ const current = params.dir
+ globalSDK
+ .createClient({
+ directory: raw,
+ throwOnError: true,
+ })
+ .path.get()
+ .then((x) => {
+ if (params.dir !== current) return
+ const next = x.data?.directory ?? raw
+ batch(() => {
+ setState("invalid", "")
+ setState("resolved", next)
+ })
+ if (next === raw) return
+ const path = location.pathname.slice(current.length + 1)
+ navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
+ })
+ .catch(() => {
+ if (params.dir !== current) return
+ batch(() => {
+ setState("invalid", "")
+ setState("resolved", raw)
+ })
+ })
})
+
return (
-
-
-
- {props.children}
-
-
+
+ {(resolved) => (
+
+
+ {props.children}
+
+
+ )}
)
}
diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts
index f53cc3925..f778c96a8 100644
--- a/packages/opencode/src/cli/cmd/tui/thread.ts
+++ b/packages/opencode/src/cli/cmd/tui/thread.ts
@@ -111,8 +111,10 @@ export const TuiThreadCommand = cmd({
}
// Resolve relative paths against PWD to preserve behavior when using --cwd flag
- const root = process.env.PWD ?? process.cwd()
- const cwd = args.project ? path.resolve(root, args.project) : process.cwd()
+ const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
+ const cwd = args.project
+ ? Filesystem.resolve(path.isAbsolute(args.project) ? args.project : path.join(root, args.project))
+ : root
const file = await target()
try {
process.chdir(cwd)
diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts
index 59a896e77..df44a3a22 100644
--- a/packages/opencode/src/project/instance.ts
+++ b/packages/opencode/src/project/instance.ts
@@ -62,13 +62,14 @@ function track(directory: string, next: Promise) {
export const Instance = {
async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise {
- let existing = cache.get(input.directory)
+ const directory = Filesystem.resolve(input.directory)
+ let existing = cache.get(directory)
if (!existing) {
- Log.Default.info("creating instance", { directory: input.directory })
+ Log.Default.info("creating instance", { directory })
existing = track(
- input.directory,
+ directory,
boot({
- directory: input.directory,
+ directory,
init: input.init,
}),
)
@@ -103,11 +104,12 @@ export const Instance = {
return State.create(() => Instance.directory, init, dispose)
},
async reload(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) {
- Log.Default.info("reloading instance", { directory: input.directory })
- await State.dispose(input.directory)
- cache.delete(input.directory)
- const next = track(input.directory, boot(input))
- emit(input.directory)
+ const directory = Filesystem.resolve(input.directory)
+ Log.Default.info("reloading instance", { directory })
+ await State.dispose(directory)
+ cache.delete(directory)
+ const next = track(directory, boot({ ...input, directory }))
+ emit(directory)
return await next
},
async dispose() {
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 6ea66be98..e353198af 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -38,6 +38,7 @@ import type { ContentfulStatusCode } from "hono/utils/http-status"
import { websocket } from "hono/bun"
import { HTTPException } from "hono/http-exception"
import { errors } from "./error"
+import { Filesystem } from "@/util/filesystem"
import { QuestionRoutes } from "./routes/question"
import { PermissionRoutes } from "./routes/permission"
import { GlobalRoutes } from "./routes/global"
@@ -198,13 +199,15 @@ export namespace Server {
if (c.req.path === "/log") return next()
const workspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace")
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
- const directory = (() => {
- try {
- return decodeURIComponent(raw)
- } catch {
- return raw
- }
- })()
+ const directory = Filesystem.resolve(
+ (() => {
+ try {
+ return decodeURIComponent(raw)
+ } catch {
+ return raw
+ }
+ })(),
+ )
return WorkspaceContext.provide({
workspaceID,
diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts
index a87aaeb98..fb1f5ab9e 100644
--- a/packages/opencode/src/util/filesystem.ts
+++ b/packages/opencode/src/util/filesystem.ts
@@ -2,7 +2,7 @@ import { chmod, mkdir, readFile, writeFile } from "fs/promises"
import { createWriteStream, existsSync, statSync } from "fs"
import { lookup } from "mime-types"
import { realpathSync } from "fs"
-import { dirname, join, relative } from "path"
+import { dirname, join, relative, resolve as pathResolve } from "path"
import { Readable } from "stream"
import { pipeline } from "stream/promises"
import { Glob } from "./glob"
@@ -113,16 +113,22 @@ export namespace Filesystem {
}
}
+ // We cannot rely on path.resolve() here because git.exe may come from Git Bash, Cygwin, or MSYS2, so we need to translate these paths at the boundary.
+ export function resolve(p: string): string {
+ return normalizePath(pathResolve(windowsPath(p)))
+ }
+
export function windowsPath(p: string): string {
if (process.platform !== "win32") return p
return (
p
+ .replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
// Git Bash for Windows paths are typically //...
- .replace(/^\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`)
+ .replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
// Cygwin git paths are typically /cygdrive//...
- .replace(/^\/cygdrive\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`)
+ .replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
// WSL paths are typically /mnt//...
- .replace(/^\/mnt\/([a-zA-Z])\//, (_, drive) => `${drive.toUpperCase()}:/`)
+ .replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
)
}
export function overlaps(a: string, b: string) {
diff --git a/packages/opencode/src/util/which.ts b/packages/opencode/src/util/which.ts
index 78e651e8e..81da25721 100644
--- a/packages/opencode/src/util/which.ts
+++ b/packages/opencode/src/util/which.ts
@@ -3,8 +3,8 @@ import whichPkg from "which"
export function which(cmd: string, env?: NodeJS.ProcessEnv) {
const result = whichPkg.sync(cmd, {
nothrow: true,
- path: env?.PATH,
- pathExt: env?.PATHEXT,
+ path: env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path,
+ pathExt: env?.PATHEXT ?? env?.PathExt ?? process.env.PATHEXT ?? process.env.PathExt,
})
return typeof result === "string" ? result : null
}
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
index 40ab97449..96fac8cca 100644
--- a/packages/opencode/test/config/config.test.ts
+++ b/packages/opencode/test/config/config.test.ts
@@ -25,6 +25,34 @@ async function writeConfig(dir: string, config: object, name = "opencode.json")
await Filesystem.write(path.join(dir, name), JSON.stringify(config))
}
+async function check(map: (dir: string) => string) {
+ if (process.platform !== "win32") return
+ await using globalTmp = await tmpdir()
+ await using tmp = await tmpdir({ git: true, config: { snapshot: true } })
+ const prev = Global.Path.config
+ ;(Global.Path as { config: string }).config = globalTmp.path
+ Config.global.reset()
+ try {
+ await writeConfig(globalTmp.path, {
+ $schema: "https://opencode.ai/config.json",
+ snapshot: false,
+ })
+ await Instance.provide({
+ directory: map(tmp.path),
+ fn: async () => {
+ const cfg = await Config.get()
+ expect(cfg.snapshot).toBe(true)
+ expect(Instance.directory).toBe(Filesystem.resolve(tmp.path))
+ expect(Instance.project.id).not.toBe("global")
+ },
+ })
+ } finally {
+ await Instance.disposeAll()
+ ;(Global.Path as { config: string }).config = prev
+ Config.global.reset()
+ }
+}
+
test("loads config with defaults when no files exist", async () => {
await using tmp = await tmpdir()
await Instance.provide({
@@ -56,6 +84,23 @@ test("loads JSON config file", async () => {
})
})
+test("loads project config from Git Bash and MSYS2 paths on Windows", async () => {
+ // Git Bash and MSYS2 both use //... paths on Windows.
+ await check((dir) => {
+ const drive = dir[0].toLowerCase()
+ const rest = dir.slice(2).replaceAll("\\", "/")
+ return `/${drive}${rest}`
+ })
+})
+
+test("loads project config from Cygwin paths on Windows", async () => {
+ await check((dir) => {
+ const drive = dir[0].toLowerCase()
+ const rest = dir.slice(2).replaceAll("\\", "/")
+ return `/cygdrive/${drive}${rest}`
+ })
+})
+
test("ignores legacy tui keys in opencode config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts
index a6255db88..c757e3424 100644
--- a/packages/opencode/test/util/filesystem.test.ts
+++ b/packages/opencode/test/util/filesystem.test.ts
@@ -440,4 +440,67 @@ describe("filesystem", () => {
expect(await fs.readFile(filepath, "utf-8")).toBe(content)
})
})
+
+ describe("resolve()", () => {
+ test("resolves slash-prefixed drive paths on Windows", async () => {
+ if (process.platform !== "win32") return
+ await using tmp = await tmpdir()
+ const forward = tmp.path.replaceAll("\\", "/")
+ expect(Filesystem.resolve(`/${forward}`)).toBe(Filesystem.normalizePath(tmp.path))
+ })
+
+ test("resolves slash-prefixed drive roots on Windows", async () => {
+ if (process.platform !== "win32") return
+ await using tmp = await tmpdir()
+ const drive = tmp.path[0].toUpperCase()
+ expect(Filesystem.resolve(`/${drive}:`)).toBe(Filesystem.resolve(`${drive}:/`))
+ })
+
+ test("resolves Git Bash and MSYS2 paths on Windows", async () => {
+ // Git Bash and MSYS2 both use //... paths on Windows.
+ if (process.platform !== "win32") return
+ await using tmp = await tmpdir()
+ const drive = tmp.path[0].toLowerCase()
+ const rest = tmp.path.slice(2).replaceAll("\\", "/")
+ expect(Filesystem.resolve(`/${drive}${rest}`)).toBe(Filesystem.normalizePath(tmp.path))
+ })
+
+ test("resolves Git Bash and MSYS2 drive roots on Windows", async () => {
+ // Git Bash and MSYS2 both use / paths on Windows.
+ if (process.platform !== "win32") return
+ await using tmp = await tmpdir()
+ const drive = tmp.path[0].toLowerCase()
+ expect(Filesystem.resolve(`/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`))
+ })
+
+ test("resolves Cygwin paths on Windows", async () => {
+ if (process.platform !== "win32") return
+ await using tmp = await tmpdir()
+ const drive = tmp.path[0].toLowerCase()
+ const rest = tmp.path.slice(2).replaceAll("\\", "/")
+ expect(Filesystem.resolve(`/cygdrive/${drive}${rest}`)).toBe(Filesystem.normalizePath(tmp.path))
+ })
+
+ test("resolves Cygwin drive roots on Windows", async () => {
+ if (process.platform !== "win32") return
+ await using tmp = await tmpdir()
+ const drive = tmp.path[0].toLowerCase()
+ expect(Filesystem.resolve(`/cygdrive/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`))
+ })
+
+ test("resolves WSL mount paths on Windows", async () => {
+ if (process.platform !== "win32") return
+ await using tmp = await tmpdir()
+ const drive = tmp.path[0].toLowerCase()
+ const rest = tmp.path.slice(2).replaceAll("\\", "/")
+ expect(Filesystem.resolve(`/mnt/${drive}${rest}`)).toBe(Filesystem.normalizePath(tmp.path))
+ })
+
+ test("resolves WSL mount roots on Windows", async () => {
+ if (process.platform !== "win32") return
+ await using tmp = await tmpdir()
+ const drive = tmp.path[0].toLowerCase()
+ expect(Filesystem.resolve(`/mnt/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`))
+ })
+ })
})
diff --git a/packages/opencode/test/util/which.test.ts b/packages/opencode/test/util/which.test.ts
index 323173b18..70c2fb2d9 100644
--- a/packages/opencode/test/util/which.test.ts
+++ b/packages/opencode/test/util/which.test.ts
@@ -22,6 +22,13 @@ function env(PATH: string): NodeJS.ProcessEnv {
}
}
+function envPath(Path: string): NodeJS.ProcessEnv {
+ return {
+ Path,
+ PathExt: process.env["PathExt"] ?? process.env["PATHEXT"],
+ }
+}
+
function same(a: string | null, b: string) {
if (process.platform === "win32") {
expect(a?.toLowerCase()).toBe(b.toLowerCase())
@@ -79,4 +86,15 @@ describe("util.which", () => {
expect(which("pathext", { PATH: bin, PATHEXT: ".CMD" })).toBe(file)
})
+
+ test("uses Windows Path casing fallback", async () => {
+ if (process.platform !== "win32") return
+
+ await using tmp = await tmpdir()
+ const bin = path.join(tmp.path, "bin")
+ await fs.mkdir(bin)
+ const file = await cmd(bin, "mixed")
+
+ same(which("mixed", envPath(bin)), file)
+ })
})