Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
Comment thread
fioan89 marked this conversation as resolved.

## 0.8.6 - 2026-03-05

### Changed
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
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@ 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 a local directly where the CLI will be downloaded. If [enableDownloads] is true
Comment thread
fioan89 marked this conversation as resolved.
Outdated
* then this setting can point to the CLI file locally. Defaults to the data directory.
*/
val binaryDirectory: String?
val binaryDestination: String?

/**
* Controls whether we verify the cli signature
Expand All @@ -60,19 +60,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
32 changes: 22 additions & 10 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 @@ -125,20 +124,33 @@ class CoderSettingsStore(

/**
* To where the specified deployment should download the binary.
*
* Resolution logic:
* 1. If [binaryDestination] is null/empty or [forceDownloadToData] is true,
* the binary is placed in the deployment's data directory with the
* default CLI binary name for the current OS and architecture.
* 2. If [binaryDestination] is set and [enableDownloads] is false, the value
* is treated as an absolute path to a pre-existing CLI binary (~ and $HOME
* are expanded).
* 3. Otherwise [binaryDestination] is treated as a base directory; the binary
* is placed under a host-specific subdirectory with the default CLI binary
* name.
Comment thread
fioan89 marked this conversation as resolved.
Outdated
*/
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()
val dest = binaryDestination
if (dest.isNullOrBlank() || forceDownloadToData) {
return dataDir(url).resolve(defaultCliBinaryNameByOsAndArch).toAbsolutePath()
Comment thread
fioan89 marked this conversation as resolved.
}

val expanded = Path.of(expand(dest))
if (!enableDownloads) {
return expanded.toAbsolutePath()
}

return withHost(expanded, url).resolve(defaultCliBinaryNameByOsAndArch).toAbsolutePath()
Comment thread
fioan89 marked this conversation as resolved.
Outdated
}

