Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Added

- support for using a locally provided CLI binary when downloads are disabled

## 0.8.6 - 2026-03-05

### Changed
Expand Down
33 changes: 27 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -448,15 +448,18 @@ storage paths. The options can be configured from the plugin's main Workspaces p
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.
Defaults to enabled.

- `Binary directory` specifies the directory where CLI binaries are stored. If omitted, it defaults to the data
directory.
- `Binary destination` specifies where the CLI binary is placed. This can be a path to an existing
executable (used as-is) or a base directory (the CLI is placed under a host-specific subdirectory).
If blank, the data directory is used. Supports `~` and `$HOME` expansion.

- `Enable binary directory fallback` if enabled, falls back to the data directory when the specified binary
directory is not writable.
- `Enable binary directory fallback` when enabled, if the binary destination is not writable the
plugin falls back to the data directory instead of failing. Only takes effect when downloads are
enabled and the binary destination differs from the data directory. Defaults to disabled.
Comment on lines +457 to +459
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just an observation, but if we end up having to keep support for the fallback, then we might consider making this option also affect when downloads are disabled, so admins have the ability to always force using the configured binary even if a user places their own more up-to-date binary in their data directory.

If we can remove the fallback though then this disappears anyway.


- `Data directory` directory where plugin-specific data such as session tokens and binaries are stored if not
overridden by the binary directory setting.
- `Data directory` directory where deployment-specific data such as session tokens and CLI binaries
are stored. Each deployment gets a host-specific subdirectory (e.g. `coder.example.com`).

