From e3e779c90ba4f7055850452dfeb7c24c8a481f1d Mon Sep 17 00:00:00 2001 From: ya7010 Date: Sat, 25 Jul 2026 22:30:21 +0900 Subject: [PATCH] feat(intellij): download tombi when unavailable --- editors/intellij/README.md | 9 +- editors/intellij/build.gradle.kts | 1 + editors/intellij/gradle/libs.versions.toml | 6 + .../tombi/configurations/Configurations.kt | 5 +- .../main/kotlin/tombi/configurations/Panel.kt | 4 +- .../tombi/server/TombiBinaryDownloader.kt | 244 ++++++++++++++++++ .../tombi/server/TombiBinaryResolver.kt | 77 ++++++ .../server/TombiServerSupportProvider.kt | 58 ++++- .../main/resources/messages/tombi.properties | 1 + .../tombi/server/TombiBinaryDownloaderTest.kt | 54 ++++ .../tombi/server/TombiBinaryResolverTest.kt | 115 +++++++++ 11 files changed, 563 insertions(+), 11 deletions(-) create mode 100644 editors/intellij/src/main/kotlin/tombi/server/TombiBinaryDownloader.kt create mode 100644 editors/intellij/src/main/kotlin/tombi/server/TombiBinaryResolver.kt create mode 100644 editors/intellij/src/test/kotlin/tombi/server/TombiBinaryDownloaderTest.kt create mode 100644 editors/intellij/src/test/kotlin/tombi/server/TombiBinaryResolverTest.kt diff --git a/editors/intellij/README.md b/editors/intellij/README.md index f3e42d154..38caf062c 100644 --- a/editors/intellij/README.md +++ b/editors/intellij/README.md @@ -6,10 +6,12 @@ ## Usage -You must [have Tombi installed][2] to use this plugin. -See Tombi's documentation for more information. +The plugin uses a Tombi executable already available in the project or on +`PATH`. If one is not available, it downloads the latest Tombi release +automatically. -Once Tombi is installed, open any TOML file and start working. +You can select a specific executable in the plugin settings. Open any TOML file +to start working. ## Logging @@ -27,7 +29,6 @@ com.intellij.platform.lsp [1]: https://tombi-toml.github.io/tombi - [2]: https://tombi-toml.github.io/tombi/docs/installation diff --git a/editors/intellij/build.gradle.kts b/editors/intellij/build.gradle.kts index 067a169bc..a004385eb 100644 --- a/editors/intellij/build.gradle.kts +++ b/editors/intellij/build.gradle.kts @@ -43,6 +43,7 @@ repositories { // Dependencies are managed with Gradle version catalog - read more: https://docs.gradle.org/current/userguide/platforms.html#sub:version-catalog dependencies { + implementation(libs.commonsCompress) testImplementation(kotlin("test")) testImplementation(libs.junit) diff --git a/editors/intellij/gradle/libs.versions.toml b/editors/intellij/gradle/libs.versions.toml index 26f699121..cd68ec48e 100644 --- a/editors/intellij/gradle/libs.versions.toml +++ b/editors/intellij/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] # libraries annotations = "26.0.2-1" +commonsCompress = "1.28.0" junit = "4.13.2" # plugins changelog = "2.4.0" @@ -14,6 +15,11 @@ group = "org.jetbrains" name = "annotations" version.ref = "annotations" +[libraries.commonsCompress] +group = "org.apache.commons" +name = "commons-compress" +version.ref = "commonsCompress" + [libraries.junit] group = "junit" name = "junit" diff --git a/editors/intellij/src/main/kotlin/tombi/configurations/Configurations.kt b/editors/intellij/src/main/kotlin/tombi/configurations/Configurations.kt index ce97aa07c..0c3ec36ac 100644 --- a/editors/intellij/src/main/kotlin/tombi/configurations/Configurations.kt +++ b/editors/intellij/src/main/kotlin/tombi/configurations/Configurations.kt @@ -21,7 +21,8 @@ internal class TombiConfigurations : BaseState() { /** * The Tombi executable to be used in commands. * - * Defaults to `tombi` if not specified. + * When unspecified, the plugin looks for a local Tombi installation and + * downloads the latest release if none is available. */ - var executable by string("tombi") + var executable by string() } diff --git a/editors/intellij/src/main/kotlin/tombi/configurations/Panel.kt b/editors/intellij/src/main/kotlin/tombi/configurations/Panel.kt index fa58ef469..abfecc908 100644 --- a/editors/intellij/src/main/kotlin/tombi/configurations/Panel.kt +++ b/editors/intellij/src/main/kotlin/tombi/configurations/Panel.kt @@ -47,8 +47,8 @@ private fun Row.executableInput(block: Cell.() -> Uni internal fun makePanel(state: TombiConfigurations) = panel { row(message("configurations.executable.label")) { executableInput { - bindText(state::executable.toNonNullableProperty("tombi")) - component.emptyText.text = "tombi" + bindText(state::executable.toNonNullableProperty("")) + component.emptyText.text = message("configurations.executable.placeholder") } } } diff --git a/editors/intellij/src/main/kotlin/tombi/server/TombiBinaryDownloader.kt b/editors/intellij/src/main/kotlin/tombi/server/TombiBinaryDownloader.kt new file mode 100644 index 000000000..498ac9e9b --- /dev/null +++ b/editors/intellij/src/main/kotlin/tombi/server/TombiBinaryDownloader.kt @@ -0,0 +1,244 @@ +package tombi.server + +import com.intellij.openapi.application.PathManager +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream +import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream +import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream +import java.io.BufferedInputStream +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.StandardCopyOption +import java.nio.file.attribute.PosixFilePermission +import java.time.Duration + + +internal object TombiBinaryDownloader { + private const val RELEASES_URL = "https://github.com/tombi-toml/tombi/releases" + private val versionPattern = Regex("""v?(\d+\.\d+\.\d+)""") + + @Synchronized + fun downloadLatestOrCached( + cacheDirectory: Path = PathManager.getSystemDir().resolve("tombi"), + client: HttpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(15)) + .followRedirects(HttpClient.Redirect.ALWAYS) + .build(), + osName: String = System.getProperty("os.name"), + architecture: String = System.getProperty("os.arch"), + ): String { + return try { + downloadLatest(cacheDirectory, client, platform(osName, architecture)).toString() + } catch (error: Exception) { + newestCachedBinary(cacheDirectory)?.toString() ?: throw error + } + } + + internal fun platform(osName: String, architecture: String): Platform { + val targetArchitecture = when (architecture.lowercase()) { + "aarch64", "arm64" -> "aarch64" + "x86_64", "amd64", "x64" -> "x86_64" + else -> error("Unsupported architecture: $architecture") + } + val normalizedOsName = osName.lowercase() + val (targetOs, archiveType) = when { + normalizedOsName.startsWith("mac") -> + "apple-darwin" to ArchiveType.TAR_GZ + normalizedOsName.startsWith("linux") -> + "unknown-linux-musl" to ArchiveType.TAR_GZ + normalizedOsName.startsWith("windows") -> + "pc-windows-msvc" to ArchiveType.ZIP + else -> error("Unsupported operating system: $osName") + } + + return Platform( + target = "$targetArchitecture-$targetOs", + binaryName = if (archiveType == ArchiveType.ZIP) "tombi.exe" else "tombi", + archiveType = archiveType, + ) + } + + internal fun versionFromLatestReleaseUri(uri: URI): String { + val tag = uri.path.substringAfterLast('/') + return versionPattern.matchEntire(tag)?.groupValues?.get(1) + ?: error("Unexpected latest release URL: $uri") + } + + private fun downloadLatest( + cacheDirectory: Path, + client: HttpClient, + platform: Platform, + ): Path { + val latestRequest = HttpRequest.newBuilder(URI.create("$RELEASES_URL/latest")) + .timeout(Duration.ofSeconds(30)) + .GET() + .build() + val latestResponse = client.send(latestRequest, HttpResponse.BodyHandlers.discarding()) + requireSuccess(latestResponse.statusCode(), latestResponse.uri()) + + val version = versionFromLatestReleaseUri(latestResponse.uri()) + val versionDirectory = cacheDirectory.resolve("tombi-$version") + val binaryPath = versionDirectory.resolve(platform.binaryName) + if (Files.isRegularFile(binaryPath)) { + return binaryPath + } + + Files.createDirectories(versionDirectory) + val assetName = "tombi-cli-$version-${platform.target}.${platform.archiveType.extension}" + val assetUri = URI.create("$RELEASES_URL/download/v$version/$assetName") + val archivePath = Files.createTempFile(versionDirectory, "download-", ".${platform.archiveType.extension}") + val extractedPath = Files.createTempFile(versionDirectory, "tombi-", ".tmp") + + try { + val assetRequest = HttpRequest.newBuilder(assetUri) + .timeout(Duration.ofMinutes(2)) + .GET() + .build() + val assetResponse = client.send(assetRequest, HttpResponse.BodyHandlers.ofFile(archivePath)) + requireSuccess(assetResponse.statusCode(), assetResponse.uri()) + + BufferedInputStream(Files.newInputStream(archivePath)).use { input -> + Files.newOutputStream(extractedPath).use { output -> + when (platform.archiveType) { + ArchiveType.TAR_GZ -> + extractFromTarGz(input, platform.binaryName, output) + ArchiveType.ZIP -> + extractFromZip(input, platform.binaryName, output) + } + } + } + makeExecutable(extractedPath) + moveIntoPlace(extractedPath, binaryPath) + } finally { + Files.deleteIfExists(archivePath) + Files.deleteIfExists(extractedPath) + } + + return binaryPath + } + + private fun extractFromTarGz( + input: java.io.InputStream, + binaryName: String, + output: java.io.OutputStream, + ) { + TarArchiveInputStream(GzipCompressorInputStream(input)).use { archive -> + while (true) { + val entry = archive.nextEntry ?: break + if (entry.isFile && Path.of(entry.name).fileName.toString() == binaryName) { + archive.copyTo(output) + return + } + } + } + error("$binaryName was not found in the downloaded archive") + } + + private fun extractFromZip( + input: java.io.InputStream, + binaryName: String, + output: java.io.OutputStream, + ) { + ZipArchiveInputStream(input).use { archive -> + while (true) { + val entry = archive.nextEntry ?: break + if (!entry.isDirectory && Path.of(entry.name).fileName.toString() == binaryName) { + archive.copyTo(output) + return + } + } + } + error("$binaryName was not found in the downloaded archive") + } + + private fun makeExecutable(binaryPath: Path) { + try { + val permissions = Files.getPosixFilePermissions(binaryPath) + permissions.addAll( + setOf( + PosixFilePermission.OWNER_EXECUTE, + PosixFilePermission.GROUP_EXECUTE, + PosixFilePermission.OTHERS_EXECUTE, + ), + ) + Files.setPosixFilePermissions(binaryPath, permissions) + } catch (_: UnsupportedOperationException) { + binaryPath.toFile().setExecutable(true, false) + } + } + + private fun moveIntoPlace(source: Path, target: Path) { + try { + Files.move( + source, + target, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING) + } + } + + private fun newestCachedBinary(cacheDirectory: Path): Path? { + if (!Files.isDirectory(cacheDirectory)) { + return null + } + + return Files.list(cacheDirectory).use { entries -> + entries + .map { directory -> + val version = versionPattern.matchEntire(directory.fileName.toString().removePrefix("tombi-")) + ?.groupValues + ?.get(1) + val binary = listOf("tombi", "tombi.exe") + .map(directory::resolve) + .firstOrNull(Files::isRegularFile) + if (version != null && binary != null) { + Triple(versionComponents(version), version, binary) + } else { + null + } + } + .filter { it != null } + .map { it!! } + .max { left, right -> compareVersions(left.first, right.first) } + .orElse(null) + ?.third + } + } + + private fun versionComponents(version: String): List = + version.split('.').map(String::toInt) + + private fun compareVersions(left: List, right: List): Int { + for (index in 0 until maxOf(left.size, right.size)) { + val comparison = left.getOrElse(index) { 0 }.compareTo(right.getOrElse(index) { 0 }) + if (comparison != 0) { + return comparison + } + } + return 0 + } + + private fun requireSuccess(statusCode: Int, uri: URI) { + check(statusCode in 200..299) { + "Request to $uri failed with HTTP $statusCode" + } + } + + internal data class Platform( + val target: String, + val binaryName: String, + val archiveType: ArchiveType, + ) + + internal enum class ArchiveType(val extension: String) { + TAR_GZ("tar.gz"), + ZIP("zip"), + } +} diff --git a/editors/intellij/src/main/kotlin/tombi/server/TombiBinaryResolver.kt b/editors/intellij/src/main/kotlin/tombi/server/TombiBinaryResolver.kt new file mode 100644 index 000000000..f83a596a1 --- /dev/null +++ b/editors/intellij/src/main/kotlin/tombi/server/TombiBinaryResolver.kt @@ -0,0 +1,77 @@ +package tombi.server + +import com.intellij.util.EnvironmentUtil +import java.nio.file.Files +import java.nio.file.Path + + +internal object TombiBinaryResolver { + fun resolveLocal( + projectPath: Path?, + configuredExecutable: String?, + environment: Map = EnvironmentUtil.getEnvironmentMap(), + osName: String = System.getProperty("os.name"), + userHome: String = System.getProperty("user.home"), + ): String? { + configuredExecutable + ?.trim() + ?.takeIf(String::isNotEmpty) + ?.let { return expandHome(it, userHome) } + + val isWindows = osName.startsWith("Windows", ignoreCase = true) + val binaryName = if (isWindows) "tombi.exe" else "tombi" + + if (projectPath != null) { + val virtualEnvironmentDirectory = if (isWindows) "Scripts" else "bin" + val projectCandidates = mutableListOf( + projectPath.resolve(".venv").resolve(virtualEnvironmentDirectory).resolve(binaryName), + projectPath.resolve("node_modules").resolve(".bin").resolve(binaryName), + ) + if (isWindows) { + projectCandidates.addAll( + listOf("tombi.cmd", "tombi.ps1").map { + projectPath.resolve("node_modules").resolve(".bin").resolve(it) + }, + ) + } + projectCandidates.firstOrNull(Files::isRegularFile)?.let { + return it.toString() + } + } + + return findOnPath(binaryName, environment, isWindows) + } + + private fun expandHome(path: String, userHome: String): String = + when { + path == "~" -> userHome + path.startsWith("~/") || path.startsWith("~\\") -> userHome + path.drop(1) + else -> path + } + + private fun findOnPath( + binaryName: String, + environment: Map, + isWindows: Boolean, + ): String? { + val pathValue = environment.entries + .firstOrNull { (key, _) -> key.equals("PATH", ignoreCase = isWindows) } + ?.value + ?: return null + + val separator = if (isWindows) ';' else ':' + val candidateNames = if (isWindows) { + listOf(binaryName, "tombi.cmd", "tombi.bat") + } else { + listOf(binaryName) + } + + return pathValue + .split(separator) + .asSequence() + .filter(String::isNotBlank) + .flatMap { directory -> candidateNames.asSequence().map(Path.of(directory)::resolve) } + .firstOrNull(Files::isRegularFile) + ?.toString() + } +} diff --git a/editors/intellij/src/main/kotlin/tombi/server/TombiServerSupportProvider.kt b/editors/intellij/src/main/kotlin/tombi/server/TombiServerSupportProvider.kt index f87c7827e..8d8412635 100644 --- a/editors/intellij/src/main/kotlin/tombi/server/TombiServerSupportProvider.kt +++ b/editors/intellij/src/main/kotlin/tombi/server/TombiServerSupportProvider.kt @@ -1,14 +1,22 @@ package tombi.server +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile import com.intellij.platform.lsp.api.LspServer +import com.intellij.platform.lsp.api.LspServerManager import com.intellij.platform.lsp.api.LspServerSupportProvider import com.intellij.platform.lsp.api.LspServerSupportProvider.LspServerStarter import com.intellij.platform.lsp.api.lsWidget.LspServerWidgetItem +import com.intellij.util.concurrency.AppExecutorUtil import tombi.Icons import tombi.configurations.TombiConfigurable import tombi.configurations.tombiConfigurations +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ConcurrentHashMap /** @@ -25,10 +33,54 @@ internal class TombiServerSupportProvider : LspServerSupportProvider { LspServerWidgetItem(lspServer, currentFile, Icons._16, TombiConfigurable::class.java) override fun fileOpened(project: Project, file: VirtualFile, serverStarter: LspServerStarter) { - if (file.isTOMLFile) { - val executable = tombiConfigurations.executable ?: return + if (!file.isTOMLFile) { + return + } + + val configuredExecutable = tombiConfigurations.executable + TombiBinaryResolver.resolveLocal(project.path, configuredExecutable)?.let { executable -> serverStarter.ensureServerStarted(TombiServerDescriptor(project, executable)) + return + } + + managedExecutable + ?.takeIf { Files.isRegularFile(Path.of(it)) } + ?.let { executable -> + serverStarter.ensureServerStarted(TombiServerDescriptor(project, executable)) + return + } + + val resolution = managedResolutions.computeIfAbsent(project) { + CompletableFuture.supplyAsync( + { TombiBinaryDownloader.downloadLatestOrCached() }, + AppExecutorUtil.getAppExecutorService(), + ) + } + + resolution.whenComplete { executable, error -> + managedResolutions.remove(project, resolution) + + if (error != null) { + LOG.warn("Failed to obtain a Tombi language server", error) + return@whenComplete + } + + managedExecutable = executable + ApplicationManager.getApplication().invokeLater { + if (!project.isDisposed) { + LspServerManager.getInstance(project).ensureServerStarted( + TombiServerSupportProvider::class.java, + TombiServerDescriptor(project, executable), + ) + } + } } } - + + companion object { + private val LOG = Logger.getInstance(TombiServerSupportProvider::class.java) + private val managedResolutions = ConcurrentHashMap>() + @Volatile + private var managedExecutable: String? = null + } } diff --git a/editors/intellij/src/main/resources/messages/tombi.properties b/editors/intellij/src/main/resources/messages/tombi.properties index 4315b0809..2e087c705 100644 --- a/editors/intellij/src/main/resources/messages/tombi.properties +++ b/editors/intellij/src/main/resources/messages/tombi.properties @@ -2,3 +2,4 @@ languageServer.presentableName = Tombi configurations.displayName = Tombi configurations.executable.label = Executable: +configurations.executable.placeholder = Automatic diff --git a/editors/intellij/src/test/kotlin/tombi/server/TombiBinaryDownloaderTest.kt b/editors/intellij/src/test/kotlin/tombi/server/TombiBinaryDownloaderTest.kt new file mode 100644 index 000000000..007cdd150 --- /dev/null +++ b/editors/intellij/src/test/kotlin/tombi/server/TombiBinaryDownloaderTest.kt @@ -0,0 +1,54 @@ +package tombi.server + +import java.net.URI +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + + +class TombiBinaryDownloaderTest { + @Test + fun `resolves supported release targets`() { + assertEquals( + TombiBinaryDownloader.Platform( + target = "aarch64-apple-darwin", + binaryName = "tombi", + archiveType = TombiBinaryDownloader.ArchiveType.TAR_GZ, + ), + TombiBinaryDownloader.platform("Mac OS X", "arm64"), + ) + assertEquals( + TombiBinaryDownloader.Platform( + target = "x86_64-unknown-linux-musl", + binaryName = "tombi", + archiveType = TombiBinaryDownloader.ArchiveType.TAR_GZ, + ), + TombiBinaryDownloader.platform("Linux", "amd64"), + ) + assertEquals( + TombiBinaryDownloader.Platform( + target = "aarch64-pc-windows-msvc", + binaryName = "tombi.exe", + archiveType = TombiBinaryDownloader.ArchiveType.ZIP, + ), + TombiBinaryDownloader.platform("Windows 11", "aarch64"), + ) + } + + @Test + fun `extracts version from latest release redirect`() { + assertEquals( + "1.2.4", + TombiBinaryDownloader.versionFromLatestReleaseUri( + URI.create("https://github.com/tombi-toml/tombi/releases/tag/v1.2.4"), + ), + ) + } + + @Test + fun `rejects unsupported platform`() { + assertFailsWith { + TombiBinaryDownloader.platform("FreeBSD", "x86_64") + } + } +} diff --git a/editors/intellij/src/test/kotlin/tombi/server/TombiBinaryResolverTest.kt b/editors/intellij/src/test/kotlin/tombi/server/TombiBinaryResolverTest.kt new file mode 100644 index 000000000..bc0df125a --- /dev/null +++ b/editors/intellij/src/test/kotlin/tombi/server/TombiBinaryResolverTest.kt @@ -0,0 +1,115 @@ +package tombi.server + +import java.nio.file.Files +import kotlin.io.path.createDirectories +import kotlin.io.path.createFile +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + + +class TombiBinaryResolverTest { + @Test + fun `configured executable takes priority and expands home`() { + assertEquals( + "/home/test/bin/tombi", + TombiBinaryResolver.resolveLocal( + projectPath = null, + configuredExecutable = "~/bin/tombi", + environment = emptyMap(), + osName = "Linux", + userHome = "/home/test", + ), + ) + } + + @Test + fun `project virtual environment takes priority over node modules and path`() { + val project = Files.createTempDirectory("tombi-project") + val venvTombi = project.resolve(".venv/bin/tombi") + val nodeModulesTombi = project.resolve("node_modules/.bin/tombi") + val pathDirectory = Files.createTempDirectory("tombi-path") + val pathTombi = pathDirectory.resolve("tombi") + venvTombi.parent.createDirectories() + nodeModulesTombi.parent.createDirectories() + venvTombi.createFile() + nodeModulesTombi.createFile() + pathTombi.createFile() + + assertEquals( + venvTombi.toString(), + TombiBinaryResolver.resolveLocal( + projectPath = project, + configuredExecutable = null, + environment = mapOf("PATH" to pathDirectory.toString()), + osName = "Linux", + ), + ) + } + + @Test + fun `node modules takes priority over path`() { + val project = Files.createTempDirectory("tombi-project") + val nodeModulesTombi = project.resolve("node_modules/.bin/tombi") + val pathDirectory = Files.createTempDirectory("tombi-path") + nodeModulesTombi.parent.createDirectories() + nodeModulesTombi.createFile() + pathDirectory.resolve("tombi").createFile() + + assertEquals( + nodeModulesTombi.toString(), + TombiBinaryResolver.resolveLocal( + projectPath = project, + configuredExecutable = null, + environment = mapOf("PATH" to pathDirectory.toString()), + osName = "Linux", + ), + ) + } + + @Test + fun `returns path installation when no project installation exists`() { + val pathDirectory = Files.createTempDirectory("tombi-path") + val pathTombi = pathDirectory.resolve("tombi").createFile() + + assertEquals( + pathTombi.toString(), + TombiBinaryResolver.resolveLocal( + projectPath = Files.createTempDirectory("tombi-project"), + configuredExecutable = null, + environment = mapOf("PATH" to pathDirectory.toString()), + osName = "Linux", + ), + ) + } + + @Test + fun `finds node modules command shim on Windows`() { + val project = Files.createTempDirectory("tombi-project") + val nodeModulesTombi = project.resolve("node_modules/.bin/tombi.cmd") + nodeModulesTombi.parent.createDirectories() + nodeModulesTombi.createFile() + + assertEquals( + nodeModulesTombi.toString(), + TombiBinaryResolver.resolveLocal( + projectPath = project, + configuredExecutable = null, + environment = emptyMap(), + osName = "Windows 11", + ), + ) + } + + @Test + fun `returns null when no local installation exists`() { + assertNull( + TombiBinaryResolver.resolveLocal( + projectPath = Files.createTempDirectory("tombi-project"), + configuredExecutable = null, + environment = emptyMap(), + osName = "Linux", + ), + ) + } +}