Skip to content

Commit e51ed46

Browse files
authored
fix(tui): canonicalize cwd after chdir (#16641)
1 parent d15c2ce commit e51ed46

2 files changed

Lines changed: 164 additions & 5 deletions

File tree

packages/opencode/src/cli/cmd/tui/thread.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,18 +110,20 @@ export const TuiThreadCommand = cmd({
110110
return
111111
}
112112

113-
// Resolve relative paths against PWD to preserve behavior when using --cwd flag
113+
// Resolve relative --project paths from PWD, then use the real cwd after
114+
// chdir so the thread and worker share the same directory key.
114115
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
115-
const cwd = args.project
116+
const next = args.project
116117
? Filesystem.resolve(path.isAbsolute(args.project) ? args.project : path.join(root, args.project))
117-
: root
118+
: Filesystem.resolve(process.cwd())
118119
const file = await target()
119120
try {
120-
process.chdir(cwd)
121+
process.chdir(next)
121122
} catch {
122-
UI.error("Failed to change directory to " + cwd)
123+
UI.error("Failed to change directory to " + next)
123124
return
124125
}
126+
const cwd = Filesystem.resolve(process.cwd())
125127

126128
const worker = new Worker(file, {
127129
env: Object.fromEntries(
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { describe, expect, mock, test } from "bun:test"
2+
import fs from "fs/promises"
3+
import path from "path"
4+
import { tmpdir } from "../../fixture/fixture"
5+
6+
const stop = new Error("stop")
7+
const seen = {
8+
tui: [] as string[],
9+
inst: [] as string[],
10+
}
11+
12+
mock.module("../../../src/cli/cmd/tui/app", () => ({
13+
tui: async (input: { directory: string }) => {
14+
seen.tui.push(input.directory)
15+
throw stop
16+
},
17+
}))
18+
19+
mock.module("@/util/rpc", () => ({
20+
Rpc: {
21+
client: () => ({
22+
call: async () => ({ url: "http://127.0.0.1" }),
23+
on: () => {},
24+
}),
25+
},
26+
}))
27+
28+
mock.module("@/cli/ui", () => ({
29+
UI: {
30+
error: () => {},
31+
},
32+
}))
33+
34+
mock.module("@/util/log", () => ({
35+
Log: {
36+
init: async () => {},
37+
create: () => ({
38+
error: () => {},
39+
info: () => {},
40+
warn: () => {},
41+
debug: () => {},
42+
time: () => ({ stop: () => {} }),
43+
}),
44+
Default: {
45+
error: () => {},
46+
info: () => {},
47+
warn: () => {},
48+
debug: () => {},
49+
},
50+
},
51+
}))
52+
53+
mock.module("@/util/timeout", () => ({
54+
withTimeout: <T>(input: Promise<T>) => input,
55+
}))
56+
57+
mock.module("@/cli/network", () => ({
58+
withNetworkOptions: <T>(input: T) => input,
59+
resolveNetworkOptions: async () => ({
60+
mdns: false,
61+
port: 0,
62+
hostname: "127.0.0.1",
63+
}),
64+
}))
65+
66+
mock.module("../../../src/cli/cmd/tui/win32", () => ({
67+
win32DisableProcessedInput: () => {},
68+
win32InstallCtrlCGuard: () => undefined,
69+
}))
70+
71+
mock.module("@/config/tui", () => ({
72+
TuiConfig: {
73+
get: () => ({}),
74+
},
75+
}))
76+
77+
mock.module("@/project/instance", () => ({
78+
Instance: {
79+
provide: async (input: { directory: string; fn: () => Promise<unknown> | unknown }) => {
80+
seen.inst.push(input.directory)
81+
return input.fn()
82+
},
83+
},
84+
}))
85+
86+
describe("tui thread", () => {
87+
async function call(project?: string) {
88+
const { TuiThreadCommand } = await import("../../../src/cli/cmd/tui/thread")
89+
const args: Parameters<NonNullable<typeof TuiThreadCommand.handler>>[0] = {
90+
_: [],
91+
$0: "opencode",
92+
project,
93+
prompt: "hi",
94+
model: undefined,
95+
agent: undefined,
96+
session: undefined,
97+
continue: false,
98+
fork: false,
99+
port: 0,
100+
hostname: "127.0.0.1",
101+
mdns: false,
102+
"mdns-domain": "opencode.local",
103+
mdnsDomain: "opencode.local",
104+
cors: [],
105+
}
106+
return TuiThreadCommand.handler(args)
107+
}
108+
109+
async function check(project?: string) {
110+
await using tmp = await tmpdir({ git: true })
111+
const cwd = process.cwd()
112+
const pwd = process.env.PWD
113+
const worker = globalThis.Worker
114+
const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY")
115+
const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link")
116+
const type = process.platform === "win32" ? "junction" : "dir"
117+
seen.tui.length = 0
118+
seen.inst.length = 0
119+
await fs.symlink(tmp.path, link, type)
120+
121+
Object.defineProperty(process.stdin, "isTTY", {
122+
configurable: true,
123+
value: true,
124+
})
125+
globalThis.Worker = class extends EventTarget {
126+
onerror = null
127+
onmessage = null
128+
onmessageerror = null
129+
postMessage() {}
130+
terminate() {}
131+
} as unknown as typeof Worker
132+
133+
try {
134+
process.chdir(tmp.path)
135+
process.env.PWD = link
136+
await expect(call(project)).rejects.toBe(stop)
137+
expect(seen.inst[0]).toBe(tmp.path)
138+
expect(seen.tui[0]).toBe(tmp.path)
139+
} finally {
140+
process.chdir(cwd)
141+
if (pwd === undefined) delete process.env.PWD
142+
else process.env.PWD = pwd
143+
if (tty) Object.defineProperty(process.stdin, "isTTY", tty)
144+
else delete (process.stdin as { isTTY?: boolean }).isTTY
145+
globalThis.Worker = worker
146+
await fs.rm(link, { recursive: true, force: true }).catch(() => undefined)
147+
}
148+
}
149+
150+
test("uses the real cwd when PWD points at a symlink", async () => {
151+
await check()
152+
})
153+
154+
test("uses the real cwd after resolving a relative project from PWD", async () => {
155+
await check(".")
156+
})
157+
})

0 commit comments

Comments
 (0)