@@ -4,108 +4,255 @@ import com.intellij.openapi.diagnostic.logger
44import com.intellij.openapi.util.SystemInfo
55import java.io.File
66import java.util.concurrent.TimeUnit
7+ import kotlin.concurrent.thread
78
89data class OpenCodeInfo (val path : String , val version : String )
910
1011object 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}
0 commit comments