Skip to content

Commit 67b3d4c

Browse files
authored
Improve OpenCode executable resolution and management (#1)
* feat: simplify OpenCode executable resolution and startup flow * fix: align OpenCode launch behavior across server start and terminal attach * test: update executable resolution and settings coverage
1 parent ca29e53 commit 67b3d4c

17 files changed

Lines changed: 906 additions & 162 deletions

.idea/.name

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/main/kotlin/com/ashotn/opencode/companion/OpenCodeChecker.kt

Lines changed: 204 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -4,108 +4,255 @@ import com.intellij.openapi.diagnostic.logger
44
import com.intellij.openapi.util.SystemInfo
55
import java.io.File
66
import java.util.concurrent.TimeUnit
7+
import kotlin.concurrent.thread
78

89
data class OpenCodeInfo(val path: String, val version: String)
910

1011
object OpenCodeChecker {
1112

1213
private val log = logger<OpenCodeChecker>()
14+
private val requiredHelpCommands = listOf("opencode serve", "opencode attach")
15+
private const val COMMAND_TIMEOUT_SECONDS = 10L
16+
private const val OUTPUT_JOIN_TIMEOUT_MILLIS = 1_000L
17+
18+
private data class CommandResult(
19+
val exitCode: Int?,
20+
val output: String,
21+
val timedOut: Boolean,
22+
)
23+
24+
private val osSpecificInstallLocations: List<String>
25+
get() = when {
26+
SystemInfo.isWindows -> {
27+
val localAppData = System.getenv("LOCALAPPDATA")?.takeIf { it.isNotBlank() }
28+
val appData = System.getenv("APPDATA")?.takeIf { it.isNotBlank() }
29+
buildList {
30+
localAppData?.let {
31+
add("$it\\OpenCode\\opencode-cli.exe")
32+
}
33+
appData?.let {
34+
add("$it\\npm\\opencode")
35+
add("$it\\npm\\opencode.cmd")
36+
}
37+
}
38+
}
39+
40+
SystemInfo.isMac -> {
41+
val home = System.getProperty("user.home")?.takeIf { it.isNotBlank() }
42+
buildList {
43+
add("/usr/local/bin/opencode") // Homebrew (Intel Mac)
44+
add("/opt/homebrew/bin/opencode") // Homebrew (Apple Silicon)
45+
home?.let {
46+
add("$it/.local/bin/opencode")
47+
add("$it/.bun/bin/opencode")
48+
add("$it/.npm-global/bin/opencode")
49+
}
50+
}
51+
}
52+
53+
SystemInfo.isLinux -> {
54+
val home = System.getProperty("user.home")?.takeIf { it.isNotBlank() }
55+
buildList {
56+
add("/usr/bin/opencode")
57+
home?.let {
58+
add("$it/.opencode/bin/opencode")
59+
add("$it/.local/bin/opencode")
60+
add("$it/.bun/bin/opencode")
61+
add("$it/.npm-global/bin/opencode")
62+
}
63+
}
64+
}
65+
66+
else -> emptyList()
67+
}
1368

1469
/**
1570
* Returns an [OpenCodeInfo] containing the resolved path and version of the `opencode`
1671
* executable, or null if no valid executable is found.
1772
*
18-
* If [userProvidedPath] is non-blank, it is validated (file exists, is executable, and
19-
* responds to `--version`). If it does not pass validation, a warning is logged and null
20-
* is returned immediately (auto-resolve is NOT attempted).
73+
* If [userProvidedPath] is non-blank, it is validated (file exists, is runnable on this OS, and
74+
* responds to `--version` and `--help`). If it does not pass validation, a warning is
75+
* logged and null is returned immediately (auto-resolve is NOT attempted).
2176
*
2277
* If [userProvidedPath] is blank or null, the auto-resolve strategy is used:
2378
* PATH entries are searched first, followed by common install locations. The first
24-
* candidate that passes all validation gates (including `--version`) is returned.
79+
* candidate that passes all validation gates is returned.
2580
*/
2681
fun findExecutable(userProvidedPath: String? = null): OpenCodeInfo? {
27-
if (userProvidedPath.isNullOrBlank()) {
28-
return autoResolve()
29-
}
30-
val file = File(userProvidedPath)
31-
return if (file.isFile && file.canExecute()) {
32-
val version = getVersion(file.absolutePath)
33-
if (version != null) {
34-
OpenCodeInfo(file.absolutePath, version)
35-
} else {
36-
log.warn("OpenCode executable at user-provided path did not respond to --version: $userProvidedPath")
37-
null
38-
}
39-
} else {
40-
log.warn("OpenCode executable not found at user-provided path: $userProvidedPath")
82+
val normalizedUserProvidedPath = normalizeUserProvidedPath(userProvidedPath) ?: return autoResolve()
83+
84+
val file = File(normalizedUserProvidedPath)
85+
if (!isCandidateFile(file)) {
86+
log.warn(
87+
"OpenCode executable at user-provided path is invalid: $normalizedUserProvidedPath " +
88+
"(exists=${file.exists()}, isFile=${file.isFile}, canExecute=${file.canExecute()}, os=${SystemInfo.OS_NAME})"
89+
)
90+
return null
91+
}
92+
93+
val absolutePath = file.absolutePath
94+
return validateCandidate(absolutePath) ?: run {
95+
log.warn("OpenCode executable at user-provided path failed validation: $absolutePath")
4196
null
4297
}
4398
}
4499

45100
private fun autoResolve(): OpenCodeInfo? {
46-
val executableNames = if (SystemInfo.isWindows) listOf("opencode.exe", "opencode.cmd") else listOf("opencode")
47-
48-
val pathEnv = System.getenv("PATH") ?: ""
49-
for (dir in pathEnv.split(File.pathSeparator)) {
50-
for (executableName in executableNames) {
51-
val candidate = File(dir, executableName)
52-
if (candidate.isFile && candidate.canExecute()) {
53-
val version = getVersion(candidate.absolutePath)
54-
if (version != null) return OpenCodeInfo(candidate.absolutePath, version)
55-
}
101+
val executableNames =
102+
if (SystemInfo.isWindows) {
103+
listOf("opencode", "opencode.cmd", "opencode-cli.exe")
104+
} else {
105+
listOf("opencode")
56106
}
57-
}
58107

59-
val home = System.getProperty("user.home")
60-
val extraLocations = if (SystemInfo.isWindows) {
61-
listOf(
62-
"${System.getenv("APPDATA") ?: ""}\\npm\\opencode.cmd",
63-
"${System.getenv("LOCALAPPDATA") ?: ""}\\Programs\\opencode\\opencode.exe",
64-
)
108+
val pathEnv = System.getenv("PATH")
109+
if (pathEnv.isNullOrBlank()) {
110+
log.debug("PATH environment variable is empty; skipping PATH scan")
65111
} else {
66-
listOf(
67-
"/usr/local/bin/opencode",
68-
"/usr/bin/opencode",
69-
"$home/.local/bin/opencode",
70-
"$home/.bun/bin/opencode",
71-
"$home/.npm-global/bin/opencode",
72-
)
112+
for (dir in pathEnv.split(File.pathSeparator)) {
113+
if (dir.isBlank()) continue
114+
for (executableName in executableNames) {
115+
val candidate = File(dir, executableName)
116+
if (isCandidateFile(candidate)) {
117+
validateCandidate(candidate.absolutePath)?.let { return it }
118+
}
119+
}
120+
}
73121
}
74122

75-
for (path in extraLocations) {
123+
for (path in osSpecificInstallLocations) {
76124
val candidate = File(path)
77-
if (candidate.isFile && candidate.canExecute()) {
78-
val version = getVersion(candidate.absolutePath)
79-
if (version != null) return OpenCodeInfo(candidate.absolutePath, version)
125+
if (isCandidateFile(candidate)) {
126+
validateCandidate(candidate.absolutePath)?.let { return it }
80127
}
81128
}
82129

83130
return null
84131
}
85132

133+
private fun normalizeUserProvidedPath(path: String?): String? {
134+
return path
135+
?.trim()
136+
?.removeSurrounding("\"")
137+
?.removeSurrounding("'")
138+
?.takeIf { it.isNotBlank() }
139+
}
140+
141+
private fun isCandidateFile(candidate: File): Boolean {
142+
if (!candidate.exists() || !candidate.isFile) {
143+
return false
144+
}
145+
return if (SystemInfo.isWindows) {
146+
true
147+
} else {
148+
candidate.canExecute()
149+
}
150+
}
151+
152+
private fun validateCandidate(path: String): OpenCodeInfo? {
153+
val version = getVersion(path) ?: return null
154+
val helpOutput = getHelpOutput(path) ?: return null
155+
if (!hasRequiredHelpCommands(helpOutput, path)) return null
156+
return OpenCodeInfo(path, version)
157+
}
158+
86159
private fun getVersion(path: String): String? {
160+
val result = runCommand(path, "--version") ?: return null
161+
162+
if (result.timedOut) {
163+
log.warn("OpenCode --version timed out for: $path")
164+
return null
165+
}
166+
167+
val exitCode = result.exitCode
168+
if (exitCode != 0) {
169+
log.warn("OpenCode --version exited with code $exitCode for: $path")
170+
return null
171+
}
172+
173+
val output = result.output
174+
if (output.isBlank()) {
175+
log.warn("OpenCode --version returned empty output for: $path")
176+
return null
177+
}
178+
179+
val startsWithSemVer = Regex("^\\d+\\.\\d+\\.\\d+.*").matches(output)
180+
if (!startsWithSemVer) {
181+
log.warn("OpenCode --version output did not start with semantic version for: $path. Output: '$output'")
182+
return null
183+
}
184+
185+
return output
186+
}
187+
188+
private fun getHelpOutput(path: String): String? {
189+
val result = runCommand(path, "--help") ?: return null
190+
191+
if (result.timedOut) {
192+
log.warn("OpenCode --help timed out for: $path")
193+
return null
194+
}
195+
196+
val exitCode = result.exitCode
197+
if (exitCode != 0) {
198+
log.warn("OpenCode --help exited with code $exitCode for: $path")
199+
return null
200+
}
201+
202+
val output = result.output
203+
if (output.isBlank()) {
204+
log.warn("OpenCode --help returned empty output for: $path")
205+
return null
206+
}
207+
208+
return output
209+
}
210+
211+
private fun runCommand(path: String, arg: String): CommandResult? {
87212
return try {
88-
val process = ProcessBuilder(path, "--version")
213+
val process = ProcessBuilder(path, arg)
89214
.redirectErrorStream(true)
90215
.start()
91216

92-
val completed = process.waitFor(3, TimeUnit.SECONDS)
217+
var output = ""
218+
val readerThread = thread(start = true, isDaemon = true, name = "opencode-checker-$arg") {
219+
output = process.inputStream.bufferedReader().use { it.readText() }
220+
}
221+
222+
val completed = process.waitFor(COMMAND_TIMEOUT_SECONDS, TimeUnit.SECONDS)
93223
if (!completed) {
94224
process.destroyForcibly()
95-
log.warn("OpenCode --version timed out for: $path")
96-
return null
97-
}
98-
if (process.exitValue() != 0) {
99-
log.warn("OpenCode --version exited with code ${process.exitValue()} for: $path")
100-
return null
225+
process.waitFor(1, TimeUnit.SECONDS)
226+
runCatching { process.inputStream.close() }
227+
readerThread.join(OUTPUT_JOIN_TIMEOUT_MILLIS)
228+
return CommandResult(exitCode = null, output = output.trim(), timedOut = true)
101229
}
102230

103-
process.inputStream.bufferedReader().use { reader ->
104-
reader.readText().trim().ifBlank { null }
231+
readerThread.join(OUTPUT_JOIN_TIMEOUT_MILLIS)
232+
if (readerThread.isAlive) {
233+
log.warn("OpenCode command output reader did not finish for: $path $arg")
105234
}
235+
236+
CommandResult(exitCode = process.exitValue(), output = output.trim(), timedOut = false)
106237
} catch (e: Exception) {
107-
log.warn("Failed to run --version for: $path", e)
238+
log.warn("Failed to run command '$arg' for: $path", e)
108239
null
109240
}
110241
}
242+
243+
private fun hasRequiredHelpCommands(helpOutput: String, path: String): Boolean {
244+
val normalizedOutput = helpOutput.lowercase()
245+
val missingCommands = requiredHelpCommands.filterNot { normalizedOutput.contains(it) }
246+
if (missingCommands.isNotEmpty()) {
247+
log.warn(
248+
"OpenCode --help output missing required commands for: $path. Missing: ${
249+
missingCommands.joinToString(
250+
", "
251+
)
252+
}"
253+
)
254+
return false
255+
}
256+
return true
257+
}
111258
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.ashotn.opencode.companion
2+
3+
sealed interface OpenCodeExecutableResolutionState {
4+
data object Resolving : OpenCodeExecutableResolutionState
5+
data class Resolved(val info: OpenCodeInfo) : OpenCodeExecutableResolutionState
6+
data object NotFound : OpenCodeExecutableResolutionState
7+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.ashotn.opencode.companion
2+
3+
import com.intellij.util.messages.Topic
4+
5+
/**
6+
* Published when the current [OpenCodeInfo] changes.
7+
*/
8+
fun interface OpenCodeInfoChangedListener {
9+
companion object {
10+
@JvmField
11+
val TOPIC = Topic.create("OpenCode Info Changed", OpenCodeInfoChangedListener::class.java)
12+
}
13+
14+
fun onOpenCodeInfoChanged(info: OpenCodeInfo?)
15+
}

0 commit comments

Comments
 (0)