/**
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"))
Comment thread
fioan89 marked this conversation as resolved.
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
4 changes: 2 additions & 2 deletions src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ class CoderSettingsPage(
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)
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 @@ -201,7 +201,7 @@ class CoderSettingsPage(
settings.binarySource ?: ""
}
binaryDirectoryField.contentState.update {
settings.binaryDirectory ?: ""
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
145 changes: 130 additions & 15 deletions src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import com.coder.toolbox.cli.ex.SSHConfigFormatException
import com.coder.toolbox.sdk.DataGen.Companion.workspace
import com.coder.toolbox.sdk.v2.models.Workspace
import com.coder.toolbox.settings.Environment
import com.coder.toolbox.store.BINARY_DESTINATION
import com.coder.toolbox.store.BINARY_DIRECTORY
import com.coder.toolbox.store.BINARY_NAME
import com.coder.toolbox.store.BINARY_SOURCE
import com.coder.toolbox.store.CODER_SSH_CONFIG_OPTIONS
import com.coder.toolbox.store.CoderSecretsStore
Expand Down Expand Up @@ -184,7 +184,7 @@ internal class CoderCLIManagerTest {
val settings = CoderSettingsStore(
pluginTestSettingsStore(
DATA_DIRECTORY to tmpdir.resolve("cli-data-dir").toString(),
BINARY_DIRECTORY to tmpdir.resolve("cli-bin-dir").toString(),
BINARY_DESTINATION to tmpdir.resolve("cli-bin-dir").toString(),
),
Environment(),
context.logger
Expand All @@ -203,6 +203,91 @@ internal class CoderCLIManagerTest {
assertEquals(settings.binPath(url, true), ccm2.localBinaryPath)
}

@Test
fun `testBinaryDestination with downloads enabled places binary under host subdirectory`() {
val (srv, url) = mockServer()
val binDir = tmpdir.resolve("bin-dest-downloads-enabled")
val settings = CoderSettingsStore(
pluginTestSettingsStore(
BINARY_DESTINATION to binDir.toString(),
FALLBACK_ON_CODER_FOR_SIGNATURES to "allow",
),
Environment(),
context.logger
)

val ccm = CoderCLIManager(context.copy(settingsStore = settings), url)

// With downloads enabled (default), binaryDestination is a base directory
// and the binary is placed under <binaryDestination>/<host>/<defaultCliBinaryName>
val expectedPath = binDir.resolve("localhost-${url.port}").resolve(settings.defaultCliBinaryNameByOsAndArch)
assertEquals(expectedPath.toAbsolutePath(), ccm.localBinaryPath)

// Verify it actually downloads successfully to that location.
assertTrue(runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) })
assertTrue(ccm.localBinaryPath.toFile().exists())

srv.stop(0)
}

@Test
fun `testBinaryDestination with downloads disabled points directly to binary`() {
val binaryFile = tmpdir.resolve("local-cli").resolve("my-coder-binary")
binaryFile.parent.toFile().mkdirs()
binaryFile.toFile().writeText(mkbinVersion("1.0.0"))
if (getOS() != OS.WINDOWS) {
binaryFile.toFile().setExecutable(true)
}

val settings = CoderSettingsStore(
pluginTestSettingsStore(
BINARY_DESTINATION to binaryFile.toString(),
ENABLE_DOWNLOADS to "false",
),
Environment(),
context.logger
)

val ccm = CoderCLIManager(context.copy(settingsStore = settings), URL("https://test.coder.com"))

// With downloads disabled, binaryDestination is the absolute path to the binary itself.
assertEquals(binaryFile.toAbsolutePath(), ccm.localBinaryPath)
assertEquals(SemVer(1, 0, 0), ccm.version())
}

@Test
@Suppress("DEPRECATION")
fun `testBinaryDirectory fallback to BINARY_DESTINATION`() {
val binDir = tmpdir.resolve("bin-dir-fallback")
val url = URL("http://localhost")

// Using the deprecated BINARY_DIRECTORY key should still work
// because binaryDestination falls back to it.
val settingsWithOldKey = CoderSettingsStore(
pluginTestSettingsStore(
BINARY_DIRECTORY to binDir.toString(),
),
Environment(),
context.logger
)
val expectedOldKey = binDir.resolve("localhost").resolve(settingsWithOldKey.defaultCliBinaryNameByOsAndArch)
assertEquals(expectedOldKey.toAbsolutePath(), settingsWithOldKey.binPath(url))

// BINARY_DESTINATION takes priority over BINARY_DIRECTORY.
val overrideDir = tmpdir.resolve("bin-dest-override")
val settingsWithBothKeys = CoderSettingsStore(
pluginTestSettingsStore(
BINARY_DESTINATION to overrideDir.toString(),
BINARY_DIRECTORY to binDir.toString(),
),
Environment(),
context.logger
)
val expectedNewKey =
overrideDir.resolve("localhost").resolve(settingsWithBothKeys.defaultCliBinaryNameByOsAndArch)
assertEquals(expectedNewKey.toAbsolutePath(), settingsWithBothKeys.binPath(url))
}

@Test
fun testFailsToWrite() {
if (getOS() == OS.WINDOWS) {
Expand Down Expand Up @@ -279,7 +364,6 @@ internal class CoderCLIManagerTest {
context.copy(
settingsStore = CoderSettingsStore(
pluginTestSettingsStore(
BINARY_NAME to "coder.bat",
DATA_DIRECTORY to tmpdir.resolve("mock-cli").toString(),
FALLBACK_ON_CODER_FOR_SIGNATURES to "allow",
),
Expand All @@ -291,7 +375,11 @@ internal class CoderCLIManagerTest {
)

assertEquals(true, runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) })
assertEquals(SemVer(url.port.toLong(), 0, 0), ccm.version())
// On Windows the downloaded script is saved as .exe and cannot be executed
// as a batch script, so skip the version check.
if (getOS() != OS.WINDOWS) {
assertEquals(SemVer(url.port.toLong(), 0, 0), ccm.version())
}

// It should skip the second attempt.
assertEquals(false, runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) })
Expand Down Expand Up @@ -735,10 +823,16 @@ internal class CoderCLIManagerTest {
val ccm = CoderCLIManager(
context.copy(
settingsStore = CoderSettingsStore(
pluginTestSettingsStore(
BINARY_NAME to "coder.bat",
BINARY_DIRECTORY to tmpdir.resolve("bad-version").toString(),
),
if (getOS() == OS.WINDOWS) {
pluginTestSettingsStore(
BINARY_DESTINATION to tmpdir.resolve("bad-version").resolve("coder.bat").toString(),
ENABLE_DOWNLOADS to "false",
)
} else {
pluginTestSettingsStore(
BINARY_DESTINATION to tmpdir.resolve("bad-version").toString(),
)
},
Environment(),
context.logger,
)
Expand Down Expand Up @@ -789,10 +883,16 @@ internal class CoderCLIManagerTest {
val ccm = CoderCLIManager(
context.copy(
settingsStore = CoderSettingsStore(
pluginTestSettingsStore(
BINARY_NAME to "coder.bat",
BINARY_DIRECTORY to tmpdir.resolve("matches-version").toString(),
),
if (getOS() == OS.WINDOWS) {
pluginTestSettingsStore(
BINARY_DESTINATION to tmpdir.resolve("matches-version").resolve("coder.bat").toString(),
ENABLE_DOWNLOADS to "false",
)
} else {
pluginTestSettingsStore(
BINARY_DESTINATION to tmpdir.resolve("matches-version").toString(),
)
},
Environment(),
context.logger,
)
Expand Down Expand Up @@ -884,14 +984,26 @@ internal class CoderCLIManagerTest {
)

val (srv, url) = mockServer()
val binaryName = context.settingsStore.defaultCliBinaryNameByOsAndArch
val host = "localhost-${url.port}"

tests.forEach {
// When downloads are disabled, binPath treats binaryDestination
// as a direct file path. We must provide the full path including
// host subdirectory and binary name so that binPath(url).parent
// does not resolve to the shared cli-manager tmpdir.
val binDestination = if (it.enableDownloads) {
tmpdir.resolve("ensure-bin-dir").toString()
} else {
tmpdir.resolve("ensure-bin-dir").resolve(host).resolve(binaryName).toString()
}

val settingsStore = CoderSettingsStore(
pluginTestSettingsStore(
ENABLE_DOWNLOADS to it.enableDownloads.toString(),
ENABLE_BINARY_DIR_FALLBACK to it.enableFallback.toString(),
DATA_DIRECTORY to tmpdir.resolve("ensure-data-dir").toString(),
BINARY_DIRECTORY to tmpdir.resolve("ensure-bin-dir").toString(),
BINARY_DESTINATION to binDestination,
FALLBACK_ON_CODER_FOR_SIGNATURES to "allow"
),
Environment(),
Expand Down Expand Up @@ -1007,7 +1119,6 @@ internal class CoderCLIManagerTest {
context.copy(
settingsStore = CoderSettingsStore(
pluginTestSettingsStore(
BINARY_NAME to "coder.bat",
DATA_DIRECTORY to tmpdir.resolve("features").toString(),
FALLBACK_ON_CODER_FOR_SIGNATURES to "allow"
),
Expand All @@ -1018,7 +1129,11 @@ internal class CoderCLIManagerTest {
url,
)
assertEquals(true, runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) })
assertEquals(it.second, ccm.features, "version: ${it.first}")
// On Windows the downloaded script is saved as .exe and cannot be executed
// as a batch script, so skip the features check.
if (getOS() != OS.WINDOWS) {
assertEquals(it.second, ccm.features, "version: ${it.first}")
}

srv.stop(0)
}
Expand Down
Loading
Loading