From 80f7df0d5349716d27dbbb25c2b52a450a06e740 Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Thu, 8 Jan 2026 02:30:22 +0800 Subject: [PATCH] feat(version): add CLI version check on extension startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check daemon CLI version against server API when extension starts. If a newer version is available, show warning notification with update command: curl -sSL {webEndpoint}/i | bash - Add VersionChecker class with HTTP client and notifications - Read apiEndpoint and webEndpoint from config file - Show warning only once per session - Add "Copy Update Command" action to notification 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../jetbrains/config/ConfigLoader.kt | 12 +- .../shelltime/jetbrains/config/Constants.kt | 6 + .../jetbrains/config/ShellTimeConfig.kt | 8 +- .../jetbrains/heartbeat/HeartbeatData.kt | 10 ++ .../services/ShellTimeProjectService.kt | 37 ++++++ .../jetbrains/version/VersionChecker.kt | 121 ++++++++++++++++++ 6 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/xyz/shelltime/jetbrains/version/VersionChecker.kt diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/config/ConfigLoader.kt b/src/main/kotlin/xyz/shelltime/jetbrains/config/ConfigLoader.kt index 1f37f28..2610bb5 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/config/ConfigLoader.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/config/ConfigLoader.kt @@ -165,7 +165,17 @@ class ConfigLoader { socketPath = fileConfig.socketPath ?: Constants.DEFAULT_SOCKET_PATH, heartbeatInterval = Constants.DEFAULT_FLUSH_INTERVAL_MS, debug = false, - exclude = fileConfig.exclude ?: emptyList() + exclude = fileConfig.exclude ?: emptyList(), + apiEndpoint = fileConfig.apiEndpoint, + webEndpoint = fileConfig.webEndpoint ) } + + /** + * Get raw file config for accessing apiEndpoint/webEndpoint + */ + fun getFileConfig(): ShellTimeFileConfig? { + val configFile = findConfigFile() ?: return null + return loadConfigFile(configFile) + } } diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/config/Constants.kt b/src/main/kotlin/xyz/shelltime/jetbrains/config/Constants.kt index d167a3d..41a0ee9 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/config/Constants.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/config/Constants.kt @@ -27,4 +27,10 @@ object Constants { /** Notification group ID */ const val NOTIFICATION_GROUP_ID = "ShellTime" + + /** Version check API endpoint path */ + const val VERSION_CHECK_ENDPOINT = "/api/v1/cli/version-check" + + /** Version check timeout in milliseconds */ + const val VERSION_CHECK_TIMEOUT_MS = 5_000L } diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/config/ShellTimeConfig.kt b/src/main/kotlin/xyz/shelltime/jetbrains/config/ShellTimeConfig.kt index 0d50220..86c64c5 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/config/ShellTimeConfig.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/config/ShellTimeConfig.kt @@ -43,5 +43,11 @@ data class ShellTimeConfig( val debug: Boolean = false, /** Patterns to exclude from tracking */ - val exclude: List = emptyList() + val exclude: List = emptyList(), + + /** API endpoint for version check */ + val apiEndpoint: String? = null, + + /** Web endpoint for update command */ + val webEndpoint: String? = null ) diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatData.kt b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatData.kt index 8867ebd..addc08c 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatData.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/heartbeat/HeartbeatData.kt @@ -114,3 +114,13 @@ data class StatusResponse( val platform: String? = null, val goVersion: String? = null ) + +/** + * Response from version check API + */ +@Serializable +data class VersionCheckResponse( + val isLatest: Boolean, + val latestVersion: String, + val version: String +) diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeProjectService.kt b/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeProjectService.kt index 7ba7076..1f67e3a 100644 --- a/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeProjectService.kt +++ b/src/main/kotlin/xyz/shelltime/jetbrains/services/ShellTimeProjectService.kt @@ -12,6 +12,8 @@ import com.intellij.openapi.vfs.VirtualFile import xyz.shelltime.jetbrains.heartbeat.HeartbeatCollector import xyz.shelltime.jetbrains.heartbeat.HeartbeatSender import xyz.shelltime.jetbrains.heartbeat.HeartbeatSenderCallback +import xyz.shelltime.jetbrains.version.VersionChecker +import kotlinx.coroutines.* /** * Project-level service for ShellTime @@ -25,7 +27,9 @@ class ShellTimeProjectService(private val project: Project) : Disposable { private lateinit var collector: HeartbeatCollector private lateinit var sender: HeartbeatSender + private var versionChecker: VersionChecker? = null private var statusCallback: HeartbeatSenderCallback? = null + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private val editorFactoryListener = object : EditorFactoryListener { override fun editorCreated(event: EditorFactoryEvent) { @@ -72,6 +76,37 @@ class ShellTimeProjectService(private val project: Project) : Disposable { // Start the sender sender.start() + + // Check CLI version in background (non-blocking) + checkCliVersion() + } + + /** + * Check CLI version against server + */ + private fun checkCliVersion() { + val apiEndpoint = settings.apiEndpoint + val webEndpoint = settings.webEndpoint + + if (apiEndpoint.isNullOrEmpty() || webEndpoint.isNullOrEmpty()) { + return + } + + versionChecker = VersionChecker(apiEndpoint, webEndpoint, settings.debug) + + scope.launch { + try { + val socketClient = appService.getSocketClient() + val status = socketClient.getStatus() + val daemonVersion = status?.version + + if (daemonVersion != null) { + versionChecker?.checkVersion(daemonVersion, project) + } + } catch (e: Exception) { + // Silently ignore - version check is optional + } + } } /** @@ -147,5 +182,7 @@ class ShellTimeProjectService(private val project: Project) : Disposable { if (::collector.isInitialized) { collector.dispose() } + versionChecker?.dispose() + scope.cancel() } } diff --git a/src/main/kotlin/xyz/shelltime/jetbrains/version/VersionChecker.kt b/src/main/kotlin/xyz/shelltime/jetbrains/version/VersionChecker.kt new file mode 100644 index 0000000..5f6d2a9 --- /dev/null +++ b/src/main/kotlin/xyz/shelltime/jetbrains/version/VersionChecker.kt @@ -0,0 +1,121 @@ +package xyz.shelltime.jetbrains.version + +import com.intellij.ide.BrowserUtil +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.project.Project +import kotlinx.coroutines.* +import kotlinx.serialization.json.Json +import xyz.shelltime.jetbrains.config.Constants +import xyz.shelltime.jetbrains.heartbeat.VersionCheckResponse +import xyz.shelltime.jetbrains.utils.Logger +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder + +/** + * Checks CLI version against the server and notifies user if update is available + */ +class VersionChecker( + private val apiEndpoint: String, + private val webEndpoint: String, + debug: Boolean = false +) { + private val logger = Logger("VersionChecker", debug) + private val json = Json { ignoreUnknownKeys = true } + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + @Volatile + private var hasShownWarning = false + + /** + * Check CLI version asynchronously + */ + fun checkVersion(currentVersion: String, project: Project?) { + if (hasShownWarning) { + logger.log("Version warning already shown this session, skipping") + return + } + + scope.launch { + try { + val result = fetchVersionCheck(currentVersion) + if (result != null && !result.isLatest) { + hasShownWarning = true + withContext(Dispatchers.Main) { + showUpdateWarning(currentVersion, result.latestVersion, project) + } + } else if (result != null) { + logger.log("CLI version $currentVersion is up to date") + } + } catch (e: Exception) { + logger.log("Version check failed: ${e.message}") + } + } + } + + private fun fetchVersionCheck(version: String): VersionCheckResponse? { + val encodedVersion = URLEncoder.encode(version, "UTF-8") + val url = URL("$apiEndpoint${Constants.VERSION_CHECK_ENDPOINT}?version=$encodedVersion") + + logger.log("Checking version at: $url") + + val connection = url.openConnection() as HttpURLConnection + connection.requestMethod = "GET" + connection.connectTimeout = Constants.VERSION_CHECK_TIMEOUT_MS.toInt() + connection.readTimeout = Constants.VERSION_CHECK_TIMEOUT_MS.toInt() + connection.setRequestProperty("Accept", "application/json") + + return try { + if (connection.responseCode == HttpURLConnection.HTTP_OK) { + val response = connection.inputStream.bufferedReader().use { it.readText() } + json.decodeFromString(response) + } else { + logger.log("Version check HTTP error: ${connection.responseCode}") + null + } + } finally { + connection.disconnect() + } + } + + private fun showUpdateWarning(currentVersion: String, latestVersion: String, project: Project?) { + val message = "ShellTime CLI update available: $currentVersion -> $latestVersion" + val updateCommand = "curl -sSL $webEndpoint/i | bash" + + NotificationGroupManager.getInstance() + .getNotificationGroup(Constants.NOTIFICATION_GROUP_ID) + .createNotification( + "ShellTime Update Available", + "$message

Run: $updateCommand", + NotificationType.WARNING + ) + .addAction(object : com.intellij.notification.NotificationAction("Copy Update Command") { + override fun actionPerformed( + e: com.intellij.notification.AnActionEvent, + notification: com.intellij.notification.Notification + ) { + val clipboard = java.awt.Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(java.awt.datatransfer.StringSelection(updateCommand), null) + notification.expire() + + NotificationGroupManager.getInstance() + .getNotificationGroup(Constants.NOTIFICATION_GROUP_ID) + .createNotification( + "Update command copied to clipboard", + NotificationType.INFORMATION + ) + .notify(project) + } + }) + .notify(project) + } + + fun setDebug(enabled: Boolean) { + logger.setDebug(enabled) + } + + fun dispose() { + scope.cancel() + } +}