diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cf1ae9..b699b66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,72 @@ ## Unreleased +## 0.3.2 - 2025-06-25 + +### Changed + +- the logos and icons now match the new branding + +## 0.3.1 - 2025-06-19 + +### Added + +- visual text progress during Coder CLI downloading + +### Changed + +- the plugin will now remember the SSH connection state for each workspace, and it will try to automatically + establish it after an expired token was refreshed. + +### Fixed + +- `Stop` action is now available for running workspaces that have an out of date template. +- outdated and stopped workspaces are now updated and started when handling URI +- show errors when the Toolbox is visible again after being minimized. +- URI handling now installs the exact build number if it is available for the workspace. + +## 0.3.0 - 2025-06-10 + +### Added + +- support for Toolbox 2.6.3 with improved URI handling + +## 0.2.3 - 2025-05-26 + +### Changed + +- improved workspace status reporting (icon and colors) when it is failed, stopping, deleting, stopped or when we are + establishing the SSH connection. + +### Fixed + +- url on the main page is now refreshed when switching between multiple deployments (via logout/login or URI handling) +- tokens are now remembered after switching between multiple deployments + +## 0.2.2 - 2025-05-21 + +- render network status in the Settings tab, under `Additional environment information` section. +- quick action for creating new workspaces from the web dashboard. + +### Fixed + +- `Open web terminal` action is no longer displayed when the workspace is stopped. +- URL links can now be opened in Windows + +## 0.2.1 - 2025-05-05 + +### Changed + +- ssh configuration is simplified, background hostnames have been discarded. + +### Fixed + +- rendering glitches when a Workspace is stopped while SSH connection is alive +- misleading message saying that there are no workspaces rendered during manual authentication +- Coder Settings can now be accessed from the authentication wizard + +## 0.2.0 - 2025-04-24 + ### Added - support for using proxies. Proxy authentication is not yet supported. diff --git a/README.md b/README.md index c70df74..8bffe5b 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Connects your JetBrains IDE to Coder workspaces To install this plugin using JetBrains Toolbox, follow the steps below. -1. Install [JetBrains Toolbox](https://www.jetbrains.com/toolbox-app/). Make sure it's the `2.6.0.40284` release or +1. Install [JetBrains Toolbox](https://www.jetbrains.com/toolbox-app/). Make sure it's the `2.6.0.40632` release or above. 2. Launch the Toolbox app and sign in with your JetBrains account (if needed). @@ -101,6 +101,13 @@ If `ide_product_code` and `ide_build_number` is missing, Toolbox will only open page. Coder Toolbox will attempt to start the workspace if it’s not already running; however, for the most reliable experience, it’s recommended to ensure the workspace is running prior to initiating the connection. +> ⚠️ Note: `folder` should point to a remote IDEA project that has already been opened and appears in the `Projects` +> tab. +> If the path refers to a project that doesn't exist, the remote IDE won’t start or load it. + +> Until [TBX-14952](https://youtrack.jetbrains.com/issue/TBX-14952/) is fixed, it's best to either use a path to a +> previously opened project or leave it empty. + ## Configuring and Testing workspace polling with HTTP & SOCKS5 Proxy This section explains how to set up a local proxy (without authentication which is not yet supported) and verify that @@ -137,9 +144,121 @@ mitmproxy can do HTTP and SOCKS5 proxying. To configure one or the other: 5. Before authenticating to the Coder deployment we need to tell the plugin where can we find mitmproxy certificates. In Coder's Settings page, set the `TLS CA path` to `~/.mitmproxy/mitmproxy-ca-cert.pem` +## Debugging and Reporting issues + +Enabling debug logging is essential for diagnosing issues with the Toolbox plugin, especially when SSH +connections to the remote environment fail — it provides detailed output that includes SSH negotiation +and command execution, which is not visible at the default log level. + +If you encounter a problem with Coder's JetBrains Toolbox plugin, follow the steps below to gather more +information and help us diagnose and resolve it quickly. + +### Enable Debug Logging + +To help with troubleshooting or to gain more insight into the behavior of the plugin and the SSH connection to +the workspace, you can increase the log level to _DEBUG_. + +Steps to enable debug logging: + +1. Open Toolbox + +2. Navigate to the Toolbox App Menu (hexagonal menu icon) > Settings > Advanced. + +3. In the screen that appears, select _DEBUG_ for the `Log level:` section. + +4. Hit the back button at the top. + +There is no need to restart Toolbox, as it will begin logging at the __DEBUG__ level right away. + +> ⚠️ **Attention:** Toolbox does not persist log level configuration between restarts. + +#### Viewing the Logs + +Once enabled, debug logs will be written to the Toolbox log files. You can access logs directly +via Toolbox App Menu > About > Show log files. + +Alternatively, you can generate a ZIP file using the Workspace action menu, available either on the main +Workspaces page in Coder or within the individual workspace view, under the option labeled _Collect logs_. + +## Coder Settings + +The Coder Settings allows users to control CLI download behavior, SSH configuration, TLS parameters, and data +storage paths. The options can be configured from the plugin's main Workspaces page > deployment action menu > Settings. + +### CLI related settings + +```Binary source``` specifies the source URL or relative path from which the Coder CLI should be downloaded. +If a relative path is provided, it is resolved against the deployment domain. + +```Enable downloads``` allows automatic downloading of the CLI if the current version is missing or outdated. + +```Binary directory``` specifies the directory where CLI binaries are stored. If omitted, it defaults to the data +directory. + +```Enable binary directory fallback``` if enabled, falls back to the data directory when the specified binary +directory is not writable. + +```Data directory``` directory where plugin-specific data such as session tokens and binaries are stored if not +overridden by the binary directory setting. + +```Header command``` command that outputs additional HTTP headers. Each line of output must be in the format key=value. +The environment variable CODER_URL will be available to the command process. + +### TLS settings + +The following options control the secure communication behavior of the plugin with Coder deployment and its available +API. + +```TLS cert path``` path to a client certificate file for TLS authentication with Coder deployment. +The certificate should be in X.509 PEM format. + +```TLS key path``` path to the private key corresponding to the TLS certificate from above. +The certificate should be in X.509 PEM format. + +```TLS CA path``` the path of a file containing certificates for an alternate certificate authority used to verify TLS +certs returned by the Coder deployment. The file should be in X.509 PEM format. This option can also be used to verify +proxy certificates. + +```TLS alternate hostname``` overrides the hostname used in TLS verification. This is useful when the hostname +used to connect to the Coder deployment does not match the hostname in the TLS certificate. + +### SSH settings + +The following options control the SSH behavior of the Coder CLI. + +```Disable autostart``` adds the --disable-autostart flag to the SSH proxy command, preventing the CLI from keeping +workspaces constantly active. + +```Enable SSH wildcard config``` enables or disables wildcard entries in the SSH configuration, which allow generic +rules for matching multiple workspaces. + +```SSH proxy log directory``` directory where SSH proxy logs are written. Useful for debugging SSH connection issues. + +```SSH network metrics directory``` directory where network information used by the SSH proxy is stored. + +```Extra SSH options``` additional options appended to the SSH configuration. Can be used to customize the behavior of +SSH connections. + +### Saving Changes + +Changes made in the settings page are saved by clicking the Save button. Some changes, like toggling SSH wildcard +support, +may trigger regeneration of SSH configurations. + +### Security considerations + +> ⚠️ **Attention:** Token authentication is required when TLS certificates are not configured. + ## Releasing 1. Check that the changelog lists all the important changes. -2. Update the gradle.properties version. +2. Update the `gradle.properties` version. 3. Publish the resulting draft release after validating it. 4. Merge the resulting changelog PR. +5. **Compliance Reminder for auto-approval** + JetBrains enabled auto-approval for the plugin, so we need to ensure we continue to meet the following requirements: + - do **not** use Kotlin experimental APIs. + - do **not** add any lambdas, handlers, or class handles to Java runtime hooks. + - do **not** create threads manually (including via libraries). If you must, ensure they are properly cleaned up in the plugin's `CoderRemoteProvider#close()` method. + - do **not** bundle libraries that are already provided by Toolbox. + - do **not** perform any ill-intentioned actions. \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 9c81da9..93d13a0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -133,7 +133,7 @@ fun CopySpec.fromCompileDependencies() { } from("src/main/resources") { include("icon.svg") - rename("icon.svg", "pluginIcon.svg") + include("pluginIcon.svg") } // Copy dependencies, excluding those provided by Toolbox. diff --git a/gradle.properties b/gradle.properties index a4ec268..b2b2959 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.2.0 +version=0.3.2 group=com.coder.toolbox name=coder-toolbox diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e9acb33..4647eb8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] -toolbox-plugin-api = "1.0.38881" -kotlin = "2.1.0" +toolbox-plugin-api = "1.1.41749" +kotlin = "2.1.10" coroutines = "1.10.1" serialization = "1.8.0" okhttp = "4.12.0" @@ -9,12 +9,12 @@ marketplace-client = "2.0.46" gradle-wrapper = "0.14.0" exec = "1.12" moshi = "1.15.2" -ksp = "2.1.0-1.0.29" -retrofit = "2.11.0" +ksp = "2.1.10-1.0.31" +retrofit = "3.0.0" changelog = "2.2.1" gettext = "0.7.0" -plugin-structure = "3.304" -mockk = "1.14.0" +plugin-structure = "3.308" +mockk = "1.14.4" [libraries] toolbox-core-api = { module = "com.jetbrains.toolbox:core-api", version.ref = "toolbox-plugin-api" } diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index 69ee6cd..3c4de20 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -1,15 +1,19 @@ package com.coder.toolbox -import com.coder.toolbox.browser.BrowserUtil +import com.coder.toolbox.browser.browse import com.coder.toolbox.cli.CoderCLIManager +import com.coder.toolbox.cli.SshCommandProcessHandle import com.coder.toolbox.models.WorkspaceAndAgentStatus import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.sdk.ex.APIResponseException +import com.coder.toolbox.sdk.v2.models.NetworkMetrics import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent +import com.coder.toolbox.util.waitForFalseWithTimeout import com.coder.toolbox.util.withPath import com.coder.toolbox.views.Action import com.coder.toolbox.views.EnvironmentView +import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.remoteDev.AfterDisconnectHook import com.jetbrains.toolbox.api.remoteDev.BeforeConnectionHook import com.jetbrains.toolbox.api.remoteDev.DeleteEnvironmentConfirmationParams @@ -19,15 +23,22 @@ import com.jetbrains.toolbox.api.remoteDev.environments.EnvironmentContentsView import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentDescription import com.jetbrains.toolbox.api.remoteDev.states.RemoteEnvironmentState import com.jetbrains.toolbox.api.ui.actions.ActionDescription +import com.squareup.moshi.Moshi +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout +import java.io.File +import java.nio.file.Path import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds +private val POLL_INTERVAL = 5.seconds + /** * Represents an agent and workspace combination. * @@ -43,39 +54,51 @@ class CoderRemoteEnvironment( private var wsRawStatus = WorkspaceAndAgentStatus.from(workspace, agent) override var name: String = "${workspace.name}.${agent.name}" + private var isConnected: MutableStateFlow = MutableStateFlow(false) + override val connectionRequest: MutableStateFlow = MutableStateFlow(false) + override val state: MutableStateFlow = MutableStateFlow(wsRawStatus.toRemoteEnvironmentState(context)) override val description: MutableStateFlow = MutableStateFlow(EnvironmentDescription.General(context.i18n.pnotr(workspace.templateDisplayName))) - + override val additionalEnvironmentInformation: MutableMap = mutableMapOf() override val actionsList: MutableStateFlow> = MutableStateFlow(getAvailableActions()) + private val networkMetricsMarshaller = Moshi.Builder().build().adapter(NetworkMetrics::class.java) + private val proxyCommandHandle = SshCommandProcessHandle(context) + private var pollJob: Job? = null + fun asPairOfWorkspaceAndAgent(): Pair = Pair(workspace, agent) private fun getAvailableActions(): List { - val actions = mutableListOf( - Action(context.i18n.ptrl("Open web terminal")) { + val actions = mutableListOf() + if (wsRawStatus.canStop()) { + actions.add(Action(context.i18n.ptrl("Open web terminal")) { context.cs.launch { - BrowserUtil.browse(client.url.withPath("/${workspace.ownerName}/$name/terminal").toString()) { + context.desktop.browse(client.url.withPath("/${workspace.ownerName}/$name/terminal").toString()) { context.ui.showErrorInfoPopup(it) } } - }, + }) + } + actions.add( Action(context.i18n.ptrl("Open in dashboard")) { context.cs.launch { - BrowserUtil.browse(client.url.withPath("/@${workspace.ownerName}/${workspace.name}").toString()) { + context.desktop.browse( + client.url.withPath("/@${workspace.ownerName}/${workspace.name}").toString() + ) { context.ui.showErrorInfoPopup(it) } } - }, + }) - Action(context.i18n.ptrl("View template")) { - context.cs.launch { - BrowserUtil.browse(client.url.withPath("/templates/${workspace.templateName}").toString()) { - context.ui.showErrorInfoPopup(it) - } + actions.add(Action(context.i18n.ptrl("View template")) { + context.cs.launch { + context.desktop.browse(client.url.withPath("/templates/${workspace.templateName}").toString()) { + context.ui.showErrorInfoPopup(it) } - }) + } + }) if (wsRawStatus.canStart()) { if (workspace.outdated) { @@ -103,30 +126,88 @@ class CoderRemoteEnvironment( update(workspace.copy(latestBuild = build), agent) } }) - } else { - actions.add(Action(context.i18n.ptrl("Stop")) { - context.cs.launch { - val build = client.stopWorkspace(workspace) - update(workspace.copy(latestBuild = build), agent) - } - }) } + actions.add(Action(context.i18n.ptrl("Stop")) { + context.cs.launch { + tryStopSshConnection() + + val build = client.stopWorkspace(workspace) + update(workspace.copy(latestBuild = build), agent) + } + }) } return actions } + private suspend fun tryStopSshConnection() { + if (isConnected.value) { + connectionRequest.update { + false + } + + if (isConnected.waitForFalseWithTimeout(10.seconds) == null) { + context.logger.warn("The SSH connection to workspace $name could not be dropped in time, going to stop the workspace while the SSH connection is live") + } + } + } + override fun getBeforeConnectionHooks(): List = listOf(this) override fun getAfterDisconnectHooks(): List = listOf(this) override fun beforeConnection() { context.logger.info("Connecting to $id...") - this.isConnected = true + context.cs.launch { + state.update { + wsRawStatus.toSshConnectingEnvState(context) + } + } + isConnected.update { true } + pollJob = pollNetworkMetrics() + } + + private fun pollNetworkMetrics(): Job = context.cs.launch { + context.logger.info("Starting the network metrics poll job for $id") + while (isActive) { + context.logger.debug("Searching SSH command's PID for workspace $id...") + val pid = proxyCommandHandle.findByWorkspaceAndAgent(workspace, agent) + if (pid == null) { + context.logger.debug("No SSH command PID was found for workspace $id") + delay(POLL_INTERVAL) + continue + } + + val metricsFile = Path.of(context.settingsStore.networkInfoDir, "$pid.json").toFile() + if (metricsFile.doesNotExists()) { + context.logger.debug("No metrics file found at ${metricsFile.absolutePath} for $id") + delay(POLL_INTERVAL) + continue + } + context.logger.debug("Loading metrics from ${metricsFile.absolutePath} for $id") + try { + val metrics = networkMetricsMarshaller.fromJson(metricsFile.readText()) + if (metrics == null) { + return@launch + } + context.logger.debug("$id metrics: $metrics") + additionalEnvironmentInformation.put(context.i18n.ptrl("Network Status"), metrics.toPretty()) + } catch (e: Exception) { + context.logger.error( + e, + "Error encountered while trying to load network metrics from ${metricsFile.absolutePath} for $id" + ) + } + delay(POLL_INTERVAL) + } } - override fun afterDisconnect() { + private fun File.doesNotExists(): Boolean = !this.exists() + + override fun afterDisconnect(isManual: Boolean) { + context.logger.info("Stopping the network metrics poll job for $id") + pollJob?.cancel() this.connectionRequest.update { false } - this.isConnected = false + isConnected.update { false } context.logger.info("Disconnected from $id") } @@ -153,31 +234,38 @@ class CoderRemoteEnvironment( * The contents are provided by the SSH view provided by Toolbox, all we * have to do is provide it a host name. */ - override suspend - fun getContentsView(): EnvironmentContentsView = EnvironmentView( + override suspend fun getContentsView(): EnvironmentContentsView = EnvironmentView( client.url, cli, workspace, agent ) - private var isConnected = false - override val connectionRequest: MutableStateFlow = MutableStateFlow(false) - /** - * Does nothing. In theory, we could do something like start the workspace - * when you click into the workspace, but you would still need to press - * "connect" anyway before the content is populated so there does not seem - * to be much value. + * Automatically launches the SSH connection if the workspace is visible, is ready and there is no + * connection already established. */ override fun setVisible(visibilityState: EnvironmentVisibilityState) { - if (wsRawStatus.ready() && visibilityState.contentsVisible == true && isConnected == false) { + if (visibilityState.contentsVisible) { + startSshConnection() + } + } + + /** + * Launches the SSH connection if the workspace is ready and there is no connection already established. + * + * Returns true if the SSH connection was scheduled to start, false otherwise. + */ + fun startSshConnection(): Boolean { + if (wsRawStatus.ready() && !isConnected.value) { context.cs.launch { connectionRequest.update { true } } + return true } + return false } override fun getDeleteEnvironmentConfirmationParams(): DeleteEnvironmentConfirmationParams? { @@ -191,7 +279,7 @@ class CoderRemoteEnvironment( } } - override fun onDelete() { + override val deleteActionFlow: StateFlow<(() -> Unit)?> = MutableStateFlow { context.cs.launch { try { client.removeWorkspace(workspace) @@ -220,6 +308,8 @@ class CoderRemoteEnvironment( } } + fun isConnected(): Boolean = isConnected.value + /** * An environment is equal if it has the same ID. */ diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 4ccce7e..101cf71 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -1,22 +1,26 @@ package com.coder.toolbox +import com.coder.toolbox.browser.browse import com.coder.toolbox.cli.CoderCLIManager import com.coder.toolbox.sdk.CoderRestClient +import com.coder.toolbox.sdk.ex.APIResponseException import com.coder.toolbox.sdk.v2.models.WorkspaceStatus import com.coder.toolbox.util.CoderProtocolHandler import com.coder.toolbox.util.DialogUi +import com.coder.toolbox.util.withPath import com.coder.toolbox.views.Action -import com.coder.toolbox.views.AuthWizardPage +import com.coder.toolbox.views.CoderCliSetupWizardPage import com.coder.toolbox.views.CoderSettingsPage import com.coder.toolbox.views.NewEnvironmentPage -import com.coder.toolbox.views.state.AuthWizardState +import com.coder.toolbox.views.state.CoderCliSetupWizardState import com.coder.toolbox.views.state.WizardStep import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon.IconType import com.jetbrains.toolbox.api.core.util.LoadableState +import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.remoteDev.RemoteProvider -import com.jetbrains.toolbox.api.remoteDev.RemoteProviderEnvironment +import com.jetbrains.toolbox.api.ui.actions.ActionDelimiter import com.jetbrains.toolbox.api.ui.actions.ActionDescription import com.jetbrains.toolbox.api.ui.components.UiPage import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -59,12 +63,20 @@ class CoderRemoteProvider( // On the first load, automatically log in if we can. private var firstRun = true private val isInitialized: MutableStateFlow = MutableStateFlow(false) - private var coderHeaderPage = NewEnvironmentPage(context, context.i18n.pnotr(context.deploymentUrl?.first ?: "")) + private var coderHeaderPage = NewEnvironmentPage(context, context.i18n.pnotr(context.deploymentUrl.toString())) private val linkHandler = CoderProtocolHandler(context, dialogUi, isInitialized) - override val environments: MutableStateFlow>> = MutableStateFlow( + + override val environments: MutableStateFlow>> = MutableStateFlow( LoadableState.Loading ) + private val visibilityState = MutableStateFlow( + ProviderVisibilityState( + applicationVisible = false, + providerVisible = false + ) + ) + /** * With the provided client, start polling for workspaces. Every time a new * workspace is added, reconfigure SSH using the provided cli (including the @@ -105,7 +117,6 @@ class CoderRemoteProvider( return@launch } - // Reconfigure if environments changed. if (lastEnvironments.size != resolvedEnvironments.size || lastEnvironments != resolvedEnvironments) { context.logger.info("Workspaces have changed, reconfiguring CLI: $resolvedEnvironments") @@ -115,7 +126,7 @@ class CoderRemoteProvider( environments.update { LoadableState.Value(resolvedEnvironments.toList()) } - if (isInitialized.value == false) { + if (!isInitialized.value) { context.logger.info("Environments for ${client.url} are now initialized") isInitialized.update { true @@ -125,6 +136,21 @@ class CoderRemoteProvider( clear() addAll(resolvedEnvironments.sortedBy { it.id }) } + + if (WorkspaceConnectionManager.shouldEstablishWorkspaceConnections) { + WorkspaceConnectionManager.allConnected().forEach { wsId -> + val env = lastEnvironments.firstOrNull() { it.id == wsId } + if (env != null && !env.isConnected()) { + context.logger.info("Establishing lost SSH connection for workspace with id $wsId") + if (!env.startSshConnection()) { + context.logger.info("Can't establish lost SSH connection for workspace with id $wsId") + } + } + } + WorkspaceConnectionManager.reset() + } + + WorkspaceConnectionManager.collectStatuses(lastEnvironments) } catch (_: CancellationException) { context.logger.debug("${client.url} polling loop canceled") break @@ -135,7 +161,12 @@ class CoderRemoteProvider( client.setupSession() } else { context.logger.error(ex, "workspace polling error encountered, trying to auto-login") + if (ex is APIResponseException && ex.isTokenExpired) { + WorkspaceConnectionManager.shouldEstablishWorkspaceConnections = true + } close() + // force auto-login + firstRun = true goToEnvironmentsPage() break } @@ -165,6 +196,7 @@ class CoderRemoteProvider( // Keep the URL and token to make it easy to log back in, but set // rememberMe to false so we do not try to automatically log in. context.secrets.rememberMe = false + WorkspaceConnectionManager.reset() close() } @@ -184,6 +216,14 @@ class CoderRemoteProvider( override val additionalPluginActions: StateFlow> = MutableStateFlow( listOf( + Action(context.i18n.ptrl("Create workspace")) { + context.cs.launch { + context.desktop.browse(client?.url?.withPath("/templates").toString()) { + context.ui.showErrorInfoPopup(it) + } + } + }, + CoderDelimiter(context.i18n.pnotr("")), Action(context.i18n.ptrl("Settings")) { context.ui.showUiPage(settingsPage) }, @@ -202,7 +242,7 @@ class CoderRemoteProvider( environments.value = LoadableState.Value(emptyList()) isInitialized.update { false } client = null - AuthWizardState.resetSteps() + CoderCliSetupWizardState.resetSteps() } override val svgIcon: SvgIcon = @@ -250,18 +290,36 @@ class CoderRemoteProvider( * a place to put a timer ("last updated 10 seconds ago" for example) * and a manual refresh button. */ - override fun setVisible(visibilityState: ProviderVisibilityState) {} + override fun setVisible(visibility: ProviderVisibilityState) { + visibilityState.update { + visibility + } + } /** * Handle incoming links (like from the dashboard). */ override suspend fun handleUri(uri: URI) { - linkHandler.handle(uri, shouldDoAutoLogin()) { restClient, cli -> + linkHandler.handle( + uri, shouldDoAutoSetup(), + { + coderHeaderPage.isBusyCreatingNewEnvironment.update { + true + } + }, + { + coderHeaderPage.isBusyCreatingNewEnvironment.update { + false + } + } + ) { restClient, cli -> // stop polling and de-initialize resources close() // start initialization with the new settings this@CoderRemoteProvider.client = restClient coderHeaderPage = NewEnvironmentPage(context, context.i18n.pnotr(restClient.url.toString())) + + environments.showLoadingMessage() pollJob = poll(restClient, cli) } } @@ -285,17 +343,17 @@ class CoderRemoteProvider( * list. */ override fun getOverrideUiPage(): UiPage? { - // Show sign in page if we have not configured the client yet. + // Show the setup page if we have not configured the client yet. if (client == null) { val errorBuffer = mutableListOf() - // When coming back to the application, authenticate immediately. - val autologin = shouldDoAutoLogin() + // When coming back to the application, initializeSession immediately. + val autoSetup = shouldDoAutoSetup() context.secrets.lastToken.let { lastToken -> context.secrets.lastDeploymentURL.let { lastDeploymentURL -> - if (autologin && lastDeploymentURL.isNotBlank() && (lastToken.isNotBlank() || !settings.requireTokenAuth)) { + if (autoSetup && lastDeploymentURL.isNotBlank() && (lastToken.isNotBlank() || !settings.requireTokenAuth)) { try { - AuthWizardState.goToStep(WizardStep.LOGIN) - return AuthWizardPage(context, true, ::onConnect) + CoderCliSetupWizardState.goToStep(WizardStep.CONNECT) + return CoderCliSetupWizardPage(context, settingsPage, visibilityState, true, ::onConnect) } catch (ex: Exception) { errorBuffer.add(ex) } @@ -305,28 +363,40 @@ class CoderRemoteProvider( firstRun = false // Login flow. - val authWizard = AuthWizardPage(context, false, ::onConnect) + val setupWizardPage = + CoderCliSetupWizardPage(context, settingsPage, visibilityState, onConnect = ::onConnect) // We might have navigated here due to a polling error. errorBuffer.forEach { - authWizard.notify("Error encountered", it) + setupWizardPage.notify("Error encountered", it) } // and now reset the errors, otherwise we show it every time on the screen - return authWizard + return setupWizardPage } return null } - private fun shouldDoAutoLogin(): Boolean = firstRun && context.secrets.rememberMe == true + private fun shouldDoAutoSetup(): Boolean = firstRun && context.secrets.rememberMe == true - private fun onConnect(client: CoderRestClient, cli: CoderCLIManager) { + private suspend fun onConnect(client: CoderRestClient, cli: CoderCLIManager) { // Store the URL and token for use next time. context.secrets.lastDeploymentURL = client.url.toString() context.secrets.lastToken = client.token ?: "" + context.secrets.storeTokenFor(client.url, context.secrets.lastToken) // Currently we always remember, but this could be made an option. context.secrets.rememberMe = true this.client = client pollJob?.cancel() + environments.showLoadingMessage() + coderHeaderPage = NewEnvironmentPage(context, context.i18n.pnotr(client.url.toString())) pollJob = poll(client, cli) - goToEnvironmentsPage() + context.refreshMainPage() + } + + private fun MutableStateFlow>>.showLoadingMessage() { + this.update { + LoadableState.Loading + } } } + +private class CoderDelimiter(override val label: LocalizableString) : ActionDelimiter \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt index 9e5eace..0bb4135 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt @@ -1,23 +1,32 @@ package com.coder.toolbox -import com.coder.toolbox.settings.SettingSource import com.coder.toolbox.store.CoderSecretsStore import com.coder.toolbox.store.CoderSettingsStore import com.coder.toolbox.util.toURL +import com.coder.toolbox.views.CoderPage import com.jetbrains.toolbox.api.core.diagnostics.Logger +import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper +import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager import com.jetbrains.toolbox.api.ui.ToolboxUi import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import java.net.URL +import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds +@Suppress("UnstableApiUsage") data class CoderToolboxContext( val ui: ToolboxUi, val envPageManager: EnvironmentUiPageManager, val envStateColorPalette: EnvironmentStateColorPalette, - val ideOrchestrator: ClientHelper, + val remoteIdeOrchestrator: RemoteToolsHelper, + val jbClientOrchestrator: ClientHelper, + val desktop: LocalDesktopManager, val cs: CoroutineScope, val logger: Logger, val i18n: LocalizableStringFactory, @@ -35,32 +44,73 @@ data class CoderToolboxContext( * 3. CODER_URL. * 4. URL in global cli config. */ - val deploymentUrl: Pair? - get() = this.secrets.lastDeploymentURL.let { - if (it.isNotBlank()) { - it to SettingSource.LAST_USED - } else { - this.settingsStore.defaultURL() + val deploymentUrl: URL + get() { + if (this.secrets.lastDeploymentURL.isNotBlank()) { + return this.secrets.lastDeploymentURL.toURL() } + return this.settingsStore.defaultURL.toURL() } + suspend fun logAndShowError(title: String, error: String) { + logger.error(error) + ui.showSnackbar( + UUID.randomUUID().toString(), + i18n.pnotr(title), + i18n.pnotr(error), + i18n.ptrl("OK") + ) + } + + suspend fun logAndShowError(title: String, error: String, exception: Exception) { + logger.error(exception, error) + ui.showSnackbar( + UUID.randomUUID().toString(), + i18n.pnotr(title), + i18n.pnotr(error), + i18n.ptrl("OK") + ) + } + + suspend fun logAndShowWarning(title: String, warning: String) { + logger.warn(warning) + ui.showSnackbar( + UUID.randomUUID().toString(), + i18n.pnotr(title), + i18n.pnotr(warning), + i18n.ptrl("OK") + ) + } + + suspend fun logAndShowInfo(title: String, info: String) { + logger.info(info) + ui.showSnackbar( + UUID.randomUUID().toString(), + i18n.pnotr(title), + i18n.pnotr(info), + i18n.ptrl("OK") + ) + } + /** - * Try to find a token. - * - * Order of preference: - * - * 1. Last used token, if it was for this deployment. - * 2. Token on disk for this deployment. - * 3. Global token for Coder, if it matches the deployment. + * Forces the title bar on the main page to be refreshed */ - fun getToken(deploymentURL: String?): Pair? = this.secrets.lastToken.let { - if (it.isNotBlank() && this.secrets.lastDeploymentURL == deploymentURL) { - it to SettingSource.LAST_USED - } else { - if (deploymentURL != null) { - this.settingsStore.token(deploymentURL.toURL()) - } else null - } - } + suspend fun refreshMainPage() { + // the url/title on the main page is only refreshed if + // we're navigating to the main env page from another page. + // If TBX is already on the main page the title is not refreshed + // hence we force a navigation from a blank page. + ui.showUiPage(CoderPage.emptyPage(this)) + + // Toolbox uses an internal shared flow with a buffer of 4 items and a DROP_OLDEST strategy. + // Both showUiPage and showPluginEnvironmentsPage send events to this flow. + // If we emit two events back-to-back, the first one often gets dropped and only the second is shown. + // To reduce this risk, we add a small delay to let the UI coroutine process the first event. + // Simply yielding the coroutine isn't reliable, especially right after Toolbox starts via URI handling. + // Based on my testing, a 5–10 ms delay is enough to ensure the blank page is processed, + // while still short enough to be invisible to users. + delay(10.milliseconds) + envPageManager.showPluginEnvironmentsPage() + } } diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt index 5ab89a2..5cfcd11 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt @@ -8,10 +8,12 @@ import com.jetbrains.toolbox.api.core.PluginSettingsStore import com.jetbrains.toolbox.api.core.ServiceLocator import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.getService +import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory import com.jetbrains.toolbox.api.remoteDev.RemoteDevExtension import com.jetbrains.toolbox.api.remoteDev.RemoteProvider import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper +import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager @@ -30,7 +32,9 @@ class CoderToolboxExtension : RemoteDevExtension { serviceLocator.getService(), serviceLocator.getService(), serviceLocator.getService(), + serviceLocator.getService(), serviceLocator.getService(), + serviceLocator.getService(), serviceLocator.getService(), serviceLocator.getService(), serviceLocator.getService(), diff --git a/src/main/kotlin/com/coder/toolbox/WorkspaceConnectionManager.kt b/src/main/kotlin/com/coder/toolbox/WorkspaceConnectionManager.kt new file mode 100644 index 0000000..9196729 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/WorkspaceConnectionManager.kt @@ -0,0 +1,22 @@ +package com.coder.toolbox + +object WorkspaceConnectionManager { + private val workspaceConnectionState = mutableMapOf() + + var shouldEstablishWorkspaceConnections = false + + fun allConnected(): Set = workspaceConnectionState.filter { it.value }.map { it.key }.toSet() + + fun collectStatuses(workspaces: Set) { + workspaces.forEach { register(it.id, it.isConnected()) } + } + + private fun register(wsId: String, isConnected: Boolean) { + workspaceConnectionState[wsId] = isConnected + } + + fun reset() { + workspaceConnectionState.clear() + shouldEstablishWorkspaceConnections = false + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/browser/BrowserUtil.kt b/src/main/kotlin/com/coder/toolbox/browser/BrowserUtil.kt index 000263c..37918b7 100644 --- a/src/main/kotlin/com/coder/toolbox/browser/BrowserUtil.kt +++ b/src/main/kotlin/com/coder/toolbox/browser/BrowserUtil.kt @@ -1,66 +1,19 @@ package com.coder.toolbox.browser -import com.coder.toolbox.util.OS -import com.coder.toolbox.util.getOS -import org.zeroturnaround.exec.ProcessExecutor +import com.coder.toolbox.util.toURL +import com.jetbrains.toolbox.api.core.os.LocalDesktopManager -class BrowserUtil { - companion object { - suspend fun browse(url: String, errorHandler: suspend (BrowserException) -> Unit) { - val os = getOS() - if (os == null) { - errorHandler(BrowserException("Failed to open the URL because we can't detect the OS")) - return - } - when (os) { - OS.LINUX -> linuxBrowse(url, errorHandler) - OS.MAC -> macBrowse(url, errorHandler) - OS.WINDOWS -> windowsBrowse(url, errorHandler) - } - } - private suspend fun linuxBrowse(url: String, errorHandler: suspend (BrowserException) -> Unit) { - try { - if (OS.LINUX.getDesktopEnvironment()?.uppercase()?.contains("GNOME") == true) { - exec("gnome-open", url) - } else { - exec("xdg-open", url) - } - } catch (e: Exception) { - errorHandler( - BrowserException( - "Failed to open URL because an error was encountered. Please make sure xdg-open from package xdg-utils is available!", - e - ) - ) - } - } - - private suspend fun macBrowse(url: String, errorHandler: suspend (BrowserException) -> Unit) { - try { - exec("open", url) - } catch (e: Exception) { - errorHandler(BrowserException("Failed to open URL because an error was encountered.", e)) - } - } - - private suspend fun windowsBrowse(url: String, errorHandler: suspend (BrowserException) -> Unit) { - try { - exec("cmd", "start \"$url\"") - } catch (e: Exception) { - errorHandler(BrowserException("Failed to open URL because an error was encountered.", e)) - } - } - - private fun exec(vararg args: String): String { - val stdout = - ProcessExecutor() - .command(*args) - .exitValues(0) - .readOutput(true) - .execute() - .outputUTF8() - return stdout - } +suspend fun LocalDesktopManager.browse(rawUrl: String, errorHandler: suspend (BrowserException) -> Unit) { + try { + val url = rawUrl.toURL() + this.openUrl(url) + } catch (e: Exception) { + errorHandler( + BrowserException( + "Failed to open $rawUrl because an error was encountered", + e + ) + ) } } \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt index c7e4297..e4ef501 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt @@ -32,7 +32,7 @@ import java.net.HttpURLConnection import java.net.URL import java.nio.file.Files import java.nio.file.Path -import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption import java.util.zip.GZIPInputStream import javax.net.ssl.HttpsURLConnection @@ -44,6 +44,8 @@ internal data class Version( @Json(name = "version") val version: String, ) +private const val DOWNLOADING_CODER_CLI = "Downloading Coder CLI..." + /** * Do as much as possible to get a valid, up-to-date CLI. * @@ -60,6 +62,7 @@ fun ensureCLI( context: CoderToolboxContext, deploymentURL: URL, buildVersion: String, + showTextProgress: (String) -> Unit ): CoderCLIManager { val settings = context.settingsStore.readOnly() val cli = CoderCLIManager(deploymentURL, context.logger, settings) @@ -76,9 +79,10 @@ fun ensureCLI( // If downloads are enabled download the new version. if (settings.enableDownloads) { - context.logger.info("Downloading Coder CLI...") + context.logger.info(DOWNLOADING_CODER_CLI) + showTextProgress(DOWNLOADING_CODER_CLI) try { - cli.download() + cli.download(buildVersion, showTextProgress) return cli } catch (e: java.nio.file.AccessDeniedException) { // Might be able to fall back to the data directory. @@ -98,8 +102,9 @@ fun ensureCLI( } if (settings.enableDownloads) { - context.logger.info("Downloading Coder CLI...") - dataCLI.download() + context.logger.info(DOWNLOADING_CODER_CLI) + showTextProgress(DOWNLOADING_CODER_CLI) + dataCLI.download(buildVersion, showTextProgress) return dataCLI } @@ -137,7 +142,7 @@ class CoderCLIManager( /** * Download the CLI from the deployment if necessary. */ - fun download(): Boolean { + fun download(buildVersion: String, showTextProgress: (String) -> Unit): Boolean { val eTag = getBinaryETag() val conn = remoteBinaryURL.openConnection() as HttpURLConnection if (!settings.headerCommand.isNullOrBlank()) { @@ -162,13 +167,27 @@ class CoderCLIManager( when (conn.responseCode) { HttpURLConnection.HTTP_OK -> { logger.info("Downloading binary to $localBinaryPath") + Files.deleteIfExists(localBinaryPath) Files.createDirectories(localBinaryPath.parent) - conn.inputStream.use { - Files.copy( - if (conn.contentEncoding == "gzip") GZIPInputStream(it) else it, - localBinaryPath, - StandardCopyOption.REPLACE_EXISTING, - ) + val outputStream = Files.newOutputStream( + localBinaryPath, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING + ) + val sourceStream = if (conn.isGzip()) GZIPInputStream(conn.inputStream) else conn.inputStream + + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytesRead: Int + var totalRead = 0L + + sourceStream.use { source -> + outputStream.use { sink -> + while (source.read(buffer).also { bytesRead = it } != -1) { + sink.write(buffer, 0, bytesRead) + totalRead += bytesRead + showTextProgress("${settings.defaultCliBinaryNameByOsAndArch} $buildVersion - ${totalRead.toHumanReadableSize()} downloaded") + } + } } if (getOS() != OS.WINDOWS) { localBinaryPath.toFile().setExecutable(true) @@ -178,6 +197,7 @@ class CoderCLIManager( HttpURLConnection.HTTP_NOT_MODIFIED -> { logger.info("Using cached binary at $localBinaryPath") + showTextProgress("Using cached binary") return false } } @@ -190,6 +210,21 @@ class CoderCLIManager( throw ResponseException("Unexpected response from $remoteBinaryURL", conn.responseCode) } + private fun HttpURLConnection.isGzip(): Boolean = this.contentEncoding.equals("gzip", ignoreCase = true) + + fun Long.toHumanReadableSize(): String { + if (this < 1024) return "$this B" + + val kb = this / 1024.0 + if (kb < 1024) return String.format("%.1f KB", kb) + + val mb = kb / 1024.0 + if (mb < 1024) return String.format("%.1f MB", mb) + + val gb = mb / 1024.0 + return String.format("%.1f GB", gb) + } + /** * Return the entity tag for the binary on disk, if any. */ @@ -203,7 +238,7 @@ class CoderCLIManager( } /** - * Use the provided token to authenticate the CLI. + * Use the provided token to initializeSession the CLI. */ fun login(token: String): String { logger.info("Storing CLI credentials in $coderConfigPath") @@ -271,14 +306,13 @@ class CoderCLIManager( "ssh", "--stdio", if (settings.disableAutostart && feats.disableAutostart) "--disable-autostart" else null, + "--network-info-dir ${escape(settings.networkInfoDir)}" ) val proxyArgs = baseArgs + listOfNotNull( if (!settings.sshLogDirectory.isNullOrBlank()) "--log-dir" else null, if (!settings.sshLogDirectory.isNullOrBlank()) escape(settings.sshLogDirectory!!) else null, if (feats.reportWorkspaceUsage) "--usage-app=jetbrains" else null, ) - val backgroundProxyArgs = - baseArgs + listOfNotNull(if (feats.reportWorkspaceUsage) "--usage-app=disable" else null) val extraConfig = if (!settings.sshConfigOptions.isNullOrBlank()) { "\n" + settings.sshConfigOptions!!.prependIndent(" ") @@ -301,19 +335,8 @@ class CoderCLIManager( """.trimIndent() .plus("\n" + options.prependIndent(" ")) .plus(extraConfig) - .plus("\n\n") - .plus( - """ - Host ${getBackgroundHostnamePrefix(deploymentURL)}--* - ProxyCommand ${backgroundProxyArgs.joinToString(" ")} --ssh-host-prefix ${ - getBackgroundHostnamePrefix( - deploymentURL - ) - }-- %h - """.trimIndent() - .plus("\n" + options.prependIndent(" ")) - .plus(extraConfig), - ).replace("\n", System.lineSeparator()) + + .plus("\n") + .replace("\n", System.lineSeparator()) + System.lineSeparator() + endBlock } else { wsWithAgents.joinToString( @@ -328,19 +351,7 @@ class CoderCLIManager( .plus("\n" + options.prependIndent(" ")) .plus(extraConfig) .plus("\n") - .plus( - """ - Host ${getBackgroundHostname(deploymentURL, it.workspace(), it.agent())} - ProxyCommand ${backgroundProxyArgs.joinToString(" ")} ${ - getWsByOwner( - it.workspace(), - it.agent() - ) - } - """.trimIndent() - .plus("\n" + options.prependIndent(" ")) - .plus(extraConfig), - ).replace("\n", System.lineSeparator()) + .replace("\n", System.lineSeparator()) }, ) } @@ -519,17 +530,11 @@ class CoderCLIManager( } } - fun getBackgroundHostname(url: URL, ws: Workspace, agent: WorkspaceAgent): String { - return "${getHostname(url, ws, agent)}--bg" - } - companion object { private val tokenRegex = "--token [^ ]+".toRegex() private fun getHostnamePrefix(url: URL): String = "coder-jetbrains-toolbox-${url.safeHost()}" - private fun getBackgroundHostnamePrefix(url: URL): String = "coder-jetbrains-toolbox-${url.safeHost()}-bg" - private fun getWsByOwner(ws: Workspace, agent: WorkspaceAgent): String = "${ws.ownerName}/${ws.name}.${agent.name}" diff --git a/src/main/kotlin/com/coder/toolbox/cli/SshCommandProcessHandle.kt b/src/main/kotlin/com/coder/toolbox/cli/SshCommandProcessHandle.kt new file mode 100644 index 0000000..1d36e4f --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/cli/SshCommandProcessHandle.kt @@ -0,0 +1,42 @@ +package com.coder.toolbox.cli + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.sdk.v2.models.Workspace +import com.coder.toolbox.sdk.v2.models.WorkspaceAgent +import kotlin.jvm.optionals.getOrNull + +/** + * Identifies the PID for the SSH Coder command spawned by Toolbox. + */ +class SshCommandProcessHandle(private val ctx: CoderToolboxContext) { + + /** + * Finds the PID of a Coder (not the proxy command) ssh cmd associated with the specified workspace and agent. + * Null is returned when no ssh command process was found. + * + * Implementation Notes: + * An iterative DFS approach where we start with Toolbox's direct children, grep the command + * and if nothing is found we continue with the processes children. Toolbox spawns an ssh command + * as a separate command which in turns spawns another child for the proxy command. + */ + fun findByWorkspaceAndAgent(ws: Workspace, agent: WorkspaceAgent): Long? { + val stack = ArrayDeque(ProcessHandle.current().children().toList()) + while (stack.isNotEmpty()) { + val processHandle = stack.removeLast() + val cmdLine = processHandle.info().commandLine().getOrNull() + ctx.logger.debug("SSH command PID: ${processHandle.pid()} Command: $cmdLine") + if (cmdLine != null && cmdLine.isSshCommandFor(ws, agent)) { + ctx.logger.debug("SSH command with PID: ${processHandle.pid()} and Command: $cmdLine matches ${ws.name}.${agent.name}") + return processHandle.pid() + } else { + stack.addAll(processHandle.children().toList()) + } + } + return null + } + + private fun String.isSshCommandFor(ws: Workspace, agent: WorkspaceAgent): Boolean { + // usage-app is present only in the ProxyCommand + return !this.contains("--usage-app=jetbrains") && this.contains("${ws.name}.${agent.name}") + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt b/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt index 7a67b36..cc04dfe 100644 --- a/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt +++ b/src/main/kotlin/com/coder/toolbox/models/WorkspaceAndAgentStatus.kt @@ -7,10 +7,13 @@ import com.coder.toolbox.sdk.v2.models.WorkspaceAgentLifecycleState import com.coder.toolbox.sdk.v2.models.WorkspaceAgentStatus import com.coder.toolbox.sdk.v2.models.WorkspaceStatus import com.jetbrains.toolbox.api.core.ui.color.StateColor -import com.jetbrains.toolbox.api.remoteDev.states.CustomRemoteEnvironmentState +import com.jetbrains.toolbox.api.remoteDev.states.CustomRemoteEnvironmentStateV2 import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateIcons import com.jetbrains.toolbox.api.remoteDev.states.StandardRemoteEnvironmentState + +private val CircularSpinner: EnvironmentStateIcons = EnvironmentStateIcons.Connecting + /** * WorkspaceAndAgentStatus represents the combined status of a single agent and * its workspace (or just the workspace if there are no agents). @@ -58,9 +61,9 @@ enum class WorkspaceAndAgentStatus(val label: String, val description: String) { * Note that a reachable environment will always display "connected" or * "disconnected" regardless of the label we give that status. */ - fun toRemoteEnvironmentState(context: CoderToolboxContext): CustomRemoteEnvironmentState { - return CustomRemoteEnvironmentState( - label, + fun toRemoteEnvironmentState(context: CoderToolboxContext): CustomRemoteEnvironmentStateV2 { + return CustomRemoteEnvironmentStateV2( + context.i18n.pnotr(label), color = getStateColor(context), reachable = ready() || unhealthy(), // TODO@JB: How does this work? Would like a spinner for pending states. @@ -69,23 +72,34 @@ enum class WorkspaceAndAgentStatus(val label: String, val description: String) { } private fun getStateColor(context: CoderToolboxContext): StateColor { - return if (ready()) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Active) - else if (unhealthy()) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Unhealthy) - else if (canStart()) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Failed) - else if (pending()) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Activating) + return if (this == FAILED) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.FailedToStart) else if (this == DELETING) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Deleting) else if (this == DELETED) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Deleted) + else if (ready()) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Active) + else if (unhealthy()) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Unhealthy) + else if (canStart() || this == STOPPING) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Hibernating) + else if (pending()) context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Activating) else context.envStateColorPalette.getColor(StandardRemoteEnvironmentState.Unreachable) } private fun getStateIcon(): EnvironmentStateIcons { - return if (ready() || unhealthy()) EnvironmentStateIcons.Active - else if (canStart()) EnvironmentStateIcons.Hibernated - else if (pending()) EnvironmentStateIcons.Connecting - else if (this == DELETING || this == DELETED) EnvironmentStateIcons.Offline + return if (this == FAILED) EnvironmentStateIcons.Error + else if (pending() || this == DELETING || this == DELETED || this == STOPPING) CircularSpinner + else if (ready() || unhealthy()) EnvironmentStateIcons.Active + else if (canStart()) EnvironmentStateIcons.Offline else EnvironmentStateIcons.NoIcon } + fun toSshConnectingEnvState(context: CoderToolboxContext): CustomRemoteEnvironmentStateV2 { + val existingState = toRemoteEnvironmentState(context) + return CustomRemoteEnvironmentStateV2( + context.i18n.pnotr("SSHing"), + existingState.color, + existingState.isReachable, + EnvironmentStateIcons.Connecting + ) + } + /** * Return true if the agent is in a connectable state. */ diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 6785675..365e1ed 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -131,12 +131,11 @@ open class CoderRestClient( } /** - * Authenticate and load information about the current user and the build - * version. + * Load information about the current user and the build version. * * @throws [APIResponseException]. */ - suspend fun authenticate(): User { + suspend fun initializeSession(): User { me = me() buildVersion = buildInfo().version return me @@ -149,7 +148,12 @@ open class CoderRestClient( suspend fun me(): User { val userResponse = retroRestClient.me() if (!userResponse.isSuccessful) { - throw APIResponseException("authenticate", url, userResponse.code(), userResponse.parseErrorBody(moshi)) + throw APIResponseException( + "initializeSession", + url, + userResponse.code(), + userResponse.parseErrorBody(moshi) + ) } return userResponse.body()!! @@ -192,12 +196,12 @@ open class CoderRestClient( } /** - * Maps the list of workspaces to the associated agents. + * Maps the available workspaces to the associated agents. */ - suspend fun groupByAgents(workspaces: List): Set> { + suspend fun workspacesByAgents(): Set> { // It is possible for there to be resources with duplicate names so we // need to use a set. - return workspaces.flatMap { ws -> + return workspaces().flatMap { ws -> when (ws.latestBuild.status) { WorkspaceStatus.RUNNING -> ws.latestBuild.resources else -> resources(ws) diff --git a/src/main/kotlin/com/coder/toolbox/sdk/ex/APIResponseException.kt b/src/main/kotlin/com/coder/toolbox/sdk/ex/APIResponseException.kt index 9f78198..d109c75 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/ex/APIResponseException.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/ex/APIResponseException.kt @@ -8,8 +8,9 @@ import java.net.URL class APIResponseException(action: String, url: URL, code: Int, errorResponse: ApiErrorResponse?) : IOException(formatToPretty(action, url, code, errorResponse)) { - + val reason = errorResponse?.detail val isUnauthorized = HttpURLConnection.HTTP_UNAUTHORIZED == code + val isTokenExpired = isUnauthorized && reason?.contains("API key expired") == true companion object { private fun formatToPretty( diff --git a/src/main/kotlin/com/coder/toolbox/sdk/v2/models/NetworkMetrics.kt b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/NetworkMetrics.kt new file mode 100644 index 0000000..cb7d235 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/v2/models/NetworkMetrics.kt @@ -0,0 +1,49 @@ +package com.coder.toolbox.sdk.v2.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import java.text.DecimalFormat + +private val formatter = DecimalFormat("#.00") + +/** + * Coder ssh network metrics. All properties are optional + * because Coder Connect only populates `using_coder_connect` + * while p2p doesn't populate this property. + */ +@JsonClass(generateAdapter = true) +data class NetworkMetrics( + @Json(name = "p2p") + val p2p: Boolean?, + + @Json(name = "latency") + val latency: Double?, + + @Json(name = "preferred_derp") + val preferredDerp: String?, + + @Json(name = "derp_latency") + val derpLatency: Map?, + + @Json(name = "upload_bytes_sec") + val uploadBytesSec: Long?, + + @Json(name = "download_bytes_sec") + val downloadBytesSec: Long?, + + @Json(name = "using_coder_connect") + val usingCoderConnect: Boolean? +) { + fun toPretty(): String { + if (usingCoderConnect == true) { + return "You're connected using Coder Connect" + } + return if (p2p == true) { + "Direct (${formatter.format(latency)}ms). You're connected peer-to-peer" + } else { + val derpLatency = derpLatency!![preferredDerp] + val workspaceLatency = latency!!.minus(derpLatency!!) + "You ↔ $preferredDerp (${formatter.format(derpLatency)}ms) ↔ Workspace (${formatter.format(workspaceLatency)}ms). You are connected through a relay" + } + } +} diff --git a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt index 25568d3..4d17c09 100644 --- a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt +++ b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt @@ -10,7 +10,7 @@ interface ReadOnlyCoderSettings { /** * The default URL to show in the connection window. */ - val defaultURL: String? + val defaultURL: String /** * Used to download the Coder CLI which is necessary to proxy SSH @@ -110,15 +110,11 @@ interface ReadOnlyCoderSettings { */ val sshConfigOptions: String? - /** - * The default URL to show in the connection window. - */ - fun defaultURL(): Pair? /** - * Given a deployment URL, try to find a token for it if required. + * The path where network information for SSH hosts are stored */ - fun token(deploymentURL: URL): Pair? + val networkInfoDir: String /** * Where the specified deployment should put its data. diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt index e82402e..3170a06 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt @@ -1,6 +1,7 @@ package com.coder.toolbox.store import com.jetbrains.toolbox.api.core.PluginSecretStore +import java.net.URL /** @@ -26,4 +27,10 @@ class CoderSecretsStore(private val store: PluginSecretStore) { var rememberMe: Boolean get() = get("remember-me").toBoolean() set(value) = set("remember-me", value.toString()) + + fun tokenFor(url: URL): String? = store[url.host] + + fun storeTokenFor(url: URL, token: String) { + store[url.host] = token + } } diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt index 92c08d0..d08e8d6 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -3,7 +3,6 @@ package com.coder.toolbox.store import com.coder.toolbox.settings.Environment import com.coder.toolbox.settings.ReadOnlyCoderSettings import com.coder.toolbox.settings.ReadOnlyTLSSettings -import com.coder.toolbox.settings.SettingSource import com.coder.toolbox.util.Arch import com.coder.toolbox.util.OS import com.coder.toolbox.util.expand @@ -35,7 +34,7 @@ class CoderSettingsStore( ) : ReadOnlyTLSSettings // Properties implementation - override val defaultURL: String? get() = store[DEFAULT_URL] + override val defaultURL: String get() = store[DEFAULT_URL] ?: "https://dev.coder.com" override val binarySource: String? get() = store[BINARY_SOURCE] override val binaryDirectory: String? get() = store[BINARY_DIRECTORY] override val defaultCliBinaryNameByOsAndArch: String get() = getCoderCLIForOS(getOS(), getArch()) @@ -65,48 +64,11 @@ class CoderSettingsStore( override val sshLogDirectory: String? get() = store[SSH_LOG_DIR] override val sshConfigOptions: String? get() = store[SSH_CONFIG_OPTIONS].takeUnless { it.isNullOrEmpty() } ?: env.get(CODER_SSH_CONFIG_OPTIONS) - - /** - * The default URL to show in the connection window. - */ - override fun defaultURL(): Pair? { - val envURL = env.get(CODER_URL) - if (!defaultURL.isNullOrEmpty()) { - return defaultURL!! to SettingSource.SETTINGS - } else if (envURL.isNotBlank()) { - return envURL to SettingSource.ENVIRONMENT - } else { - val (configUrl, _) = readConfig(Path.of(globalConfigDir)) - if (!configUrl.isNullOrBlank()) { - return configUrl to SettingSource.CONFIG - } - } - return null - } - - /** - * Given a deployment URL, try to find a token for it if required. - */ - override fun token(deploymentURL: URL): Pair? { - // No need to bother if we do not need token auth anyway. - if (!requireTokenAuth) { - return null - } - // Try the deployment's config directory. This could exist if someone - // has entered a URL that they are not currently connected to, but have - // connected to in the past. - val (_, deploymentToken) = readConfig(dataDir(deploymentURL).resolve("config")) - if (!deploymentToken.isNullOrBlank()) { - return deploymentToken to SettingSource.DEPLOYMENT_CONFIG - } - // Try the global config directory, in case they previously set up the - // CLI with this URL. - val (configUrl, configToken) = readConfig(Path.of(globalConfigDir)) - if (configUrl == deploymentURL.toString() && !configToken.isNullOrBlank()) { - return configToken to SettingSource.CONFIG - } - return null - } + override val networkInfoDir: String + get() = store[NETWORK_INFO_DIR].takeUnless { it.isNullOrEmpty() } ?: getDefaultGlobalDataDir() + .resolve("ssh-network-metrics") + .normalize() + .toString() /** * Where the specified deployment should put its data. @@ -232,6 +194,10 @@ class CoderSettingsStore( store[SSH_LOG_DIR] = path } + fun updateNetworkInfoDir(path: String) { + store[NETWORK_INFO_DIR] = path + } + fun updateSshConfigOptions(options: String) { store[SSH_CONFIG_OPTIONS] = options } diff --git a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt index 35040e3..e34436f 100644 --- a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt +++ b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt @@ -38,3 +38,5 @@ internal const val SSH_LOG_DIR = "sshLogDir" internal const val SSH_CONFIG_OPTIONS = "sshConfigOptions" +internal const val NETWORK_INFO_DIR = "networkInfoDir" + diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index aca2464..7d24029 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -9,20 +9,24 @@ import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent import com.coder.toolbox.sdk.v2.models.WorkspaceStatus -import com.jetbrains.toolbox.api.localization.LocalizableString +import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper +import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.time.withTimeout -import java.net.HttpURLConnection import java.net.URI -import java.net.URL +import java.util.UUID +import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration +private const val CAN_T_HANDLE_URI_TITLE = "Can't handle URI" +private val noOpTextProgress: (String) -> Unit = { _ -> } + +@Suppress("UnstableApiUsage") open class CoderProtocolHandler( private val context: CoderToolboxContext, private val dialogUi: DialogUi, @@ -40,119 +44,269 @@ open class CoderProtocolHandler( suspend fun handle( uri: URI, shouldWaitForAutoLogin: Boolean, + markAsBusy: () -> Unit, + unmarkAsBusy: () -> Unit, reInitialize: suspend (CoderRestClient, CoderCLIManager) -> Unit ) { - context.popupPluginMainPage() val params = uri.toQueryParameters() if (params.isEmpty()) { // probably a plugin installation scenario + context.logAndShowInfo("URI will not be handled", "No query parameters were provided") return } + if (shouldWaitForAutoLogin) { + isInitialized.waitForTrue() + } + + context.logger.info("Handling $uri...") + val deploymentURL = resolveDeploymentUrl(params) ?: return + val token = resolveToken(params) ?: return + val workspaceName = resolveWorkspaceName(params) ?: return + val restClient = buildRestClient(deploymentURL, token) ?: return + val workspace = restClient.workspaces().matchName(workspaceName, deploymentURL) ?: return + + val cli = configureCli(deploymentURL, restClient) + reInitialize(restClient, cli) + + var agent: WorkspaceAgent + try { + markAsBusy() + context.refreshMainPage() + if (!prepareWorkspace(workspace, restClient, workspaceName, deploymentURL)) return + // we resolve the agent after the workspace is started otherwise we can get misleading + // errors like: no agent available while workspace is starting or stopping + // we also need to retrieve the workspace again to have the latest resources (ex: agent) + // attached to the workspace. + agent = resolveAgent( + params, + restClient.workspace(workspace.id) + ) ?: return + if (!ensureAgentIsReady(workspace, agent)) return + } finally { + unmarkAsBusy() + } + val environmentId = "${workspace.name}.${agent.name}" + context.showEnvironmentPage(environmentId) + + val productCode = params.ideProductCode() + val buildNumber = params.ideBuildNumber() + val projectFolder = params.projectFolder() + + if (!productCode.isNullOrBlank() && !buildNumber.isNullOrBlank()) { + launchIde(environmentId, productCode, buildNumber, projectFolder) + } + } + + private suspend fun resolveDeploymentUrl(params: Map): String? { val deploymentURL = params.url() ?: askUrl() if (deploymentURL.isNullOrBlank()) { - context.logger.error("Query parameter \"$URL\" is missing from URI $uri") - context.showErrorPopup(MissingArgumentException("Can't handle URI because query parameter \"$URL\" is missing")) - return + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"$URL\" is missing from URI") + return null } + return deploymentURL + } - val queryToken = params.token() - val restClient = try { - authenticate(deploymentURL, queryToken) - } catch (ex: Exception) { - context.logger.error(ex, "Query parameter \"$TOKEN\" is missing from URI $uri") - context.showErrorPopup(IllegalStateException(humanizeConnectionError(deploymentURL.toURL(), true, ex))) - return + private suspend fun resolveToken(params: Map): String? { + val token = params.token() + if (token.isNullOrBlank()) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"$TOKEN\" is missing from URI") + return null } + return token + } - // TODO: Show a dropdown and ask for the workspace if missing. Right now it's not possible because dialogs are quite limited - val workspaceName = params.workspace() - if (workspaceName.isNullOrBlank()) { - context.logger.error("Query parameter \"$WORKSPACE\" is missing from URI $uri") - context.showErrorPopup(MissingArgumentException("Can't handle URI because query parameter \"$WORKSPACE\" is missing")) - return + private suspend fun resolveWorkspaceName(params: Map): String? { + val workspace = params.workspace() + if (workspace.isNullOrBlank()) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"$WORKSPACE\" is missing from URI") + return null } + return workspace + } + + private suspend fun buildRestClient(deploymentURL: String, token: String): CoderRestClient? { + try { + return authenticate(deploymentURL, token) + } catch (ex: Exception) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, humanizeConnectionError(deploymentURL.toURL(), true, ex)) + return null + } + } + + /** + * Returns an authenticated Coder CLI. + */ + private suspend fun authenticate(deploymentURL: String, token: String): CoderRestClient { + val client = CoderRestClient( + context, + deploymentURL.toURL(), + if (settings.requireTokenAuth) token else null, + PluginManager.pluginInfo.version + ) + client.initializeSession() + return client + } - val workspaces = restClient.workspaces() - val workspace = workspaces.firstOrNull { it.name == workspaceName } + private suspend fun List.matchName(workspaceName: String, deploymentURL: String): Workspace? { + val workspace = this.firstOrNull { it.name == workspaceName } if (workspace == null) { - context.logger.error("There is no workspace with name $workspaceName on $deploymentURL") - context.showErrorPopup(MissingArgumentException("Can't handle URI because workspace with name $workspaceName does not exist")) - return + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "There is no workspace with name $workspaceName on $deploymentURL" + ) + return null } + return workspace + } + private suspend fun prepareWorkspace( + workspace: Workspace, + restClient: CoderRestClient, + workspaceName: String, + deploymentURL: String + ): Boolean { when (workspace.latestBuild.status) { WorkspaceStatus.PENDING, WorkspaceStatus.STARTING -> - if (restClient.waitForReady(workspace) != true) { - context.logger.error("$workspaceName from $deploymentURL could not be ready on time") - context.showErrorPopup(MissingArgumentException("Can't handle URI because workspace $workspaceName could not be ready on time")) - return + if (!restClient.waitForReady(workspace)) { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "$workspaceName from $deploymentURL could not be ready on time" + ) + return false } WorkspaceStatus.STOPPING, WorkspaceStatus.STOPPED, WorkspaceStatus.CANCELING, WorkspaceStatus.CANCELED -> { if (settings.disableAutostart) { - context.logger.warn("$workspaceName from $deploymentURL is not started and autostart is disabled.") - context.showInfoPopup( - context.i18n.pnotr("$workspaceName is not running"), - context.i18n.ptrl("Can't handle URI because workspace is not running and autostart is disabled. Please start the workspace manually and execute the URI again."), - context.i18n.ptrl("OK") + context.logAndShowWarning( + CAN_T_HANDLE_URI_TITLE, + "$workspaceName from $deploymentURL is not running and autostart is disabled" ) - return + return false } try { - restClient.startWorkspace(workspace) + if (workspace.outdated) { + restClient.updateWorkspace(workspace) + } else { + restClient.startWorkspace(workspace) + } } catch (e: Exception) { - context.logger.error( - e, - "$workspaceName from $deploymentURL could not be started while handling URI" + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "$workspaceName from $deploymentURL could not be started", + e ) - context.showErrorPopup(MissingArgumentException("Can't handle URI because an error was encountered while trying to start workspace $workspaceName")) - return + return false } - if (restClient.waitForReady(workspace) != true) { - context.logger.error("$workspaceName from $deploymentURL could not be started on time") - context.showErrorPopup(MissingArgumentException("Can't handle URI because workspace $workspaceName could not be started on time")) - return + + if (!restClient.waitForReady(workspace)) { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "$workspaceName from $deploymentURL could not be started on time", + ) + return false } } WorkspaceStatus.FAILED, WorkspaceStatus.DELETING, WorkspaceStatus.DELETED -> { - context.logger.error("Unable to connect to $workspaceName from $deploymentURL") - context.showErrorPopup(MissingArgumentException("Can't handle URI because because we're unable to connect to workspace $workspaceName")) - return + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "Unable to connect to $workspaceName from $deploymentURL" + ) + return false } - WorkspaceStatus.RUNNING -> Unit // All is well + WorkspaceStatus.RUNNING -> return true // All is well } + return true + } - // TODO: Show a dropdown and ask for an agent if missing. - val agent: WorkspaceAgent + private suspend fun resolveAgent( + params: Map, + workspace: Workspace + ): WorkspaceAgent? { try { - agent = getMatchingAgent(params, workspace) + return getMatchingAgent(params, workspace) } catch (e: IllegalArgumentException) { - context.logger.error(e, "Can't resolve an agent for workspace $workspaceName from $deploymentURL") - context.showErrorPopup( - MissingArgumentException( - "Can't handle URI because we can't resolve an agent for workspace $workspaceName from $deploymentURL", - e - ) + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "Can't resolve an agent for workspace ${workspace.name}", + e ) - return + return null + } + } + + /** + * Return the agent matching the provided agent ID or name in the parameters. + * + * @throws [IllegalArgumentException] + */ + internal suspend fun getMatchingAgent( + parameters: Map, + workspace: Workspace, + ): WorkspaceAgent? { + val agents = workspace.latestBuild.resources.filter { it.agents != null }.flatMap { it.agents!! } + if (agents.isEmpty()) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "The workspace \"${workspace.name}\" has no agents") + return null } + + // If the agent is missing and the workspace has only one, use that. + val agent = + if (!parameters.agentID().isNullOrBlank()) { + agents.firstOrNull { it.id.toString() == parameters.agentID() } + } else if (agents.size == 1) { + agents.first() + } else { + null + } + + if (agent == null) { + if (!parameters.agentID().isNullOrBlank()) { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "The workspace \"${workspace.name}\" does not have an agent with ID \"${parameters.agentID()}\"" + ) + return null + } else { + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "Unable to determine which agent to connect to; \"$AGENT_ID\" must be set because the workspace \"${workspace.name}\" has more than one agent" + ) + return null + } + } + return agent + } + + private suspend fun ensureAgentIsReady( + workspace: Workspace, + agent: WorkspaceAgent + ): Boolean { val status = WorkspaceAndAgentStatus.from(workspace, agent) if (!status.ready()) { - context.logger.error("Agent ${agent.name} for workspace $workspaceName from $deploymentURL is not ready") - context.showErrorPopup(MissingArgumentException("Can't handle URI because agent ${agent.name} for workspace $workspaceName from $deploymentURL is not ready")) - return + context.logAndShowError( + CAN_T_HANDLE_URI_TITLE, + "Agent ${agent.name} for workspace ${workspace.name} is not ready" + ) + return false } + return true + } + private suspend fun configureCli( + deploymentURL: String, + restClient: CoderRestClient + ): CoderCLIManager { val cli = ensureCLI( context, deploymentURL.toURL(), - restClient.buildInfo().version + restClient.buildInfo().version, + noOpTextProgress ) // We only need to log in if we are using token-based auth. @@ -162,33 +316,94 @@ open class CoderProtocolHandler( } context.logger.info("Configuring Coder CLI...") - cli.configSsh(restClient.groupByAgents(workspaces)) + cli.configSsh(restClient.workspacesByAgents()) + return cli + } - if (shouldWaitForAutoLogin) { - isInitialized.waitForTrue() + private fun launchIde( + environmentId: String, + productCode: String, + buildNumber: String, + projectFolder: String? + ) { + context.cs.launch { + val selectedIde = selectAndInstallRemoteIde(productCode, buildNumber, environmentId) ?: return@launch + context.logger.info("$productCode-$buildNumber is already on $environmentId. Going to launch JBClient") + installJBClient(selectedIde, environmentId).join() + launchJBClient(selectedIde, environmentId, projectFolder) } - reInitialize(restClient, cli) + } - val environmentId = "${workspace.name}.${agent.name}" - context.popupPluginMainPage() - context.envPageManager.showEnvironmentPage(environmentId, false) - val productCode = params.ideProductCode() - val buildNumber = params.ideBuildNumber() - val projectFolder = params.projectFolder() - if (!productCode.isNullOrBlank() && !buildNumber.isNullOrBlank()) { - context.cs.launch { - val ideVersion = "$productCode-$buildNumber" - context.logger.info("installing $ideVersion on $environmentId") - val job = context.cs.launch { - context.ideOrchestrator.prepareClient(environmentId, ideVersion) - } - job.join() - context.logger.info("launching $ideVersion on $environmentId") - context.ideOrchestrator.connectToIde(environmentId, ideVersion, projectFolder) + private suspend fun selectAndInstallRemoteIde( + productCode: String, + buildNumber: String, + environmentId: String + ): String? { + val installedIdes = context.remoteIdeOrchestrator.getInstalledRemoteTools(environmentId, productCode) + + var selectedIde = "$productCode-$buildNumber" + if (installedIdes.firstOrNull { it.contains(buildNumber) } != null) { + context.logger.info("$selectedIde is already installed on $environmentId") + return selectedIde + } + + selectedIde = resolveAvailableIde(environmentId, productCode, buildNumber) ?: return null + + // needed otherwise TBX will install it again + if (!installedIdes.contains(selectedIde)) { + context.logger.info("Installing $selectedIde on $environmentId...") + context.remoteIdeOrchestrator.installRemoteTool(environmentId, selectedIde) + + if (context.remoteIdeOrchestrator.waitForIdeToBeInstalled(environmentId, selectedIde)) { + context.logger.info("Successfully installed $selectedIde on $environmentId...") + return selectedIde + } else { + context.ui.showSnackbar( + UUID.randomUUID().toString(), + context.i18n.pnotr("$selectedIde could not be installed"), + context.i18n.pnotr("$selectedIde could not be installed on time. Check the logs for more details"), + context.i18n.ptrl("OK") + ) + return null } + } else { + context.logger.info("$selectedIde is already present on $environmentId...") + return selectedIde } } + private suspend fun resolveAvailableIde(environmentId: String, productCode: String, buildNumber: String): String? { + val availableVersions = context + .remoteIdeOrchestrator + .getAvailableRemoteTools(environmentId, productCode) + + if (availableVersions.isEmpty()) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "$productCode is not available on $environmentId") + return null + } + + val buildNumberIsNotAvailable = availableVersions.firstOrNull { it.contains(buildNumber) } == null + if (buildNumberIsNotAvailable) { + val selectedIde = availableVersions.maxOf { it } + context.logAndShowInfo( + "$productCode-$buildNumber not available", + "$productCode-$buildNumber is not available, we've selected the latest $selectedIde" + ) + return selectedIde + } + return "$productCode-$buildNumber" + } + + private fun installJBClient(selectedIde: String, environmentId: String): Job = context.cs.launch { + context.logger.info("Downloading and installing JBClient counterpart to $selectedIde locally") + context.jbClientOrchestrator.prepareClient(environmentId, selectedIde) + } + + private fun launchJBClient(selectedIde: String, environmentId: String, projectFolder: String?) { + context.logger.info("Launching $selectedIde on $environmentId") + context.jbClientOrchestrator.connectToIde(environmentId, selectedIde, projectFolder) + } + private suspend fun CoderRestClient.waitForReady(workspace: Workspace): Boolean { var status = workspace.latestBuild.status try { @@ -204,6 +419,25 @@ open class CoderProtocolHandler( } } + private suspend fun RemoteToolsHelper.waitForIdeToBeInstalled( + environmentId: String, + ideHint: String, + waitTime: Duration = 2.minutes + ): Boolean { + var isInstalled = false + try { + withTimeout(waitTime.toJavaDuration()) { + while (!isInstalled) { + delay(5.seconds) + isInstalled = getInstalledRemoteTools(environmentId, ideHint).isNotEmpty() + } + } + return true + } catch (_: TimeoutCancellationException) { + return false + } + } + private suspend fun askUrl(): String? { context.popupPluginMainPage() return dialogUi.ask( @@ -211,129 +445,17 @@ open class CoderProtocolHandler( context.i18n.ptrl("Enter the full URL of your Coder deployment") ) } - - /** - * Return an authenticated Coder CLI, asking for the token. - * Throw MissingArgumentException if the user aborts. Any network or invalid - * token error may also be thrown. - */ - private suspend fun authenticate( - deploymentURL: String, - tryToken: String? - ): CoderRestClient { - val token = - if (settings.requireTokenAuth) { - // Try the provided token immediately on the first attempt. - if (!tryToken.isNullOrBlank()) { - tryToken - } else { - context.popupPluginMainPage() - // Otherwise ask for a new token, showing the previous token. - dialogUi.askToken(deploymentURL.toURL()) - } - } else { - null - } - - if (settings.requireTokenAuth && token == null) { // User aborted. - throw MissingArgumentException("Token is required") - } - val client = CoderRestClient( - context, - deploymentURL.toURL(), - token, - PluginManager.pluginInfo.version - ) - client.authenticate() - return client - } - } -/** - * Follow a URL's redirects to its final destination. - */ -internal fun resolveRedirects(url: URL): URL { - var location = url - val maxRedirects = 10 - for (i in 1..maxRedirects) { - val conn = location.openConnection() as HttpURLConnection - conn.instanceFollowRedirects = false - conn.connect() - val code = conn.responseCode - val nextLocation = conn.getHeaderField("Location") - conn.disconnect() - // Redirects are triggered by any code starting with 3 plus a - // location header. - if (code < 300 || code >= 400 || nextLocation.isNullOrBlank()) { - return location - } - // Location headers might be relative. - location = URL(location, nextLocation) - } - throw Exception("Too many redirects") -} - -/** - * Return the agent matching the provided agent ID or name in the parameters. - * - * @throws [IllegalArgumentException] - */ -internal fun getMatchingAgent( - parameters: Map, - workspace: Workspace, -): WorkspaceAgent { - val agents = workspace.latestBuild.resources.filter { it.agents != null }.flatMap { it.agents!! } - if (agents.isEmpty()) { - throw IllegalArgumentException("The workspace \"${workspace.name}\" has no agents") - } - - // If the agent is missing and the workspace has only one, use that. - // Prefer the ID over the name if both are set. - val agent = - if (!parameters.agentID().isNullOrBlank()) { - agents.firstOrNull { it.id.toString() == parameters.agentID() } - } else if (agents.size == 1) { - agents.first() - } else { - null - } - - if (agent == null) { - if (!parameters.agentID().isNullOrBlank()) { - throw IllegalArgumentException("The workspace \"${workspace.name}\" does not have an agent with ID \"${parameters.agentID()}\"") - } else { - throw MissingArgumentException( - "Unable to determine which agent to connect to; \"$AGENT_ID\" must be set because the workspace \"${workspace.name}\" has more than one agent", - ) - } - } - - return agent -} - -private suspend fun CoderToolboxContext.showErrorPopup(error: Throwable) { - popupPluginMainPage() - this.ui.showErrorInfoPopup(error) -} - -private suspend fun CoderToolboxContext.showInfoPopup( - title: LocalizableString, - message: LocalizableString, - okLabel: LocalizableString -) { - popupPluginMainPage() - this.ui.showInfoPopup(title, message, okLabel) -} private fun CoderToolboxContext.popupPluginMainPage() { this.ui.showWindow() this.envPageManager.showPluginEnvironmentsPage(true) } -/** - * Suspends the coroutine until first true value is received. - */ -suspend fun StateFlow.waitForTrue() = this.first { it } +private suspend fun CoderToolboxContext.showEnvironmentPage(envId: String) { + this.ui.showWindow() + this.envPageManager.showEnvironmentPage(envId, false) +} class MissingArgumentException(message: String, ex: Throwable? = null) : IllegalArgumentException(message, ex) diff --git a/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt b/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt index 44a3dfb..3678813 100644 --- a/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt +++ b/src/main/kotlin/com/coder/toolbox/util/Dialogs.kt @@ -1,10 +1,8 @@ package com.coder.toolbox.util import com.coder.toolbox.CoderToolboxContext -import com.coder.toolbox.browser.BrowserUtil import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.ui.components.TextType -import java.net.URL /** * Dialog implementation for standalone Gateway. @@ -23,47 +21,7 @@ class DialogUi(private val context: CoderToolboxContext) { placeholder: LocalizableString? = null, ): String? { return context.ui.showTextInputPopup( - title, - description, - placeholder, - TextType.General, - context.i18n.ptrl("OK"), - context.i18n.ptrl("Cancel") - ) - } - - suspend fun askPassword( - title: LocalizableString, - description: LocalizableString, - placeholder: LocalizableString? = null, - ): String? { - return context.ui.showTextInputPopup( - title, - description, - placeholder, - TextType.Password, - context.i18n.ptrl("OK"), - context.i18n.ptrl("Cancel") - ) - } - - private suspend fun openUrl(url: URL) { - BrowserUtil.browse(url.toString()) { - context.ui.showErrorInfoPopup(it) - } - } - - /** - * Open a dialog for providing the token. - */ - suspend fun askToken( - url: URL, - ): String? { - openUrl(url.withPath("/login?redirect=%2Fcli-auth")) - return askPassword( - title = context.i18n.ptrl("Session Token"), - description = context.i18n.pnotr("Please paste the session token from the web-page"), - placeholder = context.i18n.pnotr("") + title, description, placeholder, TextType.General, context.i18n.ptrl("OK"), context.i18n.ptrl("Cancel") ) } } diff --git a/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt b/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt index 1135227..0a15db8 100644 --- a/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt +++ b/src/main/kotlin/com/coder/toolbox/util/LinkMap.kt @@ -3,7 +3,6 @@ package com.coder.toolbox.util const val URL = "url" const val TOKEN = "token" const val WORKSPACE = "workspace" -const val AGENT_NAME = "agent" const val AGENT_ID = "agent_id" private const val IDE_PRODUCT_CODE = "ide_product_code" private const val IDE_BUILD_NUMBER = "ide_build_number" diff --git a/src/main/kotlin/com/coder/toolbox/util/StateFlowExtensions.kt b/src/main/kotlin/com/coder/toolbox/util/StateFlowExtensions.kt new file mode 100644 index 0000000..46ae602 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/util/StateFlowExtensions.kt @@ -0,0 +1,22 @@ +package com.coder.toolbox.util + +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.time.Duration + +/** + * Suspends the coroutine until first true value is received. + */ +suspend fun StateFlow.waitForTrue() = this.first { it } + +/** + * Suspends the coroutine until first false value is received. + */ +suspend fun StateFlow.waitForFalseWithTimeout(duration: Duration): Boolean? { + if (!this.value) return false + + return withTimeoutOrNull(duration) { + this@waitForFalseWithTimeout.first { !it } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/views/AuthWizardPage.kt b/src/main/kotlin/com/coder/toolbox/views/AuthWizardPage.kt deleted file mode 100644 index ca92ed7..0000000 --- a/src/main/kotlin/com/coder/toolbox/views/AuthWizardPage.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.coder.toolbox.views - -import com.coder.toolbox.CoderToolboxContext -import com.coder.toolbox.cli.CoderCLIManager -import com.coder.toolbox.sdk.CoderRestClient -import com.coder.toolbox.views.state.AuthWizardState -import com.coder.toolbox.views.state.WizardStep -import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription -import com.jetbrains.toolbox.api.ui.components.UiField -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update - -class AuthWizardPage( - private val context: CoderToolboxContext, - initialAutoLogin: Boolean = false, - onConnect: ( - client: CoderRestClient, - cli: CoderCLIManager, - ) -> Unit, -) : CoderPage(context, context.i18n.ptrl("Authenticate to Coder")) { - private val shouldAutoLogin = MutableStateFlow(initialAutoLogin) - - private val signInStep = SignInStep(context, this::notify) - private val tokenStep = TokenStep(context) - private val connectStep = ConnectStep(context, shouldAutoLogin, this::notify, this::displaySteps, onConnect) - - - /** - * Fields for this page, displayed in order. - */ - override val fields: MutableStateFlow> = MutableStateFlow(emptyList()) - override val actionButtons: MutableStateFlow> = MutableStateFlow(emptyList()) - - override fun beforeShow() { - displaySteps() - } - - private fun displaySteps() { - when (AuthWizardState.currentStep()) { - WizardStep.URL_REQUEST -> { - fields.update { - listOf(signInStep.panel) - } - actionButtons.update { - listOf( - Action(context.i18n.ptrl("Sign In"), closesPage = false, actionBlock = { - if (signInStep.onNext()) { - displaySteps() - } - }) - ) - } - signInStep.onVisible() - } - - WizardStep.TOKEN_REQUEST -> { - fields.update { - listOf(tokenStep.panel) - } - actionButtons.update { - listOf( - Action(context.i18n.ptrl("Connect"), closesPage = false, actionBlock = { - if (tokenStep.onNext()) { - displaySteps() - } - }), - Action(context.i18n.ptrl("Back"), closesPage = false, actionBlock = { - tokenStep.onBack() - displaySteps() - }) - ) - } - tokenStep.onVisible() - } - - WizardStep.LOGIN -> { - fields.update { - listOf(connectStep.panel) - } - actionButtons.update { - listOf( - Action(context.i18n.ptrl("Back"), closesPage = false, actionBlock = { - connectStep.onBack() - shouldAutoLogin.update { - false - } - displaySteps() - }) - ) - } - connectStep.onVisible() - } - } - } -} diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt new file mode 100644 index 0000000..c6193da --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/views/CoderCliSetupWizardPage.kt @@ -0,0 +1,159 @@ +package com.coder.toolbox.views + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.cli.CoderCLIManager +import com.coder.toolbox.sdk.CoderRestClient +import com.coder.toolbox.sdk.ex.APIResponseException +import com.coder.toolbox.util.toURL +import com.coder.toolbox.views.state.CoderCliSetupContext +import com.coder.toolbox.views.state.CoderCliSetupWizardState +import com.coder.toolbox.views.state.WizardStep +import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState +import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription +import com.jetbrains.toolbox.api.ui.components.UiField +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.util.UUID + +class CoderCliSetupWizardPage( + private val context: CoderToolboxContext, + private val settingsPage: CoderSettingsPage, + private val visibilityState: MutableStateFlow, + initialAutoSetup: Boolean = false, + onConnect: suspend ( + client: CoderRestClient, + cli: CoderCLIManager, + ) -> Unit, +) : CoderPage(context.i18n.ptrl("Setting up Coder"), false) { + private val shouldAutoSetup = MutableStateFlow(initialAutoSetup) + private val settingsAction = Action(context.i18n.ptrl("Settings"), actionBlock = { + context.ui.showUiPage(settingsPage) + }) + + private val deploymentUrlStep = DeploymentUrlStep(context, this::notify) + private val tokenStep = TokenStep(context) + private val connectStep = ConnectStep( + context, + shouldAutoSetup, + this::notify, + this::displaySteps, + onConnect + ) + + /** + * Fields for this page, displayed in order. + */ + override val fields: MutableStateFlow> = MutableStateFlow(emptyList()) + override val actionButtons: MutableStateFlow> = MutableStateFlow(emptyList()) + + private val errorBuffer = mutableListOf() + + init { + if (shouldAutoSetup.value) { + CoderCliSetupContext.url = context.secrets.lastDeploymentURL.toURL() + CoderCliSetupContext.token = context.secrets.lastToken + } + } + + override fun beforeShow() { + displaySteps() + if (errorBuffer.isNotEmpty() && visibilityState.value.applicationVisible) { + errorBuffer.forEach { + showError(it) + } + errorBuffer.clear() + } + } + + private fun displaySteps() { + when (CoderCliSetupWizardState.currentStep()) { + WizardStep.URL_REQUEST -> { + fields.update { + listOf(deploymentUrlStep.panel) + } + actionButtons.update { + listOf( + Action(context.i18n.ptrl("Next"), closesPage = false, actionBlock = { + if (deploymentUrlStep.onNext()) { + displaySteps() + } + }), + settingsAction + ) + } + deploymentUrlStep.onVisible() + } + + WizardStep.TOKEN_REQUEST -> { + fields.update { + listOf(tokenStep.panel) + } + actionButtons.update { + listOf( + Action(context.i18n.ptrl("Connect"), closesPage = false, actionBlock = { + if (tokenStep.onNext()) { + displaySteps() + } + }), + settingsAction, + Action(context.i18n.ptrl("Back"), closesPage = false, actionBlock = { + tokenStep.onBack() + displaySteps() + }) + ) + } + tokenStep.onVisible() + } + + WizardStep.CONNECT -> { + fields.update { + listOf(connectStep.panel) + } + actionButtons.update { + listOf( + settingsAction, + Action(context.i18n.ptrl("Back"), closesPage = false, actionBlock = { + connectStep.onBack() + shouldAutoSetup.update { + false + } + displaySteps() + }) + ) + } + connectStep.onVisible() + } + } + } + + /** + * Show an error as a popup on this page. + */ + fun notify(logPrefix: String, ex: Throwable) { + context.logger.error(ex, logPrefix) + if (!visibilityState.value.applicationVisible) { + context.logger.debug("Toolbox is not yet visible, scheduling error to be displayed later") + errorBuffer.add(ex) + return + } + showError(ex) + } + + private fun showError(ex: Throwable) { + val textError = if (ex is APIResponseException) { + if (!ex.reason.isNullOrBlank()) { + ex.reason + } else ex.message + } else ex.message + + context.cs.launch { + context.ui.showSnackbar( + UUID.randomUUID().toString(), + context.i18n.ptrl("Error encountered while setting up Coder"), + context.i18n.pnotr(textError ?: ""), + context.i18n.ptrl("Dismiss") + ) + } + } +} diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt index 77fb1c2..9b83f45 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt @@ -6,8 +6,7 @@ import com.jetbrains.toolbox.api.core.ui.icons.SvgIcon.IconType import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription import com.jetbrains.toolbox.api.ui.components.UiPage -import kotlinx.coroutines.launch -import java.util.UUID +import kotlinx.coroutines.flow.MutableStateFlow /** * Base page that handles the icon, displaying error notifications, and @@ -20,7 +19,6 @@ import java.util.UUID * to use the mouse. */ abstract class CoderPage( - private val context: CoderToolboxContext, title: LocalizableString, showIcon: Boolean = true, ) : UiPage(title) { @@ -39,19 +37,10 @@ abstract class CoderPage( SvgIcon(byteArrayOf(), type = IconType.Masked) } - /** - * Show an error as a popup on this page. - */ - fun notify(logPrefix: String, ex: Throwable) { - context.logger.error(ex, logPrefix) - context.cs.launch { - context.ui.showSnackbar( - UUID.randomUUID().toString(), - context.i18n.pnotr(logPrefix), - context.i18n.pnotr(ex.message ?: ""), - context.i18n.ptrl("Dismiss") - ) - } + override val isBusyCreatingNewEnvironment: MutableStateFlow = MutableStateFlow(false) + + companion object { + fun emptyPage(ctx: CoderToolboxContext): UiPage = UiPage(ctx.i18n.pnotr("")) } } diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt index ff86c42..de2ce0b 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt @@ -20,7 +20,7 @@ import kotlinx.coroutines.launch * I have not been able to test this page. */ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel) : - CoderPage(context, context.i18n.ptrl("Coder Settings"), false) { + CoderPage(context.i18n.ptrl("Coder Settings"), false) { private val settings = context.settingsStore.readOnly() // TODO: Copy over the descriptions, holding until I can test this page. @@ -56,6 +56,8 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel< TextField(context.i18n.ptrl("Extra SSH options"), settings.sshConfigOptions ?: "", TextType.General) private val sshLogDirField = TextField(context.i18n.ptrl("SSH proxy log directory"), settings.sshLogDirectory ?: "", TextType.General) + private val networkInfoDirField = + TextField(context.i18n.ptrl("SSH network metrics directory"), settings.networkInfoDir, TextType.General) override val fields: StateFlow> = MutableStateFlow( @@ -73,6 +75,7 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel< disableAutostartField, enableSshWildCardConfig, sshLogDirField, + networkInfoDirField, sshExtraArgs, ) ) @@ -104,6 +107,7 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel< } } context.settingsStore.updateSshLogDir(sshLogDirField.textState.value) + context.settingsStore.updateNetworkInfoDir(networkInfoDirField.textState.value) context.settingsStore.updateSshConfigOptions(sshExtraArgs.textState.value) } ) diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index 3abbae8..9964d0c 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -5,9 +5,8 @@ import com.coder.toolbox.cli.CoderCLIManager import com.coder.toolbox.cli.ensureCLI import com.coder.toolbox.plugin.PluginManager import com.coder.toolbox.sdk.CoderRestClient -import com.coder.toolbox.util.toURL -import com.coder.toolbox.views.state.AuthWizardState -import com.jetbrains.toolbox.api.localization.LocalizableString +import com.coder.toolbox.views.state.CoderCliSetupContext +import com.coder.toolbox.views.state.CoderCliSetupWizardState import com.jetbrains.toolbox.api.ui.components.LabelField import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.ValidationErrorField @@ -28,7 +27,7 @@ class ConnectStep( private val shouldAutoLogin: StateFlow, private val notify: (String, Throwable) -> Unit, private val refreshWizard: () -> Unit, - private val onConnect: ( + private val onConnect: suspend ( client: CoderRestClient, cli: CoderCLIManager, ) -> Unit, @@ -43,15 +42,19 @@ class ConnectStep( RowGroup.RowField(errorField) ) - override val nextButtonTitle: LocalizableString? = null - override fun onVisible() { errorField.textState.update { context.i18n.pnotr("") } - val url = context.deploymentUrl?.first?.toURL() - statusField.textState.update { context.i18n.pnotr("Connecting to ${url?.host}...") } + if (CoderCliSetupContext.isNotReadyForAuth()) { + errorField.textState.update { + context.i18n.pnotr("URL and token were not properly configured. Please go back and provide a proper URL and token!") + } + return + } + + statusField.textState.update { context.i18n.pnotr("Connecting to ${CoderCliSetupContext.url!!.host}...") } connect() } @@ -59,52 +62,55 @@ class ConnectStep( * Try connecting to Coder with the provided URL and token. */ private fun connect() { - val url = context.deploymentUrl?.first?.toURL() - val token = context.getToken(context.deploymentUrl?.first)?.first - if (url == null) { + if (!CoderCliSetupContext.hasUrl()) { errorField.textState.update { context.i18n.ptrl("URL is required") } return } - if (token.isNullOrBlank()) { + if (!CoderCliSetupContext.hasToken()) { errorField.textState.update { context.i18n.ptrl("Token is required") } return } signInJob?.cancel() signInJob = context.cs.launch { try { - statusField.textState.update { (context.i18n.ptrl("Authenticating to ${url.host}...")) } val client = CoderRestClient( context, - url, - token, + CoderCliSetupContext.url!!, + CoderCliSetupContext.token!!, PluginManager.pluginInfo.version, ) // allows interleaving with the back/cancel action yield() - client.authenticate() - statusField.textState.update { (context.i18n.ptrl("Checking Coder binary...")) } - val cli = ensureCLI(context, client.url, client.buildVersion) + client.initializeSession() + statusField.textState.update { (context.i18n.ptrl("Checking Coder CLI...")) } + val cli = ensureCLI( + context, client.url, + client.buildVersion + ) { progress -> + statusField.textState.update { (context.i18n.pnotr(progress)) } + } // We only need to log in if we are using token-based auth. if (client.token != null) { - statusField.textState.update { (context.i18n.ptrl("Configuring CLI...")) } + statusField.textState.update { (context.i18n.ptrl("Configuring Coder CLI...")) } // allows interleaving with the back/cancel action yield() cli.login(client.token) } - statusField.textState.update { (context.i18n.ptrl("Successfully configured ${url.host}...")) } + statusField.textState.update { (context.i18n.ptrl("Successfully configured ${CoderCliSetupContext.url!!.host}...")) } // allows interleaving with the back/cancel action yield() + CoderCliSetupContext.reset() + CoderCliSetupWizardState.resetSteps() onConnect(client, cli) - AuthWizardState.resetSteps() } catch (ex: CancellationException) { if (ex.message != USER_HIT_THE_BACK_BUTTON) { - notify("Connection to ${url.host} was configured", ex) + notify("Connection to ${CoderCliSetupContext.url!!.host} was configured", ex) onBack() refreshWizard() } } catch (ex: Exception) { - notify("Failed to configure ${url.host}", ex) + notify("Failed to configure ${CoderCliSetupContext.url!!.host}", ex) onBack() refreshWizard() } @@ -120,10 +126,11 @@ class ConnectStep( signInJob?.cancel(CancellationException(USER_HIT_THE_BACK_BUTTON)) } finally { if (shouldAutoLogin.value) { - AuthWizardState.resetSteps() + CoderCliSetupContext.reset() + CoderCliSetupWizardState.resetSteps() context.secrets.rememberMe = false } else { - AuthWizardState.goToPreviousStep() + CoderCliSetupWizardState.goToPreviousStep() } } } diff --git a/src/main/kotlin/com/coder/toolbox/views/SignInStep.kt b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt similarity index 68% rename from src/main/kotlin/com/coder/toolbox/views/SignInStep.kt rename to src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt index 488045e..aa87b57 100644 --- a/src/main/kotlin/com/coder/toolbox/views/SignInStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -2,15 +2,15 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.util.toURL -import com.coder.toolbox.views.state.AuthWizardState -import com.jetbrains.toolbox.api.localization.LocalizableString -import com.jetbrains.toolbox.api.ui.components.LabelField +import com.coder.toolbox.views.state.CoderCliSetupContext +import com.coder.toolbox.views.state.CoderCliSetupWizardState import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.TextField import com.jetbrains.toolbox.api.ui.components.TextType import com.jetbrains.toolbox.api.ui.components.ValidationErrorField import kotlinx.coroutines.flow.update import java.net.MalformedURLException +import java.net.URL /** * A page with a field for providing the Coder deployment URL. @@ -18,30 +18,25 @@ import java.net.MalformedURLException * Populates with the provided URL, at which point the user can accept or * enter their own. */ -class SignInStep(private val context: CoderToolboxContext, private val notify: (String, Throwable) -> Unit) : +class DeploymentUrlStep( + private val context: CoderToolboxContext, + private val notify: (String, Throwable) -> Unit +) : WizardStep { private val urlField = TextField(context.i18n.ptrl("Deployment URL"), "", TextType.General) - private val descriptionField = LabelField(context.i18n.pnotr("")) private val errorField = ValidationErrorField(context.i18n.pnotr("")) override val panel: RowGroup = RowGroup( RowGroup.RowField(urlField), - RowGroup.RowField(descriptionField), RowGroup.RowField(errorField) ) - override val nextButtonTitle: LocalizableString? = context.i18n.ptrl("Sign In") - override fun onVisible() { errorField.textState.update { context.i18n.pnotr("") } urlField.textState.update { - context.deploymentUrl?.first ?: "" - } - - descriptionField.textState.update { - context.i18n.pnotr(context.deploymentUrl?.second?.description("URL") ?: "") + context.secrets.lastDeploymentURL } } @@ -57,22 +52,21 @@ class SignInStep(private val context: CoderToolboxContext, private val notify: ( url } try { - validateRawUrl(url) + CoderCliSetupContext.url = validateRawUrl(url) } catch (e: MalformedURLException) { notify("URL is invalid", e) return false } - context.secrets.lastDeploymentURL = url - AuthWizardState.goToNextStep() + CoderCliSetupWizardState.goToNextStep() return true } /** * Throws [MalformedURLException] if the given string violates RFC-2396 */ - private fun validateRawUrl(url: String) { + private fun validateRawUrl(url: String): URL { try { - url.toURL() + return url.toURL() } catch (e: Exception) { throw MalformedURLException(e.message) } diff --git a/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt b/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt index 56b2910..83e07c7 100644 --- a/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt @@ -15,6 +15,6 @@ import kotlinx.coroutines.flow.StateFlow * support creating environments from the plugin. */ class NewEnvironmentPage(context: CoderToolboxContext, deploymentURL: LocalizableString) : - CoderPage(context, deploymentURL) { + CoderPage(deploymentURL) { override val fields: StateFlow> = MutableStateFlow(emptyList()) } diff --git a/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt b/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt index afd9aa5..b449f40 100644 --- a/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/TokenStep.kt @@ -1,11 +1,9 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext -import com.coder.toolbox.util.toURL import com.coder.toolbox.util.withPath -import com.coder.toolbox.views.state.AuthWizardState -import com.jetbrains.toolbox.api.localization.LocalizableString -import com.jetbrains.toolbox.api.ui.components.LabelField +import com.coder.toolbox.views.state.CoderCliSetupContext +import com.coder.toolbox.views.state.CoderCliSetupWizardState import com.jetbrains.toolbox.api.ui.components.LinkField import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.TextField @@ -20,35 +18,35 @@ import kotlinx.coroutines.flow.update * Populate with the provided token, at which point the user can accept or * enter their own. */ -class TokenStep(private val context: CoderToolboxContext) : WizardStep { +class TokenStep( + private val context: CoderToolboxContext, +) : WizardStep { private val tokenField = TextField(context.i18n.ptrl("Token"), "", TextType.Password) - private val descriptionField = LabelField(context.i18n.pnotr("")) private val linkField = LinkField(context.i18n.ptrl("Get a token"), "") private val errorField = ValidationErrorField(context.i18n.pnotr("")) override val panel: RowGroup = RowGroup( RowGroup.RowField(tokenField), - RowGroup.RowField(descriptionField), RowGroup.RowField(linkField), RowGroup.RowField(errorField) ) - override val nextButtonTitle: LocalizableString? = context.i18n.ptrl("Connect") override fun onVisible() { errorField.textState.update { context.i18n.pnotr("") } - tokenField.textState.update { - context.getToken(context.deploymentUrl?.first)?.first ?: "" - } - descriptionField.textState.update { - context.i18n.pnotr( - context.getToken(context.deploymentUrl?.first)?.second?.description("token") - ?: "No existing token for ${context.deploymentUrl} found." - ) + if (CoderCliSetupContext.hasUrl()) { + tokenField.textState.update { + context.secrets.tokenFor(CoderCliSetupContext.url!!) ?: "" + } + } else { + errorField.textState.update { + context.i18n.pnotr("URL not configure in the previous step. Please go back and provide a proper URL.") + return + } } (linkField.urlState as MutableStateFlow).update { - context.deploymentUrl?.first?.toURL()?.withPath("/login?redirect=%2Fcli-auth")?.toString() ?: "" + CoderCliSetupContext.url!!.withPath("/login?redirect=%2Fcli-auth")?.toString() ?: "" } } @@ -59,12 +57,12 @@ class TokenStep(private val context: CoderToolboxContext) : WizardStep { return false } - context.secrets.lastToken = token - AuthWizardState.goToNextStep() + CoderCliSetupContext.token = token + CoderCliSetupWizardState.goToNextStep() return true } override fun onBack() { - AuthWizardState.goToPreviousStep() + CoderCliSetupWizardState.goToPreviousStep() } } diff --git a/src/main/kotlin/com/coder/toolbox/views/WizardStep.kt b/src/main/kotlin/com/coder/toolbox/views/WizardStep.kt index 6ba3d52..bb19281 100644 --- a/src/main/kotlin/com/coder/toolbox/views/WizardStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/WizardStep.kt @@ -1,11 +1,9 @@ package com.coder.toolbox.views -import com.jetbrains.toolbox.api.localization.LocalizableString import com.jetbrains.toolbox.api.ui.components.RowGroup interface WizardStep { val panel: RowGroup - val nextButtonTitle: LocalizableString? /** * Callback when step is visible diff --git a/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupContext.kt b/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupContext.kt new file mode 100644 index 0000000..8d503b9 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupContext.kt @@ -0,0 +1,45 @@ +package com.coder.toolbox.views.state + +import java.net.URL + +/** + * Singleton that holds Coder CLI setup context (URL and token) across multiple + * Toolbox window lifecycle events. + * + * This ensures that user input (URL and token) is not lost when the Toolbox + * window is temporarily closed or recreated. + */ +object CoderCliSetupContext { + /** + * The currently entered URL. + */ + var url: URL? = null + + /** + * The token associated with the URL. + */ + var token: String? = null + + /** + * Returns true if a URL is currently set. + */ + fun hasUrl(): Boolean = url != null + + /** + * Returns true if a token is currently set. + */ + fun hasToken(): Boolean = !token.isNullOrBlank() + + /** + * Returns true if URL or token is missing and auth is not yet possible. + */ + fun isNotReadyForAuth(): Boolean = !(hasUrl() && token != null) + + /** + * Resets both URL and token to null. + */ + fun reset() { + url = null + token = null + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/views/state/AuthWizardState.kt b/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupWizardState.kt similarity index 57% rename from src/main/kotlin/com/coder/toolbox/views/state/AuthWizardState.kt rename to src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupWizardState.kt index 42bf2c0..f1efca4 100644 --- a/src/main/kotlin/com/coder/toolbox/views/state/AuthWizardState.kt +++ b/src/main/kotlin/com/coder/toolbox/views/state/CoderCliSetupWizardState.kt @@ -1,7 +1,14 @@ package com.coder.toolbox.views.state -object AuthWizardState { +/** + * A singleton that maintains the state of the coder setup wizard across Toolbox window lifecycle events. + * + * This is used to persist the wizard's progress (i.e., current step) between visibility changes + * of the Toolbox window. Without this object, closing and reopening the window would reset the wizard + * to its initial state by creating a new instance. + */ +object CoderCliSetupWizardState { private var currentStep = WizardStep.URL_REQUEST fun currentStep(): WizardStep = currentStep @@ -24,5 +31,5 @@ object AuthWizardState { } enum class WizardStep { - URL_REQUEST, TOKEN_REQUEST, LOGIN; + URL_REQUEST, TOKEN_REQUEST, CONNECT; } \ No newline at end of file diff --git a/src/main/resources/icon.svg b/src/main/resources/icon.svg index 15696c6..4d780a6 100644 --- a/src/main/resources/icon.svg +++ b/src/main/resources/icon.svg @@ -1,15 +1,3 @@ - - - - - - - - - - - - - - + + \ No newline at end of file diff --git a/src/main/resources/icons/create.svg b/src/main/resources/icons/create.svg deleted file mode 100644 index c6da8ba..0000000 --- a/src/main/resources/icons/create.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/main/resources/icons/create_dark.svg b/src/main/resources/icons/create_dark.svg deleted file mode 100644 index 511a8ef..0000000 --- a/src/main/resources/icons/create_dark.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/src/main/resources/icons/delete.svg b/src/main/resources/icons/delete.svg deleted file mode 100644 index a6a94e9..0000000 --- a/src/main/resources/icons/delete.svg +++ /dev/null @@ -1,7 +0,0 @@ - - DeleteTest - - - - - diff --git a/src/main/resources/icons/delete_dark.svg b/src/main/resources/icons/delete_dark.svg deleted file mode 100644 index 901c57e..0000000 --- a/src/main/resources/icons/delete_dark.svg +++ /dev/null @@ -1,7 +0,0 @@ - - DeleteTest_dark - - - - - diff --git a/src/main/resources/icons/homeFolder.svg b/src/main/resources/icons/homeFolder.svg deleted file mode 100644 index 2d482b2..0000000 --- a/src/main/resources/icons/homeFolder.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/main/resources/icons/homeFolder_dark.svg b/src/main/resources/icons/homeFolder_dark.svg deleted file mode 100644 index b7ba16b..0000000 --- a/src/main/resources/icons/homeFolder_dark.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/main/resources/icons/open_terminal.svg b/src/main/resources/icons/open_terminal.svg deleted file mode 100644 index 12d2164..0000000 --- a/src/main/resources/icons/open_terminal.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/main/resources/icons/open_terminal_dark.svg b/src/main/resources/icons/open_terminal_dark.svg deleted file mode 100644 index 3994064..0000000 --- a/src/main/resources/icons/open_terminal_dark.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/main/resources/icons/run.svg b/src/main/resources/icons/run.svg deleted file mode 100644 index d0f970e..0000000 --- a/src/main/resources/icons/run.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/src/main/resources/icons/run_dark.svg b/src/main/resources/icons/run_dark.svg deleted file mode 100644 index 25c1892..0000000 --- a/src/main/resources/icons/run_dark.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/src/main/resources/icons/stop.svg b/src/main/resources/icons/stop.svg deleted file mode 100644 index 8347961..0000000 --- a/src/main/resources/icons/stop.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/src/main/resources/icons/stop_dark.svg b/src/main/resources/icons/stop_dark.svg deleted file mode 100644 index 6392389..0000000 --- a/src/main/resources/icons/stop_dark.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/main/resources/icons/unknown.svg b/src/main/resources/icons/unknown.svg deleted file mode 100644 index 1f8cd75..0000000 --- a/src/main/resources/icons/unknown.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/main/resources/icons/update.svg b/src/main/resources/icons/update.svg deleted file mode 100644 index 50ad46f..0000000 --- a/src/main/resources/icons/update.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/src/main/resources/icons/update_dark.svg b/src/main/resources/icons/update_dark.svg deleted file mode 100644 index ebc8059..0000000 --- a/src/main/resources/icons/update_dark.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/src/main/resources/localization/defaultMessages.po b/src/main/resources/localization/defaultMessages.po index b38b2a6..fe1f90c 100644 --- a/src/main/resources/localization/defaultMessages.po +++ b/src/main/resources/localization/defaultMessages.po @@ -106,7 +106,7 @@ msgstr "" msgid "Configuring CLI..." msgstr "" -msgid "Sign In" +msgid "Next" msgstr "" msgid "Token" @@ -128,4 +128,22 @@ msgid "Extra SSH options" msgstr "" msgid "SSH proxy log directory" +msgstr "" + +msgid "SSH network metrics directory" +msgstr "" + +msgid "Network Status" +msgstr "" + +msgid "Create workspace" +msgstr "" + +msgid "Error encountered while handling Coder URI" +msgstr "" + +msgid "Error encountered while setting up Coder" +msgstr "" + +msgid "Setting up Coder" msgstr "" \ No newline at end of file diff --git a/src/main/resources/logo/coder_logo.svg b/src/main/resources/logo/coder_logo.svg deleted file mode 100644 index c500929..0000000 --- a/src/main/resources/logo/coder_logo.svg +++ /dev/null @@ -1,80 +0,0 @@ - - - - - Coder logo - - - - - - - - - - Coder logo - - - - diff --git a/src/main/resources/logo/coder_logo_16.svg b/src/main/resources/logo/coder_logo_16.svg deleted file mode 100644 index f4ab0e1..0000000 --- a/src/main/resources/logo/coder_logo_16.svg +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - Coder logo - - - - - - - - - - Coder logo - - - - diff --git a/src/main/resources/logo/coder_logo_16_dark.svg b/src/main/resources/logo/coder_logo_16_dark.svg deleted file mode 100644 index 77715c2..0000000 --- a/src/main/resources/logo/coder_logo_16_dark.svg +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - Coder logo - - - - - - - - - - Coder logo - - - - diff --git a/src/main/resources/logo/coder_logo_dark.svg b/src/main/resources/logo/coder_logo_dark.svg deleted file mode 100644 index e8c05d1..0000000 --- a/src/main/resources/logo/coder_logo_dark.svg +++ /dev/null @@ -1,80 +0,0 @@ - - - - - Coder logo - - - - - - - - - - Coder logo - - - - diff --git a/src/main/resources/pluginIcon.svg b/src/main/resources/pluginIcon.svg new file mode 100644 index 0000000..853f895 --- /dev/null +++ b/src/main/resources/pluginIcon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt index d612000..5c37c9e 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt @@ -18,6 +18,7 @@ import com.coder.toolbox.store.DISABLE_AUTOSTART import com.coder.toolbox.store.ENABLE_BINARY_DIR_FALLBACK import com.coder.toolbox.store.ENABLE_DOWNLOADS import com.coder.toolbox.store.HEADER_COMMAND +import com.coder.toolbox.store.NETWORK_INFO_DIR import com.coder.toolbox.store.SSH_CONFIG_OPTIONS import com.coder.toolbox.store.SSH_CONFIG_PATH import com.coder.toolbox.store.SSH_LOG_DIR @@ -30,8 +31,10 @@ import com.coder.toolbox.util.pluginTestSettingsStore import com.coder.toolbox.util.sha1 import com.coder.toolbox.util.toURL import com.jetbrains.toolbox.api.core.diagnostics.Logger +import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper +import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager @@ -59,12 +62,17 @@ import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertTrue +private const val VERSION_FOR_PROGRESS_REPORTING = "v2.23.1-devel+de07351b8" +private val noOpTextProgress: (String) -> Unit = { _ -> } + internal class CoderCLIManagerTest { private val context = CoderToolboxContext( mockk(), mockk(), mockk(), + mockk(), mockk(), + mockk(), mockk(), mockk(relaxed = true), mockk(), @@ -140,7 +148,7 @@ internal class CoderCLIManagerTest { val ex = assertFailsWith( exceptionClass = ResponseException::class, - block = { ccm.download() }, + block = { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }, ) assertEquals(HttpURLConnection.HTTP_INTERNAL_ERROR, ex.code) @@ -195,7 +203,7 @@ internal class CoderCLIManagerTest { assertFailsWith( exceptionClass = AccessDeniedException::class, - block = { ccm.download() }, + block = { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }, ) srv.stop(0) @@ -224,11 +232,11 @@ internal class CoderCLIManagerTest { ).readOnly(), ) - assertTrue(ccm.download()) + assertTrue(ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) assertDoesNotThrow { ccm.version() } // It should skip the second attempt. - assertFalse(ccm.download()) + assertFalse(ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) // Make sure login failures propagate. assertFailsWith( @@ -253,11 +261,11 @@ internal class CoderCLIManagerTest { ).readOnly(), ) - assertEquals(true, ccm.download()) + assertEquals(true, ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) assertEquals(SemVer(url.port.toLong(), 0, 0), ccm.version()) // It should skip the second attempt. - assertEquals(false, ccm.download()) + assertEquals(false, ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) // Should use the source override. ccm = CoderCLIManager( @@ -273,7 +281,7 @@ internal class CoderCLIManagerTest { ).readOnly(), ) - assertEquals(true, ccm.download()) + assertEquals(true, ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) assertContains(ccm.localBinaryPath.toFile().readText(), "0.0.0") srv.stop(0) @@ -321,7 +329,7 @@ internal class CoderCLIManagerTest { assertEquals("cli", ccm.localBinaryPath.toFile().readText()) assertEquals(0, ccm.localBinaryPath.toFile().lastModified()) - assertTrue(ccm.download()) + assertTrue(ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) assertNotEquals("cli", ccm.localBinaryPath.toFile().readText()) assertNotEquals(0, ccm.localBinaryPath.toFile().lastModified()) @@ -346,8 +354,8 @@ internal class CoderCLIManagerTest { val ccm1 = CoderCLIManager(url1, context.logger, settings) val ccm2 = CoderCLIManager(url2, context.logger, settings) - assertTrue(ccm1.download()) - assertTrue(ccm2.download()) + assertTrue(ccm1.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) + assertTrue(ccm2.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) srv1.stop(0) srv2.stop(0) @@ -510,7 +518,10 @@ internal class CoderCLIManagerTest { HEADER_COMMAND to it.headerCommand, SSH_CONFIG_PATH to tmpdir.resolve(it.input + "_to_" + it.output + ".conf").toString(), SSH_CONFIG_OPTIONS to it.extraConfig, - SSH_LOG_DIR to (it.sshLogDirectory?.toString() ?: "") + SSH_LOG_DIR to (it.sshLogDirectory?.toString() ?: ""), + NETWORK_INFO_DIR to tmpdir.parent.resolve("coder-toolbox") + .resolve("ssh-network-metrics") + .normalize().toString() ), env = it.env, context.logger, @@ -531,6 +542,7 @@ internal class CoderCLIManagerTest { // Output is the configuration we expect to have after configuring. val coderConfigPath = ccm.localBinaryPath.parent.resolve("config") + val networkMetricsPath = tmpdir.parent.resolve("coder-toolbox").resolve("ssh-network-metrics") val expectedConf = Path.of("src/test/resources/fixtures/outputs/").resolve(it.output + ".conf").toFile().readText() .replace(newlineRe, System.lineSeparator()) @@ -539,6 +551,10 @@ internal class CoderCLIManagerTest { "/tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64", escape(ccm.localBinaryPath.toString()) ) + .replace( + "/tmp/coder-toolbox/ssh-network-metrics", + escape(networkMetricsPath.toString()) + ) .let { conf -> if (it.sshLogDirectory != null) { conf.replace("/tmp/coder-toolbox/test.coder.invalid/logs", it.sshLogDirectory.toString()) @@ -870,12 +886,12 @@ internal class CoderCLIManagerTest { Result.ERROR -> { assertFailsWith( exceptionClass = AccessDeniedException::class, - block = { ensureCLI(localContext, url, it.buildVersion) }, + block = { ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) }, ) } Result.NONE -> { - val ccm = ensureCLI(localContext, url, it.buildVersion) + val ccm = ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) assertEquals(settings.binPath(url), ccm.localBinaryPath) assertFailsWith( exceptionClass = ProcessInitException::class, @@ -884,25 +900,25 @@ internal class CoderCLIManagerTest { } Result.DL_BIN -> { - val ccm = ensureCLI(localContext, url, it.buildVersion) + val ccm = ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) assertEquals(settings.binPath(url), ccm.localBinaryPath) assertEquals(SemVer(url.port.toLong(), 0, 0), ccm.version()) } Result.DL_DATA -> { - val ccm = ensureCLI(localContext, url, it.buildVersion) + val ccm = ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) assertEquals(settings.binPath(url, true), ccm.localBinaryPath) assertEquals(SemVer(url.port.toLong(), 0, 0), ccm.version()) } Result.USE_BIN -> { - val ccm = ensureCLI(localContext, url, it.buildVersion) + val ccm = ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) assertEquals(settings.binPath(url), ccm.localBinaryPath) assertEquals(SemVer.parse(it.version ?: ""), ccm.version()) } Result.USE_DATA -> { - val ccm = ensureCLI(localContext, url, it.buildVersion) + val ccm = ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) assertEquals(settings.binPath(url, true), ccm.localBinaryPath) assertEquals(SemVer.parse(it.fallbackVersion ?: ""), ccm.version()) } @@ -942,7 +958,7 @@ internal class CoderCLIManagerTest { context.logger, ).readOnly(), ) - assertEquals(true, ccm.download()) + assertEquals(true, ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) assertEquals(it.second, ccm.features, "version: ${it.first}") srv.stop(0) diff --git a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt index 0cee720..2727228 100644 --- a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt +++ b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt @@ -21,8 +21,10 @@ import com.coder.toolbox.store.TLS_CA_PATH import com.coder.toolbox.util.pluginTestSettingsStore import com.coder.toolbox.util.sslContextFromPEMs import com.jetbrains.toolbox.api.core.diagnostics.Logger +import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableStringFactory import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper +import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager @@ -101,7 +103,9 @@ class CoderRestClientTest { mockk(), mockk(), mockk(), + mockk(), mockk(), + mockk(), mockk(), mockk(relaxed = true), mockk(), diff --git a/src/test/kotlin/com/coder/toolbox/sdk/v2/models/NetworkMetricsTest.kt b/src/test/kotlin/com/coder/toolbox/sdk/v2/models/NetworkMetricsTest.kt new file mode 100644 index 0000000..08b98df --- /dev/null +++ b/src/test/kotlin/com/coder/toolbox/sdk/v2/models/NetworkMetricsTest.kt @@ -0,0 +1,107 @@ +package com.coder.toolbox.sdk.v2.models + +import kotlin.test.Test +import kotlin.test.assertEquals + +class NetworkMetricsTest { + + @Test + fun `toPretty should return message for Coder Connect`() { + val metrics = NetworkMetrics( + p2p = null, + latency = null, + preferredDerp = null, + derpLatency = null, + uploadBytesSec = null, + downloadBytesSec = null, + usingCoderConnect = true + ) + + val expected = "You're connected using Coder Connect" + assertEquals(expected, metrics.toPretty()) + } + + @Test + fun `toPretty should return message for P2P connection`() { + val metrics = NetworkMetrics( + p2p = true, + latency = 35.526, + preferredDerp = null, + derpLatency = null, + uploadBytesSec = null, + downloadBytesSec = null, + usingCoderConnect = false + ) + + val expected = "Direct (35.53ms). You're connected peer-to-peer" + assertEquals(expected, metrics.toPretty()) + } + + @Test + fun `toPretty should round latency with more than two decimals correctly for P2P`() { + val metrics = NetworkMetrics( + p2p = true, + latency = 42.6789, + preferredDerp = null, + derpLatency = null, + uploadBytesSec = null, + downloadBytesSec = null, + usingCoderConnect = false + ) + + val expected = "Direct (42.68ms). You're connected peer-to-peer" + assertEquals(expected, metrics.toPretty()) + } + + @Test + fun `toPretty should pad latency with one decimal correctly for P2P`() { + val metrics = NetworkMetrics( + p2p = true, + latency = 12.5, + preferredDerp = null, + derpLatency = null, + uploadBytesSec = null, + downloadBytesSec = null, + usingCoderConnect = false + ) + + val expected = "Direct (12.50ms). You're connected peer-to-peer" + assertEquals(expected, metrics.toPretty()) + } + + @Test + fun `toPretty should return message for DERP relay connection`() { + val metrics = NetworkMetrics( + p2p = false, + latency = 80.0, + preferredDerp = "derp1", + derpLatency = mapOf("derp1" to 30.0), + uploadBytesSec = null, + downloadBytesSec = null, + usingCoderConnect = false + ) + + val expected = "You ↔ derp1 (30.00ms) ↔ Workspace (50.00ms). You are connected through a relay" + assertEquals(expected, metrics.toPretty()) + } + + @Test + fun `toPretty should round and pad latencies correctly for DERP`() { + val metrics = NetworkMetrics( + p2p = false, + latency = 78.1267, + preferredDerp = "derp2", + derpLatency = mapOf("derp2" to 23.5), + uploadBytesSec = null, + downloadBytesSec = null, + usingCoderConnect = false + ) + + // Total latency: 78.1267 + // DERP latency: 23.5 → formatted as 23.50 + // Workspace latency: 78.1267 - 23.5 = 54.6267 → formatted as 54.63 + + val expected = "You ↔ derp2 (23.50ms) ↔ Workspace (54.63ms). You are connected through a relay" + assertEquals(expected, metrics.toPretty()) + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt b/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt index d80b237..5033487 100644 --- a/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt +++ b/src/test/kotlin/com/coder/toolbox/settings/CoderSettingsTest.kt @@ -3,7 +3,6 @@ package com.coder.toolbox.settings import com.coder.toolbox.store.BINARY_NAME import com.coder.toolbox.store.CODER_SSH_CONFIG_OPTIONS import com.coder.toolbox.store.CoderSettingsStore -import com.coder.toolbox.store.DEFAULT_URL import com.coder.toolbox.store.DISABLE_AUTOSTART import com.coder.toolbox.store.ENABLE_BINARY_DIR_FALLBACK import com.coder.toolbox.store.ENABLE_DOWNLOADS @@ -277,108 +276,6 @@ internal class CoderSettingsTest { assertEquals(false, settings.readOnly().requireTokenAuth) } - @Test - fun testDefaultURL() { - val tmp = Path.of(System.getProperty("java.io.tmpdir")) - val dir = tmp.resolve("coder-toolbox-test/test-default-url") - var env = Environment(mapOf("CODER_CONFIG_DIR" to dir.toString())) - dir.toFile().deleteRecursively() - - // No config. - var settings = CoderSettingsStore(pluginTestSettingsStore(), env, logger) - assertEquals(null, settings.defaultURL()) - - // Read from global config. - val globalConfigPath = Path.of(settings.readOnly().globalConfigDir) - globalConfigPath.toFile().mkdirs() - globalConfigPath.resolve("url").toFile().writeText("url-from-global-config") - settings = CoderSettingsStore(pluginTestSettingsStore(), env, logger) - assertEquals("url-from-global-config" to SettingSource.CONFIG, settings.defaultURL()) - - // Read from environment. - env = - Environment( - mapOf( - "CODER_URL" to "url-from-env", - "CODER_CONFIG_DIR" to dir.toString(), - ), - ) - settings = CoderSettingsStore(pluginTestSettingsStore(), env, logger) - assertEquals("url-from-env" to SettingSource.ENVIRONMENT, settings.defaultURL()) - - // Read from settings. - settings = - CoderSettingsStore( - pluginTestSettingsStore( - DEFAULT_URL to "url-from-settings", - ), - env, - logger - ) - assertEquals("url-from-settings" to SettingSource.SETTINGS, settings.defaultURL()) - } - - @Test - fun testToken() { - val tmp = Path.of(System.getProperty("java.io.tmpdir")) - val url = URL("http://test.deployment.coder.com") - val dir = tmp.resolve("coder-toolbox-test/test-default-token") - val env = - Environment( - mapOf( - "CODER_CONFIG_DIR" to dir.toString(), - "LOCALAPPDATA" to dir.toString(), - "XDG_DATA_HOME" to dir.toString(), - "HOME" to dir.toString(), - ), - ) - dir.toFile().deleteRecursively() - - // No config. - var settings = CoderSettingsStore(pluginTestSettingsStore(), env, logger) - assertEquals(null, settings.readOnly().token(url)) - - val globalConfigPath = Path.of(settings.readOnly().globalConfigDir) - globalConfigPath.toFile().mkdirs() - globalConfigPath.resolve("url").toFile().writeText(url.toString()) - globalConfigPath.resolve("session").toFile().writeText("token-from-global-config") - - // Ignore global config if it does not match. - assertEquals(null, settings.readOnly().token(URL("http://some.random.url"))) - - // Read from global config. - assertEquals("token-from-global-config" to SettingSource.CONFIG, settings.readOnly().token(url)) - - // Compares exactly. - assertEquals(null, settings.readOnly().token(url.withPath("/test"))) - - val deploymentConfigPath = settings.readOnly().dataDir(url).resolve("config") - deploymentConfigPath.toFile().mkdirs() - deploymentConfigPath.resolve("url").toFile().writeText("url-from-deployment-config") - deploymentConfigPath.resolve("session").toFile().writeText("token-from-deployment-config") - - // Read from deployment config. - assertEquals("token-from-deployment-config" to SettingSource.DEPLOYMENT_CONFIG, settings.readOnly().token(url)) - - // Only compares host . - assertEquals( - "token-from-deployment-config" to SettingSource.DEPLOYMENT_CONFIG, - settings.readOnly().token(url.withPath("/test")) - ) - - // Ignore if using mTLS. - settings = - CoderSettingsStore( - pluginTestSettingsStore( - TLS_KEY_PATH to "key", - TLS_CERT_PATH to "cert", - ), - env, - logger - ) - assertEquals(null, settings.readOnly().token(url)) - } - @Test fun testDefaults() { // Test defaults for the remaining settings. diff --git a/src/test/kotlin/com/coder/toolbox/util/LinkHandlerTest.kt b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt similarity index 59% rename from src/test/kotlin/com/coder/toolbox/util/LinkHandlerTest.kt rename to src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt index bb87151..2914eae 100644 --- a/src/test/kotlin/com/coder/toolbox/util/LinkHandlerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt @@ -1,41 +1,49 @@ package com.coder.toolbox.util +import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.sdk.DataGen -import com.sun.net.httpserver.HttpHandler -import com.sun.net.httpserver.HttpServer -import java.net.HttpURLConnection -import java.net.InetSocketAddress +import com.coder.toolbox.settings.Environment +import com.coder.toolbox.store.CoderSecretsStore +import com.coder.toolbox.store.CoderSettingsStore +import com.jetbrains.toolbox.api.core.diagnostics.Logger +import com.jetbrains.toolbox.api.core.os.LocalDesktopManager +import com.jetbrains.toolbox.api.localization.LocalizableStringFactory +import com.jetbrains.toolbox.api.remoteDev.connection.ClientHelper +import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper +import com.jetbrains.toolbox.api.remoteDev.connection.ToolboxProxySettings +import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette +import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager +import com.jetbrains.toolbox.api.ui.ToolboxUi +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking import java.util.UUID import kotlin.test.Test -import kotlin.test.assertContains import kotlin.test.assertEquals -import kotlin.test.assertFailsWith - -internal class LinkHandlerTest { - /** - * Create, start, and return a server that uses the provided handler. - */ - private fun mockServer(handler: HttpHandler): Pair { - val srv = HttpServer.create(InetSocketAddress(0), 0) - srv.createContext("/", handler) - srv.start() - return Pair(srv, "http://localhost:" + srv.address.port) - } - - /** - * Create, start, and return a server that mocks redirects. - */ - private fun mockRedirectServer( - location: String, - temp: Boolean, - ): Pair = mockServer { exchange -> - exchange.responseHeaders.set("Location", location) - exchange.sendResponseHeaders( - if (temp) HttpURLConnection.HTTP_MOVED_TEMP else HttpURLConnection.HTTP_MOVED_PERM, - -1, - ) - exchange.close() - } +import kotlin.test.assertNull + +internal class CoderProtocolHandlerTest { + private val context = CoderToolboxContext( + mockk(relaxed = true), + mockk(), + mockk(), + mockk(), + mockk(), + mockk(), + mockk(), + mockk(relaxed = true), + mockk(relaxed = true), + CoderSettingsStore(pluginTestSettingsStore(), Environment(), mockk(relaxed = true)), + mockk(), + mockk() + ) + + private val protocolHandler = CoderProtocolHandler( + context, + DialogUi(context), + MutableStateFlow(false) + ) private val agents = mapOf( @@ -49,7 +57,7 @@ internal class LinkHandlerTest { ) @Test - fun getMatchingAgent() { + fun tstgetMatchingAgent() { val ws = DataGen.workspace("ws", agents = agents) val tests = @@ -74,9 +82,10 @@ internal class LinkHandlerTest { "b0e4c54d-9ba9-4413-8512-11ca1e826a24", ), ) - - tests.forEach { - assertEquals(UUID.fromString(it.second), getMatchingAgent(it.first, ws).id) + runBlocking { + tests.forEach { + assertEquals(UUID.fromString(it.second), protocolHandler.getMatchingAgent(it.first, ws)?.id) + } } } @@ -104,14 +113,10 @@ internal class LinkHandlerTest { "agent with ID", ), ) - - tests.forEach { - val ex = - assertFailsWith( - exceptionClass = it.second, - block = { getMatchingAgent(it.first, ws).id }, - ) - assertContains(ex.message.toString(), it.third) + runBlocking { + tests.forEach { + assertNull(protocolHandler.getMatchingAgent(it.first, ws)?.id) + } } } @@ -126,15 +131,16 @@ internal class LinkHandlerTest { mapOf("agent" to null), mapOf("agent_id" to null), ) - - tests.forEach { - assertEquals( - UUID.fromString("b0e4c54d-9ba9-4413-8512-11ca1e826a24"), - getMatchingAgent( - it, - ws, - ).id, - ) + runBlocking { + tests.forEach { + assertEquals( + UUID.fromString("b0e4c54d-9ba9-4413-8512-11ca1e826a24"), + protocolHandler.getMatchingAgent( + it, + ws, + )?.id, + ) + } } } @@ -149,14 +155,10 @@ internal class LinkHandlerTest { "agent with ID" ), ) - - tests.forEach { - val ex = - assertFailsWith( - exceptionClass = it.second, - block = { getMatchingAgent(it.first, ws).id }, - ) - assertContains(ex.message.toString(), it.third) + runBlocking { + tests.forEach { + assertNull(protocolHandler.getMatchingAgent(it.first, ws)?.id) + } } } @@ -177,43 +179,10 @@ internal class LinkHandlerTest { "has no agents" ), ) - - tests.forEach { - val ex = - assertFailsWith( - exceptionClass = it.second, - block = { getMatchingAgent(it.first, ws).id }, - ) - assertContains(ex.message.toString(), it.third) - } - } - - @Test - fun followsRedirects() { - val (srv1, url1) = - mockServer { exchange -> - exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, -1) - exchange.close() + runBlocking { + tests.forEach { + assertNull(protocolHandler.getMatchingAgent(it.first, ws)?.id) } - val (srv2, url2) = mockRedirectServer(url1, false) - val (srv3, url3) = mockRedirectServer(url2, true) - - assertEquals(url1.toURL(), resolveRedirects(java.net.URL(url3))) - - srv1.stop(0) - srv2.stop(0) - srv3.stop(0) - } - - @Test - fun followsMaximumRedirects() { - val (srv, url) = mockRedirectServer(".", true) - - assertFailsWith( - exceptionClass = Exception::class, - block = { resolveRedirects(java.net.URL(url)) }, - ) - - srv.stop(0) + } } } diff --git a/src/test/resources/fixtures/outputs/append-blank-newlines.conf b/src/test/resources/fixtures/outputs/append-blank-newlines.conf index c73e3a2..51d1d75 100644 --- a/src/test/resources/fixtures/outputs/append-blank-newlines.conf +++ b/src/test/resources/fixtures/outputs/append-blank-newlines.conf @@ -4,17 +4,11 @@ # --- START CODER JETBRAINS TOOLBOX test.coder.invalid Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains owner/foo.agent1 - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable owner/foo.agent1 + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/append-blank.conf b/src/test/resources/fixtures/outputs/append-blank.conf index 7c9df80..f2f1c8b 100644 --- a/src/test/resources/fixtures/outputs/append-blank.conf +++ b/src/test/resources/fixtures/outputs/append-blank.conf @@ -1,16 +1,10 @@ # --- START CODER JETBRAINS TOOLBOX test.coder.invalid Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains owner/foo.agent1 - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable owner/foo.agent1 + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/append-no-blocks.conf b/src/test/resources/fixtures/outputs/append-no-blocks.conf index 8db68d4..0c34e44 100644 --- a/src/test/resources/fixtures/outputs/append-no-blocks.conf +++ b/src/test/resources/fixtures/outputs/append-no-blocks.conf @@ -5,17 +5,11 @@ Host test2 # --- START CODER JETBRAINS TOOLBOX test.coder.invalid Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains owner/foo.agent1 - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable owner/foo.agent1 + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/append-no-newline.conf b/src/test/resources/fixtures/outputs/append-no-newline.conf index 0a48b7e..c25a062 100644 --- a/src/test/resources/fixtures/outputs/append-no-newline.conf +++ b/src/test/resources/fixtures/outputs/append-no-newline.conf @@ -4,17 +4,11 @@ Host test2 Port 443 # --- START CODER JETBRAINS TOOLBOX test.coder.invalid Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains owner/foo.agent1 - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable owner/foo.agent1 + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/append-no-related-blocks.conf b/src/test/resources/fixtures/outputs/append-no-related-blocks.conf index 73ebf8d..53f964e 100644 --- a/src/test/resources/fixtures/outputs/append-no-related-blocks.conf +++ b/src/test/resources/fixtures/outputs/append-no-related-blocks.conf @@ -11,17 +11,11 @@ some jetbrains config # --- START CODER JETBRAINS TOOLBOX test.coder.invalid Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains owner/foo.agent1 - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable owner/foo.agent1 + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/disable-autostart.conf b/src/test/resources/fixtures/outputs/disable-autostart.conf index 916aa9f..27c6986 100644 --- a/src/test/resources/fixtures/outputs/disable-autostart.conf +++ b/src/test/resources/fixtures/outputs/disable-autostart.conf @@ -1,16 +1,10 @@ # --- START CODER JETBRAINS TOOLBOX test.coder.invalid Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --disable-autostart --usage-app=jetbrains owner/foo.agent1 - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --disable-autostart --usage-app=disable owner/foo.agent1 + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --disable-autostart --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/extra-config.conf b/src/test/resources/fixtures/outputs/extra-config.conf index 9a7900c..6abe1f0 100644 --- a/src/test/resources/fixtures/outputs/extra-config.conf +++ b/src/test/resources/fixtures/outputs/extra-config.conf @@ -1,15 +1,6 @@ # --- START CODER JETBRAINS TOOLBOX test.coder.invalid Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains owner/foo.agent1 - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains - ServerAliveInterval 5 - ServerAliveCountMax 3 -Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable owner/foo.agent1 + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null @@ -17,4 +8,5 @@ Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid--bg SetEnv CODER_SSH_SESSION_TYPE=JetBrains ServerAliveInterval 5 ServerAliveCountMax 3 + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/header-command-windows.conf b/src/test/resources/fixtures/outputs/header-command-windows.conf index a27f0ab..4d3b49c 100644 --- a/src/test/resources/fixtures/outputs/header-command-windows.conf +++ b/src/test/resources/fixtures/outputs/header-command-windows.conf @@ -1,16 +1,10 @@ # --- START CODER JETBRAINS TOOLBOX test.coder.invalid Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid --header-command "\"C:\Program Files\My Header Command\HeaderCommand.exe\" --url=\"%%CODER_URL%%\" --test=\"foo bar\"" ssh --stdio --usage-app=jetbrains owner/foo.agent1 - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid --header-command "\"C:\Program Files\My Header Command\HeaderCommand.exe\" --url=\"%%CODER_URL%%\" --test=\"foo bar\"" ssh --stdio --usage-app=disable owner/foo.agent1 + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid --header-command "\"C:\Program Files\My Header Command\HeaderCommand.exe\" --url=\"%%CODER_URL%%\" --test=\"foo bar\"" ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/header-command.conf b/src/test/resources/fixtures/outputs/header-command.conf index 61b5d74..4d27aaa 100644 --- a/src/test/resources/fixtures/outputs/header-command.conf +++ b/src/test/resources/fixtures/outputs/header-command.conf @@ -1,16 +1,10 @@ # --- START CODER JETBRAINS TOOLBOX test.coder.invalid Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid --header-command 'my-header-command --url="$CODER_URL" --test="foo bar" --literal='\''$CODER_URL'\''' ssh --stdio --usage-app=jetbrains owner/foo.agent1 - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid --header-command 'my-header-command --url="$CODER_URL" --test="foo bar" --literal='\''$CODER_URL'\''' ssh --stdio --usage-app=disable owner/foo.agent1 + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid --header-command 'my-header-command --url="$CODER_URL" --test="foo bar" --literal='\''$CODER_URL'\''' ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/log-dir.conf b/src/test/resources/fixtures/outputs/log-dir.conf index 84d7549..0050661 100644 --- a/src/test/resources/fixtures/outputs/log-dir.conf +++ b/src/test/resources/fixtures/outputs/log-dir.conf @@ -1,16 +1,10 @@ # --- START CODER JETBRAINS TOOLBOX test.coder.invalid Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --log-dir /tmp/coder-toolbox/test.coder.invalid/logs --usage-app=jetbrains owner/foo.agent1 - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable owner/foo.agent1 + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --log-dir /tmp/coder-toolbox/test.coder.invalid/logs --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/multiple-agents.conf b/src/test/resources/fixtures/outputs/multiple-agents.conf index a81a8be..d26e398 100644 --- a/src/test/resources/fixtures/outputs/multiple-agents.conf +++ b/src/test/resources/fixtures/outputs/multiple-agents.conf @@ -1,30 +1,18 @@ # --- START CODER JETBRAINS TOOLBOX test.coder.invalid Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains owner/foo.agent1 - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable owner/foo.agent1 + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + Host coder-jetbrains-toolbox--owner--foo.agent2--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains owner/foo.agent2 - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox--owner--foo.agent2--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable owner/foo.agent2 + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent2 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/multiple-users.conf b/src/test/resources/fixtures/outputs/multiple-users.conf index c8cd03d..13801b9 100644 --- a/src/test/resources/fixtures/outputs/multiple-users.conf +++ b/src/test/resources/fixtures/outputs/multiple-users.conf @@ -1,30 +1,18 @@ # --- START CODER JETBRAINS TOOLBOX test.coder.invalid Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains owner/foo.agent1 - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable owner/foo.agent1 + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains owner/foo.agent1 - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable owner/foo.agent1 + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/multiple-workspaces.conf b/src/test/resources/fixtures/outputs/multiple-workspaces.conf index 8629ce3..d912d26 100644 --- a/src/test/resources/fixtures/outputs/multiple-workspaces.conf +++ b/src/test/resources/fixtures/outputs/multiple-workspaces.conf @@ -1,30 +1,18 @@ # --- START CODER JETBRAINS TOOLBOX test.coder.invalid Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains owner/foo.agent1 - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable owner/foo.agent1 + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + Host coder-jetbrains-toolbox--owner--bar.agent1--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains owner/bar.agent1 - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox--owner--bar.agent1--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable owner/bar.agent1 + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/bar.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/no-disable-autostart.conf b/src/test/resources/fixtures/outputs/no-disable-autostart.conf index 7c9df80..f2f1c8b 100644 --- a/src/test/resources/fixtures/outputs/no-disable-autostart.conf +++ b/src/test/resources/fixtures/outputs/no-disable-autostart.conf @@ -1,16 +1,10 @@ # --- START CODER JETBRAINS TOOLBOX test.coder.invalid Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains owner/foo.agent1 - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable owner/foo.agent1 + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/no-report-usage.conf b/src/test/resources/fixtures/outputs/no-report-usage.conf index 7c971e5..3f2311c 100644 --- a/src/test/resources/fixtures/outputs/no-report-usage.conf +++ b/src/test/resources/fixtures/outputs/no-report-usage.conf @@ -1,16 +1,10 @@ # --- START CODER JETBRAINS TOOLBOX test.coder.invalid Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio owner/foo.agent1 - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio owner/foo.agent1 + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/replace-end-no-newline.conf b/src/test/resources/fixtures/outputs/replace-end-no-newline.conf index 77a7cbb..7e64e33 100644 --- a/src/test/resources/fixtures/outputs/replace-end-no-newline.conf +++ b/src/test/resources/fixtures/outputs/replace-end-no-newline.conf @@ -3,17 +3,11 @@ Host test Host test2 Port 443 # --- START CODER JETBRAINS TOOLBOX test.coder.invalid Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains owner/foo.agent1 - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable owner/foo.agent1 + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/replace-end.conf b/src/test/resources/fixtures/outputs/replace-end.conf index 0a48b7e..c25a062 100644 --- a/src/test/resources/fixtures/outputs/replace-end.conf +++ b/src/test/resources/fixtures/outputs/replace-end.conf @@ -4,17 +4,11 @@ Host test2 Port 443 # --- START CODER JETBRAINS TOOLBOX test.coder.invalid Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains owner/foo.agent1 - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable owner/foo.agent1 + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/replace-middle-ignore-unrelated.conf b/src/test/resources/fixtures/outputs/replace-middle-ignore-unrelated.conf index ce23062..f4f7f16 100644 --- a/src/test/resources/fixtures/outputs/replace-middle-ignore-unrelated.conf +++ b/src/test/resources/fixtures/outputs/replace-middle-ignore-unrelated.conf @@ -5,19 +5,13 @@ some coder config # ------------END-CODER------------ # --- START CODER JETBRAINS TOOLBOX test.coder.invalid Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains owner/foo.agent1 - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable owner/foo.agent1 + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid Host test2 Port 443 diff --git a/src/test/resources/fixtures/outputs/replace-middle.conf b/src/test/resources/fixtures/outputs/replace-middle.conf index 9125cd8..8d6fadc 100644 --- a/src/test/resources/fixtures/outputs/replace-middle.conf +++ b/src/test/resources/fixtures/outputs/replace-middle.conf @@ -2,19 +2,13 @@ Host test Port 80 # --- START CODER JETBRAINS TOOLBOX test.coder.invalid Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains owner/foo.agent1 - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable owner/foo.agent1 + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid Host test2 Port 443 diff --git a/src/test/resources/fixtures/outputs/replace-only.conf b/src/test/resources/fixtures/outputs/replace-only.conf index 7c9df80..f2f1c8b 100644 --- a/src/test/resources/fixtures/outputs/replace-only.conf +++ b/src/test/resources/fixtures/outputs/replace-only.conf @@ -1,16 +1,10 @@ # --- START CODER JETBRAINS TOOLBOX test.coder.invalid Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains owner/foo.agent1 - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable owner/foo.agent1 + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/replace-start.conf b/src/test/resources/fixtures/outputs/replace-start.conf index 060582d..dfc2151 100644 --- a/src/test/resources/fixtures/outputs/replace-start.conf +++ b/src/test/resources/fixtures/outputs/replace-start.conf @@ -1,18 +1,12 @@ # --- START CODER JETBRAINS TOOLBOX test.coder.invalid Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=jetbrains owner/foo.agent1 - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --usage-app=disable owner/foo.agent1 + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid Host test Port 80 diff --git a/src/test/resources/fixtures/outputs/url.conf b/src/test/resources/fixtures/outputs/url.conf index dd3b17b..d028507 100644 --- a/src/test/resources/fixtures/outputs/url.conf +++ b/src/test/resources/fixtures/outputs/url.conf @@ -1,16 +1,10 @@ # --- START CODER JETBRAINS TOOLBOX test.coder.invalid Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid?foo=bar&baz=qux ssh --stdio --usage-app=jetbrains owner/foo.agent1 - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox--owner--foo.agent1--test.coder.invalid--bg - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid?foo=bar&baz=qux ssh --stdio --usage-app=disable owner/foo.agent1 + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid?foo=bar&baz=qux ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --usage-app=jetbrains owner/foo.agent1 ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains + # --- END CODER JETBRAINS TOOLBOX test.coder.invalid diff --git a/src/test/resources/fixtures/outputs/wildcard.conf b/src/test/resources/fixtures/outputs/wildcard.conf index b3fd6b8..86d4d97 100644 --- a/src/test/resources/fixtures/outputs/wildcard.conf +++ b/src/test/resources/fixtures/outputs/wildcard.conf @@ -1,17 +1,10 @@ # --- START CODER JETBRAINS TOOLBOX test.coder.invalid Host coder-jetbrains-toolbox-test.coder.invalid--* - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --ssh-host-prefix coder-jetbrains-toolbox-test.coder.invalid-- %h + ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --network-info-dir /tmp/coder-toolbox/ssh-network-metrics --ssh-host-prefix coder-jetbrains-toolbox-test.coder.invalid-- %h ConnectTimeout 0 StrictHostKeyChecking no UserKnownHostsFile /dev/null LogLevel ERROR SetEnv CODER_SSH_SESSION_TYPE=JetBrains -Host coder-jetbrains-toolbox-test.coder.invalid-bg--* - ProxyCommand /tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64 --global-config /tmp/coder-toolbox/test.coder.invalid/config --url https://test.coder.invalid ssh --stdio --ssh-host-prefix coder-jetbrains-toolbox-test.coder.invalid-bg-- %h - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR - SetEnv CODER_SSH_SESSION_TYPE=JetBrains # --- END CODER JETBRAINS TOOLBOX test.coder.invalid