mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-18 09:24:02 +08:00
fix(jetbrains): address runtime CLI review feedback
This commit is contained in:
@@ -2,4 +2,4 @@
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Download the pinned Kilo Core release at connect time instead of bundling Core binaries in the plugin.
|
||||
Reduce JetBrains plugin size by downloading the Kilo Core release on first connect.
|
||||
|
||||
@@ -18,8 +18,7 @@ val rawSpec = layout.buildDirectory.file("generated/openapi-spec/openapi.raw.jso
|
||||
val generatedSpec = layout.buildDirectory.file("generated/openapi-spec/openapi.json")
|
||||
val generatedProps = layout.buildDirectory.dir("generated/kilo-props")
|
||||
|
||||
val pinnedCliVersion = providers.provider {
|
||||
val text = rootProject.layout.projectDirectory.file("package.json").asFile.readText()
|
||||
val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirectory.file("package.json")).asText.map { text ->
|
||||
Regex("\"version\"\\s*:\\s*\"([^\"]+)\"").find(text)?.groupValues?.get(1)
|
||||
?: error("Could not read version from package.json")
|
||||
}
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ class KiloBackendAppService private constructor(
|
||||
private const val MAX_RETRIES = 3
|
||||
private const val RETRY_DELAY_MS = 1000L
|
||||
private const val APP_LOAD_TIMEOUT_MS = 30_000L
|
||||
private const val READY_TIMEOUT_MS = 5_000L
|
||||
private const val READY_TIMEOUT_MS = 120_000L
|
||||
|
||||
/** Test factory — no IntelliJ deps needed. */
|
||||
internal fun create(
|
||||
|
||||
+10
-4
@@ -271,8 +271,8 @@ class KiloBackendSessionManager(
|
||||
title = s.title,
|
||||
version = s.version,
|
||||
time = SessionTimeDto(
|
||||
created = s.time.created.toDouble(),
|
||||
updated = s.time.updated.toDouble(),
|
||||
created = time(s.id, "created", s.time.created),
|
||||
updated = time(s.id, "updated", s.time.updated),
|
||||
archived = s.time.archived,
|
||||
),
|
||||
summary = s.summary?.let {
|
||||
@@ -292,8 +292,8 @@ class KiloBackendSessionManager(
|
||||
title = s.title,
|
||||
version = s.version,
|
||||
time = SessionTimeDto(
|
||||
created = s.time.created?.toDouble() ?: 0.0,
|
||||
updated = s.time.updated?.toDouble() ?: 0.0,
|
||||
created = time(s.id, "created", s.time.created),
|
||||
updated = time(s.id, "updated", s.time.updated),
|
||||
archived = s.time.archived,
|
||||
),
|
||||
summary = s.summary?.let {
|
||||
@@ -315,6 +315,12 @@ class KiloBackendSessionManager(
|
||||
|
||||
private fun encode(value: String) = java.net.URLEncoder.encode(value, Charsets.UTF_8)
|
||||
|
||||
private fun time(id: String, field: String, value: Number?): Double {
|
||||
if (value != null) return value.toDouble()
|
||||
log.warn("Session $id missing $field timestamp; defaulting to 0.0")
|
||||
return 0.0
|
||||
}
|
||||
|
||||
private fun escape(value: String) = buildString {
|
||||
for (c in value) {
|
||||
when (c) {
|
||||
|
||||
+86
-11
@@ -5,53 +5,104 @@ import com.intellij.openapi.application.PathManager
|
||||
import com.intellij.openapi.util.SystemInfo
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
|
||||
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.zip.ZipInputStream
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class KiloCliDownloader(
|
||||
private val http: OkHttpClient = OkHttpClient(),
|
||||
private val http: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
.writeTimeout(120, TimeUnit.SECONDS)
|
||||
.build(),
|
||||
private val log: KiloLog = KiloLog.create(KiloCliDownloader::class.java),
|
||||
private val root: File = File(PathManager.getSystemPath(), "kilo/cli"),
|
||||
private val baseUrl: String = "https://github.com/Kilo-Org/kilocode/releases/download",
|
||||
private val api: String = "https://api.github.com/repos/Kilo-Org/kilocode/releases/tags",
|
||||
) {
|
||||
companion object {
|
||||
private val DIGEST = Regex("^sha256:[a-f0-9]{64}$")
|
||||
private val JSON = Json { ignoreUnknownKeys = true }
|
||||
}
|
||||
|
||||
suspend fun resolve(version: String, force: Boolean = false, onProgress: (CliDownload) -> Unit = {}): File =
|
||||
withContext(Dispatchers.IO) {
|
||||
val platform = KiloCliPlatform.current()
|
||||
val dir = File(File(root, version), platform)
|
||||
val exe = File(dir, "bin/${KiloCliPlatform.exe()}")
|
||||
val done = File(dir, ".complete")
|
||||
val cached = done.takeIf { it.isFile }?.readText()?.trim()
|
||||
val ext = KiloCliPlatform.archive(platform)
|
||||
val archive = File(dir, "kilo-$platform.$ext")
|
||||
|
||||
if (force && dir.exists()) {
|
||||
log.info("Deleting cached CLI $version under ${dir.absolutePath}")
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
|
||||
if (exe.isFile && done.isFile) {
|
||||
if (!force && exe.isFile && cached != null && cached.matches(DIGEST) && matches(archive, cached)) {
|
||||
log.info("Using cached Kilo CLI $version for $platform at ${exe.absolutePath}")
|
||||
if (!SystemInfo.isWindows) exe.setExecutable(true)
|
||||
return@withContext exe
|
||||
}
|
||||
|
||||
val digest = asset(version, platform, ext)
|
||||
|
||||
if (dir.exists()) {
|
||||
log.info("Deleting cached CLI $version under ${dir.absolutePath}")
|
||||
if (!dir.deleteRecursively()) {
|
||||
throw IllegalStateException("Failed to delete cached Kilo CLI $version under ${dir.absolutePath}")
|
||||
}
|
||||
}
|
||||
|
||||
if (!dir.isDirectory && !dir.mkdirs()) {
|
||||
throw IllegalStateException("Failed to create Kilo CLI cache directory ${dir.absolutePath}")
|
||||
}
|
||||
|
||||
log.info("Kilo CLI $version for $platform is not cached; downloading new release into ${dir.absolutePath}")
|
||||
dir.mkdirs()
|
||||
val ext = KiloCliPlatform.archive(platform)
|
||||
val archive = File(dir, "kilo-$platform.$ext")
|
||||
onProgress(CliDownload(0, version, platform))
|
||||
download(version, platform, ext, archive, onProgress)
|
||||
verify(archive, digest)
|
||||
log.info("Downloaded Kilo CLI $version for $platform to ${archive.absolutePath} (size=${archive.length()} bytes)")
|
||||
extract(archive, dir)
|
||||
if (!exe.isFile) throw IllegalStateException("Downloaded CLI archive did not contain bin/${KiloCliPlatform.exe()}")
|
||||
if (!SystemInfo.isWindows) exe.setExecutable(true)
|
||||
done.writeText("ok\n")
|
||||
done.writeText("$digest\n")
|
||||
onProgress(CliDownload(100, version, platform))
|
||||
exe
|
||||
}
|
||||
|
||||
private fun asset(version: String, platform: String, ext: String): String {
|
||||
val name = "kilo-$platform.$ext"
|
||||
val url = "${api.trimEnd('/')}/v$version"
|
||||
log.info("Fetching Kilo CLI release metadata for $version from $url")
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.build()
|
||||
http.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("Failed to fetch Kilo CLI release metadata for $version: HTTP ${response.code}")
|
||||
}
|
||||
val body = response.body?.string()
|
||||
?: throw IllegalStateException("Failed to fetch Kilo CLI release metadata for $version: empty response body")
|
||||
val digest = JSON.parseToJsonElement(body).jsonObject["assets"]?.jsonArray
|
||||
?.firstOrNull { it.jsonObject["name"]?.jsonPrimitive?.contentOrNull == name }
|
||||
?.jsonObject?.get("digest")?.jsonPrimitive?.contentOrNull
|
||||
?: throw IllegalStateException("Kilo CLI release $version did not include $name")
|
||||
if (!digest.matches(DIGEST)) {
|
||||
throw IllegalStateException("Kilo CLI release $version asset $name has invalid digest")
|
||||
}
|
||||
return digest
|
||||
}
|
||||
}
|
||||
|
||||
private fun download(version: String, platform: String, ext: String, file: File, onProgress: (CliDownload) -> Unit) {
|
||||
val url = url(version, platform, ext)
|
||||
log.info("Downloading Kilo CLI $version for $platform from $url")
|
||||
@@ -85,6 +136,30 @@ class KiloCliDownloader(
|
||||
}
|
||||
}
|
||||
|
||||
private fun verify(file: File, digest: String) {
|
||||
val actual = sum(file)
|
||||
if (actual == digest) return
|
||||
if (file.exists() && !file.delete()) log.warn("Failed to delete invalid Kilo CLI archive ${file.absolutePath}")
|
||||
throw IllegalStateException("Kilo CLI archive digest mismatch for ${file.name}: expected $digest, got $actual")
|
||||
}
|
||||
|
||||
private fun matches(file: File, digest: String) = file.isFile && sum(file) == digest
|
||||
|
||||
private fun sum(file: File) = "sha256:${sha256(file)}"
|
||||
|
||||
private fun sha256(file: File): String {
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().buffered().use { input ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
while (true) {
|
||||
val n = input.read(buffer)
|
||||
if (n < 0) break
|
||||
md.update(buffer, 0, n)
|
||||
}
|
||||
}
|
||||
return md.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
}
|
||||
|
||||
private fun extract(file: File, dir: File) {
|
||||
log.info("Extracting Kilo CLI archive ${file.absolutePath}")
|
||||
if (file.name.endsWith(".zip")) {
|
||||
|
||||
+7
-4
@@ -155,12 +155,15 @@ internal fun profileDto(p: KiloProfile200Response): ProfileDto = ProfileDto(
|
||||
organizations = p.profile.organizations.orEmpty().map { org ->
|
||||
ProfileOrganizationDto(id = org.id, name = org.name, role = org.role)
|
||||
},
|
||||
balance = p.balance?.let { ProfileBalanceDto(balance = it.balance ?: 0.0) },
|
||||
balance = p.balance?.balance?.let { ProfileBalanceDto(balance = it) },
|
||||
kiloPass = p.kiloPass?.let {
|
||||
val base = it.currentPeriodBaseCreditsUsd ?: return@let null
|
||||
val usage = it.currentPeriodUsageUsd ?: return@let null
|
||||
val bonus = it.currentPeriodBonusCreditsUsd ?: return@let null
|
||||
ProfileKiloPassDto(
|
||||
currentPeriodBaseCreditsUsd = it.currentPeriodBaseCreditsUsd ?: 0.0,
|
||||
currentPeriodUsageUsd = it.currentPeriodUsageUsd ?: 0.0,
|
||||
currentPeriodBonusCreditsUsd = it.currentPeriodBonusCreditsUsd ?: 0.0,
|
||||
currentPeriodBaseCreditsUsd = base,
|
||||
currentPeriodUsageUsd = usage,
|
||||
currentPeriodBonusCreditsUsd = bonus,
|
||||
nextBillingAt = it.nextBillingAt,
|
||||
)
|
||||
},
|
||||
|
||||
+27
-3
@@ -109,7 +109,7 @@ class KiloBackendAppServiceTest {
|
||||
@Test
|
||||
fun `download progress maps to app state before ready`() = runBlocking {
|
||||
val resolved = CompletableDeferred<Unit>()
|
||||
val ready = CompletableDeferred<Unit>()
|
||||
val signal = CompletableDeferred<Unit>()
|
||||
val server = object : CliServer {
|
||||
override var forceExtract = false
|
||||
override fun process(): Process? = null
|
||||
@@ -117,7 +117,7 @@ class KiloBackendAppServiceTest {
|
||||
onProgress(CliDownload(37, "1.2.3", "darwin-arm64"))
|
||||
resolved.await()
|
||||
onResolved()
|
||||
ready.await()
|
||||
signal.await()
|
||||
return CliServer.State.Ready(mock.start(), mock.password)
|
||||
}
|
||||
override fun exited(proc: Process) {}
|
||||
@@ -141,7 +141,7 @@ class KiloBackendAppServiceTest {
|
||||
svc.appState.first { it == KiloAppState.Connecting }
|
||||
}
|
||||
|
||||
ready.complete(Unit)
|
||||
signal.complete(Unit)
|
||||
ready(svc)
|
||||
job.join()
|
||||
}
|
||||
@@ -852,6 +852,30 @@ class KiloBackendAppServiceTest {
|
||||
assertEquals("org_1", dto.profile?.currentOrgId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ready dto hides unknown profile amounts`() = runBlocking {
|
||||
mock.profile = """{
|
||||
"profile":{"email":"alice@test.com","name":"Alice"},
|
||||
"balance":{"balance":null},
|
||||
"kiloPass":{
|
||||
"currentPeriodBaseCreditsUsd":null,
|
||||
"currentPeriodUsageUsd":null,
|
||||
"currentPeriodBonusCreditsUsd":null,
|
||||
"nextBillingAt":null
|
||||
},
|
||||
"currentOrgId":null
|
||||
}""".trimIndent()
|
||||
val svc = create()
|
||||
svc.connect()
|
||||
|
||||
ready(svc)
|
||||
|
||||
val dto = appStateDto(svc.appState.value)
|
||||
assertEquals("alice@test.com", dto.profile?.email)
|
||||
assertNull(dto.profile?.balance)
|
||||
assertNull(dto.profile?.kiloPass)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh profile updates ready dto profile`() = runBlocking {
|
||||
mock.profile = """{"profile":{"email":"alice@test.com","name":"Alice"},"balance":null,"currentOrgId":null}"""
|
||||
|
||||
+56
-4
@@ -4,17 +4,21 @@ import ai.kilocode.backend.testing.TestLog
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okio.Buffer
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveEntry
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream
|
||||
import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContains
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class KiloCliDownloaderTest {
|
||||
@@ -25,19 +29,22 @@ class KiloCliDownloaderTest {
|
||||
fun `downloads extracts and caches pinned cli`() = runBlocking {
|
||||
MockWebServer().use { server ->
|
||||
val bytes = archive()
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(okio.Buffer().write(bytes)))
|
||||
server.enqueue(metadata(bytes))
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
|
||||
val seen = mutableListOf<CliDownload>()
|
||||
val log = TestLog()
|
||||
val cli = KiloCliDownloader(
|
||||
log = log,
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
).resolve("1.2.3", onProgress = { seen.add(it) })
|
||||
|
||||
assertTrue(cli.isFile)
|
||||
assertEquals(File(File(dir, "1.2.3"), KiloCliPlatform.current()).absolutePath, cli.parentFile.parentFile.absolutePath)
|
||||
assertEquals("#!/bin/sh\n", cli.readText())
|
||||
assertTrue(File(cli.parentFile, "kilo-sandbox-mutation-worker.js").isFile)
|
||||
assertEquals("/api/v1.2.3", server.takeRequest().path)
|
||||
assertEquals("/release/v1.2.3/kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}", server.takeRequest().path)
|
||||
assertEquals(CliDownload(0, "1.2.3", KiloCliPlatform.current()), seen.first())
|
||||
assertTrue(seen.any { it.percent == 100 && it.version == "1.2.3" && it.platform == KiloCliPlatform.current() })
|
||||
@@ -48,20 +55,55 @@ class KiloCliDownloaderTest {
|
||||
log = log,
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
).resolve("1.2.3", onProgress = { cachedProgress.add(it) })
|
||||
assertEquals(cli.absolutePath, cached.absolutePath)
|
||||
assertEquals(1, server.requestCount)
|
||||
assertEquals(2, server.requestCount)
|
||||
assertTrue(cachedProgress.isEmpty())
|
||||
assertContains(log.messages, "INFO: Using cached Kilo CLI 1.2.3 for ${KiloCliPlatform.current()} at ${cli.absolutePath}")
|
||||
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(okio.Buffer().write(bytes)))
|
||||
File(cli.parentFile.parentFile, ".complete").writeText("ok\n")
|
||||
server.enqueue(metadata(bytes))
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
|
||||
val stale = KiloCliDownloader(
|
||||
log = log,
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
).resolve("1.2.3")
|
||||
assertEquals(cli.absolutePath, stale.absolutePath)
|
||||
assertEquals(4, server.requestCount)
|
||||
|
||||
server.enqueue(metadata(bytes))
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
|
||||
val forced = KiloCliDownloader(
|
||||
log = log,
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
).resolve("1.2.3", force = true)
|
||||
assertEquals(cli.absolutePath, forced.absolutePath)
|
||||
assertEquals(2, server.requestCount)
|
||||
assertEquals(6, server.requestCount)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects cli archive with mismatched digest`() = runBlocking {
|
||||
MockWebServer().use { server ->
|
||||
val bytes = archive()
|
||||
server.enqueue(metadata("sha256:${sha256("different".toByteArray())}"))
|
||||
server.enqueue(MockResponse().setResponseCode(200).setBody(Buffer().write(bytes)))
|
||||
|
||||
val ex = assertFailsWith<IllegalStateException> {
|
||||
KiloCliDownloader(
|
||||
root = dir,
|
||||
baseUrl = server.url("/release").toString(),
|
||||
api = server.url("/api").toString(),
|
||||
).resolve("1.2.3")
|
||||
}
|
||||
|
||||
assertContains(ex.message.orEmpty(), "digest mismatch")
|
||||
assertFalse(File(File(File(dir, "1.2.3"), KiloCliPlatform.current()), ".complete").exists())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +116,16 @@ class KiloCliDownloaderTest {
|
||||
return tar(files)
|
||||
}
|
||||
|
||||
private fun metadata(bytes: ByteArray) = metadata("sha256:${sha256(bytes)}")
|
||||
|
||||
private fun metadata(digest: String) = MockResponse().setResponseCode(200).setBody(
|
||||
"""{"assets":[{"name":"kilo-${KiloCliPlatform.current()}.${KiloCliPlatform.archive()}","digest":"$digest"}]}"""
|
||||
)
|
||||
|
||||
private fun sha256(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256")
|
||||
.digest(bytes)
|
||||
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
|
||||
private fun zip(files: Map<String, ByteArray>): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
ZipOutputStream(out).use { zip ->
|
||||
|
||||
+14
@@ -404,6 +404,20 @@ class KiloBackendWorkspaceTest {
|
||||
assertEquals("ses_1", result.sessions[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `workspace maps missing session timestamps to zero`() = runBlocking {
|
||||
mock.sessions = """[
|
||||
{"id":"ses_1","slug":"s","projectID":"p","directory":"/test/project","title":"T","version":"1","time":{"created":null,"updated":null}}
|
||||
]"""
|
||||
val app = setup()
|
||||
val ws = ready(app)
|
||||
loaded(ws)
|
||||
|
||||
val session = ws.sessions().sessions.single()
|
||||
assertEquals(0.0, session.time.created)
|
||||
assertEquals(0.0, session.time.updated)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `workspace creates session in its directory`() = runBlocking {
|
||||
mock.sessionCreate = """{"id":"ses_new","slug":"n","projectID":"p","directory":"/test/project","title":"New","version":"1","time":{"created":1,"updated":1}}"""
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
|
||||
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
|
||||
import org.gradle.api.DefaultTask
|
||||
@@ -14,6 +19,7 @@ import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URI
|
||||
import java.security.MessageDigest
|
||||
import java.util.zip.ZipInputStream
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -21,6 +27,12 @@ import javax.inject.Inject
|
||||
* Generates the CLI OpenAPI spec from the pinned release binary.
|
||||
*/
|
||||
abstract class GenerateOpenApiSpecTask : DefaultTask() {
|
||||
companion object {
|
||||
private val DIGEST = Regex("^sha256:[a-f0-9]{64}$")
|
||||
private val JSON = Json { ignoreUnknownKeys = true }
|
||||
private const val API = "https://api.github.com/repos/Kilo-Org/kilocode/releases/tags"
|
||||
}
|
||||
|
||||
@get:Input
|
||||
abstract val cliVersion: Property<String>
|
||||
|
||||
@@ -68,20 +80,53 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() {
|
||||
val dir = cacheDir.dir(version).map { it.dir(platform) }.get().asFile
|
||||
val exe = File(dir, "bin/${exe()}")
|
||||
val done = File(dir, ".complete")
|
||||
if (exe.isFile && done.isFile) {
|
||||
val cached = done.takeIf { it.isFile }?.readText()?.trim()
|
||||
val archive = File(dir, "kilo-$platform.$ext")
|
||||
if (exe.isFile && cached != null && cached.matches(DIGEST) && matches(archive, cached)) {
|
||||
if (!windows()) exe.setExecutable(true)
|
||||
return exe
|
||||
}
|
||||
dir.mkdirs()
|
||||
val archive = File(dir, "kilo-$platform.$ext")
|
||||
|
||||
val name = "kilo-$platform.$ext"
|
||||
val digest = asset(version, name)
|
||||
if (dir.exists() && !dir.deleteRecursively()) {
|
||||
throw GradleException("Failed to delete cached pinned Kilo CLI under ${dir.absolutePath}")
|
||||
}
|
||||
if (!dir.isDirectory && !dir.mkdirs()) {
|
||||
throw GradleException("Failed to create pinned Kilo CLI cache directory ${dir.absolutePath}")
|
||||
}
|
||||
download("https://github.com/Kilo-Org/kilocode/releases/download/v$version/kilo-$platform.$ext", archive)
|
||||
verify(archive, digest)
|
||||
extract(archive, dir)
|
||||
if (!exe.isFile) throw GradleException("Downloaded CLI archive did not contain bin/${exe()}")
|
||||
if (!windows()) exe.setExecutable(true)
|
||||
done.writeText("ok\n")
|
||||
done.writeText("$digest\n")
|
||||
return exe
|
||||
}
|
||||
|
||||
private fun asset(version: String, name: String): String {
|
||||
val url = "$API/v$version"
|
||||
logger.lifecycle("Fetching pinned Kilo CLI release metadata from $url")
|
||||
val conn = URI(url).toURL().openConnection() as HttpURLConnection
|
||||
conn.connectTimeout = 30_000
|
||||
conn.readTimeout = 120_000
|
||||
conn.instanceFollowRedirects = true
|
||||
conn.setRequestProperty("Accept", "application/vnd.github+json")
|
||||
try {
|
||||
val code = conn.responseCode
|
||||
if (code !in 200..299) throw GradleException("Failed to fetch pinned Kilo CLI release metadata: HTTP $code from $url")
|
||||
val body = conn.inputStream.bufferedReader().use { it.readText() }
|
||||
val digest = JSON.parseToJsonElement(body).jsonObject["assets"]?.jsonArray
|
||||
?.firstOrNull { it.jsonObject["name"]?.jsonPrimitive?.contentOrNull == name }
|
||||
?.jsonObject?.get("digest")?.jsonPrimitive?.contentOrNull
|
||||
?: throw GradleException("Pinned Kilo CLI release $version did not include $name")
|
||||
if (!digest.matches(DIGEST)) throw GradleException("Pinned Kilo CLI release $version asset $name has invalid digest")
|
||||
return digest
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun download(url: String, file: File) {
|
||||
logger.lifecycle("Downloading pinned Kilo CLI from $url")
|
||||
val conn = URI(url).toURL().openConnection() as HttpURLConnection
|
||||
@@ -99,6 +144,30 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun verify(file: File, digest: String) {
|
||||
val actual = sum(file)
|
||||
if (actual == digest) return
|
||||
if (file.exists() && !file.delete()) logger.warn("Failed to delete invalid pinned Kilo CLI archive ${file.absolutePath}")
|
||||
throw GradleException("Pinned Kilo CLI archive digest mismatch for ${file.name}: expected $digest, got $actual")
|
||||
}
|
||||
|
||||
private fun matches(file: File, digest: String) = file.isFile && sum(file) == digest
|
||||
|
||||
private fun sum(file: File) = "sha256:${sha256(file)}"
|
||||
|
||||
private fun sha256(file: File): String {
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().buffered().use { input ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
while (true) {
|
||||
val n = input.read(buffer)
|
||||
if (n < 0) break
|
||||
md.update(buffer, 0, n)
|
||||
}
|
||||
}
|
||||
return md.digest().joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
}
|
||||
|
||||
private fun extract(file: File, dir: File) {
|
||||
if (file.name.endsWith(".zip")) {
|
||||
ZipInputStream(file.inputStream().buffered()).use { zip ->
|
||||
|
||||
+1
-1
@@ -82,10 +82,10 @@ class KiloAppService internal constructor(
|
||||
}
|
||||
|
||||
private fun onState(state: KiloAppStateDto) {
|
||||
_state.value = state
|
||||
val version = state.downloadVersion
|
||||
val platform = state.downloadPlatform
|
||||
if (version != null && platform != null) info = CoreInfo(version, platform)
|
||||
_state.value = state
|
||||
if (state.status == KiloAppStatusDto.READY) refreshModelFavoritesAsync()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user