Skip to content

Commit 015ac13

Browse files
authored
feat: support locally provided CLI binaries via binaryDestination (#286)
The existing CLI resolution logic hardcodes the CLI name based on the OS and architecture without any flexibility in specifying an existing CLI. It has a couple of options to specify the download dir (binDir and dataDir) but not the CLI name. This PR revolves around the bindDir setting which is now renamed to binaryDestination and it allows users to specify an absolute path to an existing executable filename or a destination dir. If the user specifies the destination dir then the CLI name will use the existing default name based on the os and architecture. In addition a couple of windows tests were refactored because they required git bash utilities to be installed on Windows. Recent builds on the Github CI failed because commands like `printf` were not available. A lot of UTs were also added to cover the behavior of CLI resolution when based on how binaryDestination, dataDir, enableDownloads and enableFallback flags are configured. Now there is also a special section in the README detailing the CLI resolution algorithm. By and large we tried to keep the existing behavior without breaking compatibility for existing users who were supposed to configure a folder in the binaryDir. The new behavior works like this: ``` 1. Look at the binaryDestination, and if it points to an existing executable file check it if it's version matches server version: 1.1 If it matches then return it. 1.2 If it doesn't match then check whether downloads are enabled 1.2.1 if it is enabled then download the server version of CLI and overwrite the one at binaryDestination 1.2.2 if it is not enabled then check `$dataDir/$hostname/coder-$os-$arch.$ext 1.2.2.1 if the CLI in the dataDir exists and matches, then return it. 1.2.2.1 otherwise prefer the CLI from binaryDestination over the one from dataDir 2. If binaryDestination points to a directory, check $binDestination/$hostname/coder-$os-$arch.$ext if it's version matches server version: 2.1 If it matches then return it. 2.2 If it doesn't match then check whether downloads are enabled 2.2.1 if it is enabled then download the server version of CLI and overwrite the one at $binDestination/$hostname/coder-$os-$arch.$ext 2.2.2 if it is not enabled then check `$dataDir/$hostname/coder-$os-$arch.$ext 2.2.2.1 if the CLI in the dataDir exists and matches, then return it. 2.2.2.1 otherwise prefer the CLI from $binDestination/$hostname/coder-$os-$arch.$ext over the one from dataDir ``` - resolves #285
1 parent 1c25a43 commit 015ac13

File tree

16 files changed

+1530
-242
lines changed

16 files changed

+1530
-242
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
### Added
6+
7+
- support for using a locally provided CLI binary when downloads are disabled
8+
59
## 0.8.6 - 2026-03-05
610

711
### Changed

README.md

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -448,15 +448,18 @@ storage paths. The options can be configured from the plugin's main Workspaces p
448448
If a relative path is provided, it is resolved against the deployment domain.
449449

450450
- `Enable downloads` allows automatic downloading of the CLI if the current version is missing or outdated.
451+
Defaults to enabled.
451452

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

455-
- `Enable binary directory fallback` if enabled, falls back to the data directory when the specified binary
456-
directory is not writable.
457+
- `Enable binary directory fallback` when enabled, if the binary destination is not writable the
458+
plugin falls back to the data directory instead of failing. Only takes effect when downloads are
459+
enabled and the binary destination differs from the data directory. Defaults to disabled.
457460

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

461464
- `Header command` command that outputs additional HTTP headers. Each line of output must be in the format key=value.
462465
The environment variable CODER_URL will be available to the command process.
@@ -471,6 +474,24 @@ storage paths. The options can be configured from the plugin's main Workspaces p
471474
Helpful for customers that have their own in-house dashboards. Defaults to the Coder deployment templates page.
472475
This setting supports `$workspaceOwner` as placeholder with the replacing value being the username that logged in.
473476

477+
#### How CLI resolution works
478+
479+
When connecting to a deployment the plugin ensures a compatible CLI binary is available.
480+
The settings above interact as follows:
481+
482+
1. If a CLI already exists at the binary destination and its version matches the deployment, it is
483+
used immediately.
484+
2. If **downloads are enabled**, the plugin downloads the matching version to the binary destination.
485+
- If the download fails with a permission error and **binary directory fallback** is enabled (and
486+
the binary destination is not already in the data directory), the plugin checks whether the data
487+
directory already has a matching CLI. If so it is used; otherwise the plugin downloads to the
488+
data directory instead.
489+
- Any other download error is reported to the user.
490+
3. If **downloads are disabled**, the plugin checks the data directory for a CLI whose version
491+
matches the deployment. If no exact match is found anywhere, whichever CLI is available is
492+
returned — preferring the binary destination unless it is missing, in which case the data
493+
directory CLI is used regardless of its version. If no CLI exists at all, an error is raised.
494+
474495
### TLS settings
475496

476497
The following options control the secure communication behavior of the plugin with Coder deployment and its available

gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
version=0.8.6
1+
version=0.8.7
22
group=com.coder.toolbox
33
name=coder-toolbox

src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,22 @@ internal data class Version(
5050
/**
5151
* Do as much as possible to get a valid, up-to-date CLI.
5252
*
53-
* 1. Read the binary directory for the provided URL.
54-
* 2. Abort if we already have an up-to-date version.
55-
* 3. Download the binary using an ETag.
56-
* 4. Abort if we get a 304 (covers cases where the binary is older and does not
57-
* have a version command).
58-
* 5. Download on top of the existing binary.
59-
* 6. Since the binary directory can be read-only, if downloading fails, start
60-
* from step 2 with the data directory.
53+
* 1. Create a CLI manager for the deployment URL.
54+
* 2. If the CLI version matches the build version, return it immediately.
55+
* 3. If downloads are enabled, attempt to download the CLI.
56+
* a. On success, return the CLI.
57+
* b. On [java.nio.file.AccessDeniedException]: rethrow if the binary
58+
* path parent equals the data directory or if binary directory
59+
* fallback is disabled. Otherwise, if the fallback data directory
60+
* CLI already matches the build version return it; if not, download
61+
* to the data directory and return the fallback CLI.
62+
* c. Any other exception propagates to the caller.
63+
* 4. If downloads are disabled:
64+
* a. If the data directory CLI version matches, return it.
65+
* b. If neither the configured binary nor the data directory CLI can
66+
* report a version, throw [IllegalStateException].
67+
* c. Prefer the configured binary; fall back to the data directory CLI
68+
* only when the configured binary is missing or unexecutable.
6169
*/
6270
suspend fun ensureCLI(
6371
context: CoderToolboxContext,
@@ -97,6 +105,17 @@ suspend fun ensureCLI(
97105
if (binPath.parent == dataDir || !settings.enableBinaryDirectoryFallback) {
98106
throw e
99107
}
108+
// fall back to the data directory.
109+
val fallbackCLI = CoderCLIManager(context, deploymentURL, true)
110+
val fallbackMatches = fallbackCLI.matchesVersion(buildVersion)
111+
if (fallbackMatches == true) {
112+
reportProgress("Local CLI version from data directory matches server version: $buildVersion")
113+
return fallbackCLI
114+
}
115+
116+
reportProgress("Downloading Coder CLI to the data directory...")
117+
fallbackCLI.download(buildVersion, showTextProgress)
118+
return fallbackCLI
100119
}
101120
}
102121

@@ -108,14 +127,11 @@ suspend fun ensureCLI(
108127
return dataCLI
109128
}
110129

111-
if (settings.enableDownloads) {
112-
reportProgress("Downloading Coder CLI to the data directory...")
113-
dataCLI.download(buildVersion, showTextProgress)
114-
return dataCLI
115-
}
116-
117130
// Prefer the binary directory unless the data directory has a
118131
// working binary and the binary directory does not.
132+
if (cliMatches == null && dataCLIMatches == null && !settings.enableDownloads) {
133+
throw IllegalStateException("Can't resolve Coder CLI and downloads are disabled")
134+
}
119135
return if (cliMatches == null && dataCLIMatches != null) dataCLI else cli
120136
}
121137

src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,13 @@ interface ReadOnlyCoderSettings {
3535
val binarySource: String?
3636

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

4346
/**
4447
* Controls whether we verify the cli signature
@@ -60,19 +63,14 @@ interface ReadOnlyCoderSettings {
6063
*/
6164
val defaultCliBinaryNameByOsAndArch: String
6265

63-
/**
64-
* Configurable CLI binary name with extension, dependent on OS and arch
65-
*/
66-
val binaryName: String
67-
6866
/**
6967
* Default CLI signature name based on OS and architecture
7068
*/
7169
val defaultSignatureNameByOsAndArch: String
7270

7371
/**
7472
* Where to save plugin data like the Coder binary (if not configured with
75-
* binaryDirectory) and the deployment URL and session token.
73+
* binaryDestination) and the deployment URL and session token.
7674
*/
7775
val dataDirectory: String?
7876

src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,14 @@ class CoderSettingsStore(
4141
override val defaultURL: String get() = store[DEFAULT_URL] ?: "https://dev.coder.com"
4242
override val useAppNameAsTitle: Boolean get() = store[APP_NAME_AS_TITLE]?.toBooleanStrictOrNull() ?: false
4343
override val binarySource: String? get() = store[BINARY_SOURCE]
44-
override val binaryDirectory: String? get() = store[BINARY_DIRECTORY]
44+
override val binaryDestination: String? get() = store[BINARY_DESTINATION] ?: store[BINARY_DIRECTORY]
4545
override val disableSignatureVerification: Boolean
4646
get() = store[DISABLE_SIGNATURE_VALIDATION]?.toBooleanStrictOrNull() ?: false
4747
override val fallbackOnCoderForSignatures: SignatureFallbackStrategy
4848
get() = SignatureFallbackStrategy.fromValue(store[FALLBACK_ON_CODER_FOR_SIGNATURES])
4949
override val httpClientLogLevel: HttpLoggingVerbosity
5050
get() = HttpLoggingVerbosity.fromValue(store[HTTP_CLIENT_LOG_LEVEL])
5151
override val defaultCliBinaryNameByOsAndArch: String get() = getCoderCLIForOS(getOS(), getArch())
52-
override val binaryName: String get() = store[BINARY_NAME] ?: getCoderCLIForOS(getOS(), getArch())
5352
override val defaultSignatureNameByOsAndArch: String get() = getCoderSignatureForOS(getOS(), getArch())
5453
override val dataDirectory: String? get() = store[DATA_DIRECTORY]
5554
override val globalDataDirectory: String get() = getDefaultGlobalDataDir().normalize().toString()
@@ -124,21 +123,37 @@ class CoderSettingsStore(
124123
}
125124

126125
/**
127-
* To where the specified deployment should download the binary.
126+
* To where the specified deployment should place the CLI binary.
127+
*
128+
* Resolution logic:
129+
* 1. If [binaryDestination] is null/blank, return the deployment's data
130+
* directory with the default CLI binary name. [forceDownloadToData]
131+
* is ignored because both paths resolve to the same location.
132+
* 2. If [forceDownloadToData] is true, return a host-specific subdirectory
133+
* under the deployment's data directory with the default CLI binary name.
134+
* 3. If the expanded (~ and $HOME) [binaryDestination] is an existing executable file,
135+
* return it as-is.
136+
* 4. Otherwise, treat [binaryDestination] as a base directory and return a
137+
* host-specific subdirectory with the default CLI binary name.
128138
*/
129139
override fun binPath(
130140
url: URL,
131141
forceDownloadToData: Boolean,
132142
): Path {
133-
binaryDirectory.let {
134-
val dir =
135-
if (forceDownloadToData || it.isNullOrBlank()) {
136-
dataDir(url)
137-
} else {
138-
withHost(Path.of(expand(it)), url)
139-
}
140-
return dir.resolve(binaryName).toAbsolutePath()
143+
if (binaryDestination.isNullOrBlank()) {
144+
return dataDir(url).resolve(defaultCliBinaryNameByOsAndArch).toAbsolutePath()
145+
}
146+
147+
val dest = Path.of(expand(binaryDestination!!))
148+
val isExecutable = Files.isRegularFile(dest) && Files.isExecutable(dest)
149+
150+
if (forceDownloadToData) {
151+
return dataDir(url).resolve(defaultCliBinaryNameByOsAndArch).toAbsolutePath()
152+
}
153+
if (isExecutable) {
154+
return dest.toAbsolutePath()
141155
}
156+
return withHost(dest, url).resolve(defaultCliBinaryNameByOsAndArch).toAbsolutePath()
142157
}
143158

144159
/**
@@ -179,8 +194,8 @@ class CoderSettingsStore(
179194
store[BINARY_SOURCE] = source
180195
}
181196

182-
fun updateBinaryDirectory(dir: String) {
183-
store[BINARY_DIRECTORY] = dir
197+
fun updateBinaryDestination(dest: String) {
198+
store[BINARY_DESTINATION] = dest
184199
}
185200

186201
fun updateDataDirectory(dir: String) {

src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,17 @@ internal const val APP_NAME_AS_TITLE = "useAppNameAsTitle"
1010

1111
internal const val BINARY_SOURCE = "binarySource"
1212

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

16+
internal const val BINARY_DESTINATION = "binaryDestination"
17+
1518
internal const val DISABLE_SIGNATURE_VALIDATION = "disableSignatureValidation"
1619

1720
internal const val FALLBACK_ON_CODER_FOR_SIGNATURES = "signatureFallbackStrategy"
1821

1922
internal const val HTTP_CLIENT_LOG_LEVEL = "httpClientLogLevel"
2023

21-
internal const val BINARY_NAME = "binaryName"
22-
2324
internal const val DATA_DIRECTORY = "dataDirectory"
2425

2526
internal const val ENABLE_DOWNLOADS = "enableDownloads"

src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ class CoderSettingsPage(
3838
// TODO: Copy over the descriptions, holding until I can test this page.
3939
private val binarySourceField =
4040
TextField(context.i18n.ptrl("Binary source"), settings.binarySource ?: "", TextType.General)
41-
private val binaryDirectoryField =
42-
TextField(context.i18n.ptrl("Binary directory"), settings.binaryDirectory ?: "", TextType.General)
41+
private val binaryDestinationField =
42+
TextField(context.i18n.ptrl("Binary destination"), settings.binaryDestination ?: "", TextType.General)
4343
private val dataDirectoryField =
4444
TextField(context.i18n.ptrl("Data directory"), settings.dataDirectory ?: "", TextType.General)
4545
private val enableDownloadsField =
@@ -131,7 +131,7 @@ class CoderSettingsPage(
131131
binarySourceField,
132132
enableDownloadsField,
133133
useAppNameField,
134-
binaryDirectoryField,
134+
binaryDestinationField,
135135
enableBinaryDirectoryFallbackField,
136136
disableSignatureVerificationField,
137137
signatureFallbackStrategyField,
@@ -156,7 +156,7 @@ class CoderSettingsPage(
156156
Action(context, "Save", closesPage = true) {
157157
with(context.settingsStore) {
158158
updateBinarySource(binarySourceField.contentState.value)
159-
updateBinaryDirectory(binaryDirectoryField.contentState.value)
159+
updateBinaryDestination(binaryDestinationField.contentState.value)
160160
updateDataDirectory(dataDirectoryField.contentState.value)
161161
updateEnableDownloads(enableDownloadsField.checkedState.value)
162162
updateUseAppNameAsTitle(useAppNameField.checkedState.value)
@@ -200,8 +200,8 @@ class CoderSettingsPage(
200200
binarySourceField.contentState.update {
201201
settings.binarySource ?: ""
202202
}
203-
binaryDirectoryField.contentState.update {
204-
settings.binaryDirectory ?: ""
203+
binaryDestinationField.contentState.update {
204+
settings.binaryDestination ?: ""
205205
}
206206
dataDirectoryField.contentState.update {
207207
settings.dataDirectory ?: ""

src/main/resources/localization/defaultMessages.po

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ msgstr ""
7070
msgid "Binary source"
7171
msgstr ""
7272

73-
msgid "Binary directory"
73+
msgid "Binary destination"
7474
msgstr ""
7575

7676
msgid "Data directory"

0 commit comments

Comments
 (0)