Explorar o código

fix(tui): canonicalize cwd after chdir (#16641)

Luke Parker hai 5 meses
pai
achega
e51ed460a6

+ 7 - 5
packages/opencode/src/cli/cmd/tui/thread.ts

@@ -110,18 +110,20 @@ export const TuiThreadCommand = cmd({
         return
       }
 
-      // Resolve relative paths against PWD to preserve behavior when using --cwd flag
+      // Resolve relative --project paths from PWD, then use the real cwd after
+      // chdir so the thread and worker share the same directory key.
       const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
-      const cwd = args.project
+      const next = args.project
         ? Filesystem.resolve(path.isAbsolute(args.project) ? args.project : path.join(root, args.project))
-        : root
+        : Filesystem.resolve(process.cwd())
       const file = await target()
       try {
-        process.chdir(cwd)
+        process.chdir(next)
       } catch {
-        UI.error("Failed to change directory to " + cwd)
+        UI.error("Failed to change directory to " + next)
         return
       }
+      const cwd = Filesystem.resolve(process.cwd())
 
       const worker = new Worker(file, {
         env: Object.fromEntries(

+ 157 - 0
packages/opencode/test/cli/tui/thread.test.ts

@@ -0,0 +1,157 @@
+import { describe, expect, mock, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { tmpdir } from "../../fixture/fixture"
+
+const stop = new Error("stop")
+const seen = {
+  tui: [] as string[],
+  inst: [] as string[],
+}
+
+mock.module("../../../src/cli/cmd/tui/app", () => ({
+  tui: async (input: { directory: string }) => {
+    seen.tui.push(input.directory)
+    throw stop
+  },
+}))
+
+mock.module("@/util/rpc", () => ({
+  Rpc: {
+    client: () => ({
+      call: async () => ({ url: "http://127.0.0.1" }),
+      on: () => {},
+    }),
+  },
+}))
+
+mock.module("@/cli/ui", () => ({
+  UI: {
+    error: () => {},
+  },
+}))
+
+mock.module("@/util/log", () => ({
+  Log: {
+    init: async () => {},
+    create: () => ({
+      error: () => {},
+      info: () => {},
+      warn: () => {},
+      debug: () => {},
+      time: () => ({ stop: () => {} }),
+    }),
+    Default: {
+      error: () => {},
+      info: () => {},
+      warn: () => {},
+      debug: () => {},
+    },
+  },
+}))
+
+mock.module("@/util/timeout", () => ({
+  withTimeout: <T>(input: Promise<T>) => input,
+}))
+
+mock.module("@/cli/network", () => ({
+  withNetworkOptions: <T>(input: T) => input,
+  resolveNetworkOptions: async () => ({
+    mdns: false,
+    port: 0,
+    hostname: "127.0.0.1",
+  }),
+}))
+
+mock.module("../../../src/cli/cmd/tui/win32", () => ({
+  win32DisableProcessedInput: () => {},
+  win32InstallCtrlCGuard: () => undefined,
+}))
+
+mock.module("@/config/tui", () => ({
+  TuiConfig: {
+    get: () => ({}),
+  },
+}))
+
+mock.module("@/project/instance", () => ({
+  Instance: {
+    provide: async (input: { directory: string; fn: () => Promise<unknown> | unknown }) => {
+      seen.inst.push(input.directory)
+      return input.fn()
+    },
+  },
+}))
+
+describe("tui thread", () => {
+  async function call(project?: string) {
+    const { TuiThreadCommand } = await import("../../../src/cli/cmd/tui/thread")
+    const args: Parameters<NonNullable<typeof TuiThreadCommand.handler>>[0] = {
+      _: [],
+      $0: "opencode",
+      project,
+      prompt: "hi",
+      model: undefined,
+      agent: undefined,
+      session: undefined,
+      continue: false,
+      fork: false,
+      port: 0,
+      hostname: "127.0.0.1",
+      mdns: false,
+      "mdns-domain": "opencode.local",
+      mdnsDomain: "opencode.local",
+      cors: [],
+    }
+    return TuiThreadCommand.handler(args)
+  }
+
+  async function check(project?: string) {
+    await using tmp = await tmpdir({ git: true })
+    const cwd = process.cwd()
+    const pwd = process.env.PWD
+    const worker = globalThis.Worker
+    const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY")
+    const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link")
+    const type = process.platform === "win32" ? "junction" : "dir"
+    seen.tui.length = 0
+    seen.inst.length = 0
+    await fs.symlink(tmp.path, link, type)
+
+    Object.defineProperty(process.stdin, "isTTY", {
+      configurable: true,
+      value: true,
+    })
+    globalThis.Worker = class extends EventTarget {
+      onerror = null
+      onmessage = null
+      onmessageerror = null
+      postMessage() {}
+      terminate() {}
+    } as unknown as typeof Worker
+
+    try {
+      process.chdir(tmp.path)
+      process.env.PWD = link
+      await expect(call(project)).rejects.toBe(stop)
+      expect(seen.inst[0]).toBe(tmp.path)
+      expect(seen.tui[0]).toBe(tmp.path)
+    } finally {
+      process.chdir(cwd)
+      if (pwd === undefined) delete process.env.PWD
+      else process.env.PWD = pwd
+      if (tty) Object.defineProperty(process.stdin, "isTTY", tty)
+      else delete (process.stdin as { isTTY?: boolean }).isTTY
+      globalThis.Worker = worker
+      await fs.rm(link, { recursive: true, force: true }).catch(() => undefined)
+    }
+  }
+
+  test("uses the real cwd when PWD points at a symlink", async () => {
+    await check()
+  })
+
+  test("uses the real cwd after resolving a relative project from PWD", async () => {
+    await check(".")
+  })
+})