- `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.
Expand All @@ -471,6 +474,24 @@ storage paths. The options can be configured from the plugin's main Workspaces p
Helpful for customers that have their own in-house dashboards. Defaults to the Coder deployment templates page.
This setting supports `$workspaceOwner` as placeholder with the replacing value being the username that logged in.

#### How CLI resolution works

When connecting to a deployment the plugin ensures a compatible CLI binary is available.
The settings above interact as follows:

1. If a CLI already exists at the binary destination and its version matches the deployment, it is
used immediately.
2. If **downloads are enabled**, the plugin downloads the matching version to the binary destination.
- If the download fails with a permission error and **binary directory fallback** is enabled (and
the binary destination is not already in the data directory), the plugin checks whether the data
directory already has a matching CLI. If so it is used; otherwise the plugin downloads to the
data directory instead.
- Any other download error is reported to the user.
3. If **downloads are disabled**, the plugin checks the data directory for a CLI whose version
matches the deployment. If no exact match is found anywhere, whichever CLI is available is
returned — preferring the binary destination unless it is missing, in which case the data
directory CLI is used regardless of its version. If no CLI exists at all, an error is raised.

### TLS settings

The following options control the secure communication behavior of the plugin with Coder deployment and its available
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
version=0.8.6
version=0.8.7
group=com.coder.toolbox
name=coder-toolbox
44 changes: 30 additions & 14 deletions src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,22 @@ internal data class Version(
/**
* Do as much as possible to get a valid, up-to-date CLI.
*
* 1. Read the binary directory for the provided URL.
* 2. Abort if we already have an up-to-date version.
* 3. Download the binary using an ETag.
* 4. Abort if we get a 304 (covers cases where the binary is older and does not
* have a version command).
* 5. Download on top of the existing binary.
* 6. Since the binary directory can be read-only, if downloading fails, start
* from step 2 with the data directory.
* 1. Create a CLI manager for the deployment URL.
* 2. If the CLI version matches the build version, return it immediately.
* 3. If downloads are enabled, attempt to download the CLI.
* a. On success, return the CLI.
* b. On [java.nio.file.AccessDeniedException]: rethrow if the binary
* path parent equals the data directory or if binary directory
* fallback is disabled. Otherwise, if the fallback data directory
* CLI already matches the build version return it; if not, download
* to the data directory and return the fallback CLI.
* c. Any other exception propagates to the caller.
* 4. If downloads are disabled:
* a. If the data directory CLI version matches, return it.
* b. If neither the configured binary nor the data directory CLI can
* report a version, throw [IllegalStateException].
* c. Prefer the configured binary; fall back to the data directory CLI
* only when the configured binary is missing or unexecutable.
*/
suspend fun ensureCLI(
context: CoderToolboxContext,
Expand Down Expand Up @@ -97,6 +105,17 @@ suspend fun ensureCLI(
if (binPath.parent == dataDir || !settings.enableBinaryDirectoryFallback) {
throw e
}
// fall back to the data directory.
val fallbackCLI = CoderCLIManager(context, deploymentURL, true)
val fallbackMatches = fallbackCLI.matchesVersion(buildVersion)
if (fallbackMatches == true) {
reportProgress("Local CLI version from data directory matches server version: $buildVersion")
return fallbackCLI
}

reportProgress("Downloading Coder CLI to the data directory...")
fallbackCLI.download(buildVersion, showTextProgress)
return fallbackCLI
}
}

Expand All @@ -108,14 +127,11 @@ suspend fun ensureCLI(
return dataCLI
}

if (settings.enableDownloads) {
reportProgress("Downloading Coder CLI to the data directory...")
dataCLI.download(buildVersion, showTextProgress)
return dataCLI
}

// Prefer the binary directory unless the data directory has a
// working binary and the binary directory does not.
if (cliMatches == null && dataCLIMatches == null && !settings.enableDownloads) {
throw IllegalStateException("Can't resolve Coder CLI and downloads are disabled")
}
return if (cliMatches == null && dataCLIMatches != null) dataCLI else cli
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,13 @@ interface ReadOnlyCoderSettings {
val binarySource: String?

/**
* Directories are created here that store the CLI for each domain to which
* the plugin connects. Defaults to the data directory.
* An absolute path to either a directory or an existing executable CLI binary.
* When the path points to an existing executable file, it is used as the CLI
* binary path directly. Otherwise, it is treated as a base directory under
* which the CLI is placed in a host-specific subdirectory. Defaults to the
* data directory when not set.
*/
val binaryDirectory: String?
val binaryDestination: String?

/**
* Controls whether we verify the cli signature
Expand All @@ -60,19 +63,14 @@ interface ReadOnlyCoderSettings {
*/
val defaultCliBinaryNameByOsAndArch: String

/**
* Configurable CLI binary name with extension, dependent on OS and arch
*/
val binaryName: String

/**
* Default CLI signature name based on OS and architecture
*/
val defaultSignatureNameByOsAndArch: String

/**
* Where to save plugin data like the Coder binary (if not configured with
* binaryDirectory) and the deployment URL and session token.
* binaryDestination) and the deployment URL and session token.
*/
val dataDirectory: String?

Expand Down
41 changes: 28 additions & 13 deletions src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,14 @@ class CoderSettingsStore(
override val defaultURL: String get() = store[DEFAULT_URL] ?: "https://dev.coder.com"
override val useAppNameAsTitle: Boolean get() = store[APP_NAME_AS_TITLE]?.toBooleanStrictOrNull() ?: false
override val binarySource: String? get() = store[BINARY_SOURCE]
override val binaryDirectory: String? get() = store[BINARY_DIRECTORY]
override val binaryDestination: String? get() = store[BINARY_DESTINATION] ?: store[BINARY_DIRECTORY]
override val disableSignatureVerification: Boolean
get() = store[DISABLE_SIGNATURE_VALIDATION]?.toBooleanStrictOrNull() ?: false
override val fallbackOnCoderForSignatures: SignatureFallbackStrategy
get() = SignatureFallbackStrategy.fromValue(store[FALLBACK_ON_CODER_FOR_SIGNATURES])
override val httpClientLogLevel: HttpLoggingVerbosity
get() = HttpLoggingVerbosity.fromValue(store[HTTP_CLIENT_LOG_LEVEL])
override val defaultCliBinaryNameByOsAndArch: String get() = getCoderCLIForOS(getOS(), getArch())
override val binaryName: String get() = store[BINARY_NAME] ?: getCoderCLIForOS(getOS(), getArch())
override val defaultSignatureNameByOsAndArch: String get() = getCoderSignatureForOS(getOS(), getArch())
override val dataDirectory: String? get() = store[DATA_DIRECTORY]
override val globalDataDirectory: String get() = getDefaultGlobalDataDir().normalize().toString()
Expand Down Expand Up @@ -124,21 +123,37 @@ class CoderSettingsStore(
}

/**
* To where the specified deployment should download the binary.
* To where the specified deployment should place the CLI binary.
*
* Resolution logic:
* 1. If [binaryDestination] is null/blank, return the deployment's data
* directory with the default CLI binary name. [forceDownloadToData]
* is ignored because both paths resolve to the same location.
* 2. If [forceDownloadToData] is true, return a host-specific subdirectory
* under the deployment's data directory with the default CLI binary name.
* 3. If the expanded (~ and $HOME) [binaryDestination] is an existing executable file,
* return it as-is.
* 4. Otherwise, treat [binaryDestination] as a base directory and return a
* host-specific subdirectory with the default CLI binary name.
*/
override fun binPath(
url: URL,
forceDownloadToData: Boolean,
): Path {
binaryDirectory.let {
val dir =
if (forceDownloadToData || it.isNullOrBlank()) {
dataDir(url)
} else {
withHost(Path.of(expand(it)), url)
}
return dir.resolve(binaryName).toAbsolutePath()
if (binaryDestination.isNullOrBlank()) {
return dataDir(url).resolve(defaultCliBinaryNameByOsAndArch).toAbsolutePath()
}

val dest = Path.of(expand(binaryDestination!!))
val isExecutable = Files.isRegularFile(dest) && Files.isExecutable(dest)

if (forceDownloadToData) {
return dataDir(url).resolve(defaultCliBinaryNameByOsAndArch).toAbsolutePath()
}
if (isExecutable) {
return dest.toAbsolutePath()
}
return withHost(dest, url).resolve(defaultCliBinaryNameByOsAndArch).toAbsolutePath()
}

/**
Expand Down Expand Up @@ -179,8 +194,8 @@ class CoderSettingsStore(
store[BINARY_SOURCE] = source
}

fun updateBinaryDirectory(dir: String) {
store[BINARY_DIRECTORY] = dir
fun updateBinaryDestination(dest: String) {
store[BINARY_DESTINATION] = dest
}

fun updateDataDirectory(dir: String) {
Expand Down
5 changes: 3 additions & 2 deletions src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,17 @@ internal const val APP_NAME_AS_TITLE = "useAppNameAsTitle"

internal const val BINARY_SOURCE = "binarySource"

@Deprecated("Use BINARY_DESTINATION instead", replaceWith = ReplaceWith("BINARY_DESTINATION"))
internal const val BINARY_DIRECTORY = "binaryDirectory"

internal const val BINARY_DESTINATION = "binaryDestination"

internal const val DISABLE_SIGNATURE_VALIDATION = "disableSignatureValidation"

internal const val FALLBACK_ON_CODER_FOR_SIGNATURES = "signatureFallbackStrategy"

internal const val HTTP_CLIENT_LOG_LEVEL = "httpClientLogLevel"

internal const val BINARY_NAME = "binaryName"

internal const val DATA_DIRECTORY = "dataDirectory"

internal const val ENABLE_DOWNLOADS = "enableDownloads"
Expand Down
12 changes: 6 additions & 6 deletions src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ class CoderSettingsPage(
// TODO: Copy over the descriptions, holding until I can test this page.
private val binarySourceField =
TextField(context.i18n.ptrl("Binary source"), settings.binarySource ?: "", TextType.General)
private val binaryDirectoryField =
TextField(context.i18n.ptrl("Binary directory"), settings.binaryDirectory ?: "", TextType.General)
private val binaryDestinationField =
TextField(context.i18n.ptrl("Binary destination"), settings.binaryDestination ?: "", TextType.General)
private val dataDirectoryField =
TextField(context.i18n.ptrl("Data directory"), settings.dataDirectory ?: "", TextType.General)
private val enableDownloadsField =
Expand Down Expand Up @@ -131,7 +131,7 @@ class CoderSettingsPage(
binarySourceField,
enableDownloadsField,
useAppNameField,
binaryDirectoryField,
binaryDestinationField,
enableBinaryDirectoryFallbackField,
disableSignatureVerificationField,
signatureFallbackStrategyField,
Expand All @@ -156,7 +156,7 @@ class CoderSettingsPage(
Action(context, "Save", closesPage = true) {
with(context.settingsStore) {
updateBinarySource(binarySourceField.contentState.value)
updateBinaryDirectory(binaryDirectoryField.contentState.value)
updateBinaryDestination(binaryDestinationField.contentState.value)
updateDataDirectory(dataDirectoryField.contentState.value)
updateEnableDownloads(enableDownloadsField.checkedState.value)
updateUseAppNameAsTitle(useAppNameField.checkedState.value)
Expand Down Expand Up @@ -200,8 +200,8 @@ class CoderSettingsPage(
binarySourceField.contentState.update {
settings.binarySource ?: ""
}
binaryDirectoryField.contentState.update {
settings.binaryDirectory ?: ""
binaryDestinationField.contentState.update {
settings.binaryDestination ?: ""
}
dataDirectoryField.contentState.update {
settings.dataDirectory ?: ""
Expand Down
2 changes: 1 addition & 1 deletion src/main/resources/localization/defaultMessages.po
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ msgstr ""
msgid "Binary source"
msgstr ""

msgid "Binary directory"
msgid "Binary destination"
msgstr ""

msgid "Data directory"
Expand Down
Loading
Loading