diff --git a/.github/workflows/publish-jetbrains.yml b/.github/workflows/publish-jetbrains.yml index a5d7b51576f..ba43fb4124a 100644 --- a/.github/workflows/publish-jetbrains.yml +++ b/.github/workflows/publish-jetbrains.yml @@ -125,19 +125,11 @@ jobs: JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }} JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }} - - name: Prepare CLI resources - working-directory: packages/kilo-jetbrains - run: bun script/build.ts --production --prepare-cli - env: - KILO_VERSION: ${{ steps.release.outputs.version }} - KILO_CHANNEL: ${{ steps.release.outputs.cli_channel }} - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - - name: Verify plugin working-directory: packages/kilo-jetbrains run: ./gradlew verifyPlugin -Pproduction=true -Pkilo.channel="$CHANNEL" env: + GH_TOKEN: ${{ github.token }} VERSION: ${{ steps.release.outputs.version }} CHANNEL: ${{ steps.release.outputs.marketplace_channel }} diff --git a/.kilo/skills/release-jetbrains/SKILL.md b/.kilo/skills/release-jetbrains/SKILL.md index c84718e439e..b34ec79c85e 100644 --- a/.kilo/skills/release-jetbrains/SKILL.md +++ b/.kilo/skills/release-jetbrains/SKILL.md @@ -34,7 +34,40 @@ Accepted specs: | `x.y.z-rc.n` | Explicit RC release. | | `x.y.z` | Explicit stable release. | -Show the resolved `version`, `kind`, and default `fromTagDefault` to the user and ask for confirmation before continuing. +Show the resolved `version`, `kind`, and default `fromTagDefault` to the user. + +## CLI Pin Verification + +Before dispatching prepare, verify the JetBrains plugin is pinned to the intended Kilo Core release. The plugin downloads the CLI version from `packages/kilo-jetbrains/package.json`, not from the JetBrains plugin version. + +Read the pinned CLI version: + +```bash +bun -e 'const p=require("./packages/kilo-jetbrains/package.json"); console.log(p.version)' +``` + +Verify the matching GitHub Release exists and includes every runtime asset the backend may download: + +```bash +cli_version="7.4.1" +gh release view "v${cli_version}" --repo Kilo-Org/kilocode --json assets \ + --jq '.assets[].name' | sort +``` + +Expected assets: + +```text +kilo-darwin-arm64.zip +kilo-darwin-x64.zip +kilo-linux-arm64.tar.gz +kilo-linux-x64.tar.gz +kilo-windows-arm64.zip +kilo-windows-x64.zip +``` + +If the pin is stale or the release assets are missing, stop and ask the user to update `packages/kilo-jetbrains/package.json` on `main` before dispatching prepare. The prepare workflow tags `origin/main`, so the pin must already be reviewed and merged before the release tag is created. + +Show the resolved JetBrains plugin version, release kind, default `fromTagDefault`, pinned CLI version, and CLI release asset status to the user, then ask for confirmation before continuing. ## Prepare Workflow diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 61ee8f2e57e..d78e6dd9e49 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -126,10 +126,10 @@ Requires Java 21 (see [Prerequisites](#prerequisites)). From `packages/kilo-jetb ```bash ./gradlew typecheck # Compile-check all Kotlin sources ./gradlew test # Run all tests (backend + frontend) -./gradlew --no-configuration-cache runIdeSplitMode # Launch local split-mode sandbox and prepare the CLI +./gradlew --no-configuration-cache runIdeSplitMode # Launch local split-mode sandbox; backend downloads the pinned CLI ``` -Use `./gradlew runIde` only for a monolithic sandbox; prepare the CLI first with `bun run build --prepare-cli`. +Use `./gradlew runIde` only for a monolithic sandbox. JetBrains dev runs do not build or bundle CLI binaries; the backend downloads the pinned release at connect time. Or via the root turbo filter to run only JetBrains checks from the repo root: diff --git a/packages/kilo-docs/pages/contributing/architecture/jetbrains-plugin.md b/packages/kilo-docs/pages/contributing/architecture/jetbrains-plugin.md index bbfa42efc4d..cc537bd4c9c 100644 --- a/packages/kilo-docs/pages/contributing/architecture/jetbrains-plugin.md +++ b/packages/kilo-docs/pages/contributing/architecture/jetbrains-plugin.md @@ -131,10 +131,10 @@ JetBrains Kotlin toolchain is Java 21. Gradle commands report missing or incompa | Typecheck | `./gradlew typecheck` | | Tests | `./gradlew test` | | Full plugin build | `bun run build` | -| Gradle plugin assembly with prepared CLI binaries | `./gradlew buildPlugin` | +| Gradle plugin assembly with pinned CLI download | `./gradlew buildPlugin` | | Split-mode sandbox | `./gradlew --no-configuration-cache runIdeSplitMode` | | Split backend sandbox | `./gradlew --no-configuration-cache runIdeBackend` | -| Monolithic sandbox IDE | `./gradlew runIde` after `bun run build --prepare-cli` | +| Monolithic sandbox IDE | `./gradlew runIde` | Run `Plugin DevKit | Code | Frontend and Backend API Usage` inspection when moving code across split boundary. diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md index 05dbd6c0d10..323b158f6d7 100644 --- a/packages/kilo-jetbrains/AGENTS.md +++ b/packages/kilo-jetbrains/AGENTS.md @@ -191,14 +191,14 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi - **Marketplace version build**: Use `script/build-version.sh ` from `packages/kilo-jetbrains/` to clean, build, sign, and verify the JetBrains Marketplace plugin ZIP. Pass `--skip-verification` only when explicitly needed. - **Test version build**: If the user asks for a JetBrains test build, still require a version and use `script/build-version.sh --skip-signing --skip-verification` from `packages/kilo-jetbrains/` so no signing secrets are needed. Add `--skip-clean` only when the user wants a faster incremental test build. -- **Typecheck**: `bun run typecheck` or `./gradlew typecheck` from `packages/kilo-jetbrains/` — compiles all Kotlin sources including the generated API client. Does NOT require CLI binaries. +- **Typecheck**: `bun run typecheck` or `./gradlew typecheck` from `packages/kilo-jetbrains/` — compiles all Kotlin sources including the generated API client. A cold build downloads the pinned CLI release via `generateOpenApiSpec` and needs network access; Gradle-cached incremental runs skip the download. It does not bundle per-platform CLI binaries. - **Full build**: `bun run build` from `packages/kilo-jetbrains/` (runs Gradle `buildPlugin`). - **Gradle only**: `./gradlew buildPlugin` from `packages/kilo-jetbrains/`. - **Java checks**: Do not run `java -version` as a routine preflight. Gradle commands already fail clearly when Java is missing or incompatible; check Java only when diagnosing that failure mode. - **Via Turbo**: `bun turbo build --filter=@kilocode/kilo-jetbrains` from repo root. - **Run split mode**: `./gradlew --no-configuration-cache runIdeSplitMode` or the checked-in `Run IDE (Split Mode)` configuration — launches backend and frontend locally. Emulate latency via the Split Mode widget (requires internal mode: `-Didea.is.internal=true`). - **Run split backend**: `./gradlew --no-configuration-cache runIdeBackend` — if it exits shortly after startup, check for an orphaned Java process from a previous backend run and kill it before restarting. -- **Run in monolithic sandbox**: `./gradlew runIde` — launches sandboxed IntelliJ with the plugin. Does NOT build CLI binaries. +- **Run in monolithic sandbox**: `./gradlew runIde` — launches sandboxed IntelliJ with the plugin. Does not build or bundle CLI binaries; the backend downloads the pinned release at connect time. ### CLI/SDK Change Awareness diff --git a/packages/kilo-jetbrains/RELEASING.md b/packages/kilo-jetbrains/RELEASING.md index 47d65e0f5ce..a9e844b86aa 100644 --- a/packages/kilo-jetbrains/RELEASING.md +++ b/packages/kilo-jetbrains/RELEASING.md @@ -8,6 +8,8 @@ The published code comes from `jetbrains/v`. Marketplace and GitHub rel Maintainers can use the Kilo `release-jetbrains` skill to drive this process from a version request such as `next rc` or an explicit version. The skill resolves and confirms the version, dispatches and watches the prepare workflow, helps produce a filtered human-readable JetBrains/CLI changelog draft, commits the reviewed changelog to the release PR, and watches publishing after the PR is merged. +JetBrains plugin builds and runtime downloads use the Kilo Core version pinned in `packages/kilo-jetbrains/package.json`, so verify that pin points at a published `v` release before creating the release tag. + The skill lives at `.kilo/skills/release-jetbrains/SKILL.md`. It does not move or recreate release tags, and merge permission is only required if the user explicitly asks the skill to merge the release PR automatically. ## Create Release Tag And PR diff --git a/packages/kilo-jetbrains/backend/build.gradle.kts b/packages/kilo-jetbrains/backend/build.gradle.kts index f6660399878..7421a38b88e 100644 --- a/packages/kilo-jetbrains/backend/build.gradle.kts +++ b/packages/kilo-jetbrains/backend/build.gradle.kts @@ -40,6 +40,10 @@ val writeKiloProperties by tasks.registering(WriteProperties::class) { val generateOpenApiSpec by tasks.registering(GenerateOpenApiSpecTask::class) { description = "Generate CLI OpenAPI spec into the build directory" cliVersion.set(pinnedCliVersion) + token.set( + providers.environmentVariable("GH_TOKEN") + .orElse(providers.environmentVariable("GITHUB_TOKEN")) + ) cacheDir.set(layout.buildDirectory.dir("cli-cache")) spec.set(rawSpec) } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt index b0e75d42488..73630a7d20a 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDownloader.kt @@ -12,10 +12,12 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.Response 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.time.Instant import java.util.concurrent.TimeUnit import java.util.zip.ZipInputStream import kotlin.math.roundToInt @@ -88,8 +90,21 @@ class KiloCliDownloader( .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 info = rate(response) + val body = runCatching { response.body?.string() }.getOrNull()?.take(500) + val detail = if (body.isNullOrBlank()) "" else ": $body" + if (limited(response)) { + log.warn("GitHub API rate limit hit fetching Kilo CLI $version metadata from $url ($info)$detail") + throw IllegalStateException( + "GitHub API rate limit exceeded while resolving Kilo CLI $version ($info)$detail" + ) + } + log.warn("Failed to fetch Kilo CLI $version metadata from $url: HTTP ${response.code} ($info)$detail") + throw IllegalStateException( + "Failed to fetch Kilo CLI release metadata for $version: HTTP ${response.code} ($info)$detail" + ) } + log.debug { "GitHub metadata OK for Kilo CLI $version (${rate(response)})" } 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 @@ -103,6 +118,17 @@ class KiloCliDownloader( } } + private fun rate(response: Response): String { + val reset = response.header("X-RateLimit-Reset") + ?.toLongOrNull() + ?.let { Instant.ofEpochSecond(it).toString() } + return "limit=${response.header("X-RateLimit-Limit")} remaining=${response.header("X-RateLimit-Remaining")} " + + "used=${response.header("X-RateLimit-Used")} reset=$reset retryAfter=${response.header("Retry-After")}" + } + + private fun limited(response: Response) = + response.code == 429 || (response.code == 403 && response.header("X-RateLimit-Remaining") == "0") + 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") diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt index eea3cda1c55..6a2e94bc02b 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDownloaderTest.kt @@ -107,6 +107,39 @@ class KiloCliDownloaderTest { } } + @Test + fun `reports github api rate limits while resolving metadata`() = runBlocking { + MockWebServer().use { server -> + val reset = 1_800_000_000L + server.enqueue( + MockResponse() + .setResponseCode(403) + .setHeader("X-RateLimit-Limit", "60") + .setHeader("X-RateLimit-Remaining", "0") + .setHeader("X-RateLimit-Used", "60") + .setHeader("X-RateLimit-Reset", reset.toString()) + .setBody("API rate limit exceeded") + ) + val log = TestLog() + + val ex = assertFailsWith { + KiloCliDownloader( + log = log, + root = dir, + baseUrl = server.url("/release").toString(), + api = server.url("/api").toString(), + ).resolve("1.2.3") + } + + assertContains(ex.message.orEmpty(), "GitHub API rate limit exceeded") + assertContains(ex.message.orEmpty(), "remaining=0") + assertContains(ex.message.orEmpty(), "reset=2027-01-15T08:00:00Z") + assertTrue(log.messages.any { it.contains("GitHub API rate limit hit") && it.contains("remaining=0") }) + assertEquals("/api/v1.2.3", server.takeRequest().path) + assertEquals(1, server.requestCount) + } + } + private fun archive(): ByteArray { val files = mapOf( "bin/${KiloCliPlatform.exe()}" to "#!/bin/sh\n".toByteArray(), diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt index 0d1d65ecb87..de463945f87 100644 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/GenerateOpenApiSpecTask.kt @@ -20,6 +20,7 @@ import java.io.File import java.net.HttpURLConnection import java.net.URI import java.security.MessageDigest +import java.time.Instant import java.util.zip.ZipInputStream import javax.inject.Inject @@ -36,6 +37,9 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() { @get:Input abstract val cliVersion: Property + @get:Internal + abstract val token: Property + @get:Internal abstract val cacheDir: DirectoryProperty @@ -112,9 +116,25 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() { conn.readTimeout = 120_000 conn.instanceFollowRedirects = true conn.setRequestProperty("Accept", "application/vnd.github+json") + token.getOrNull() + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { conn.setRequestProperty("Authorization", "Bearer $it") } try { val code = conn.responseCode - if (code !in 200..299) throw GradleException("Failed to fetch pinned Kilo CLI release metadata: HTTP $code from $url") + if (code !in 200..299) { + val info = rate(conn) + val body = runCatching { conn.errorStream?.bufferedReader()?.use { it.readText() } } + .getOrNull() + ?.take(500) + val detail = if (body.isNullOrBlank()) "" else ": $body" + if (limited(conn, code)) { + throw GradleException( + "GitHub API rate limit exceeded while fetching pinned Kilo CLI release metadata ($info)$detail" + ) + } + throw GradleException("Failed to fetch pinned Kilo CLI release metadata: HTTP $code from $url ($info)$detail") + } val body = conn.inputStream.bufferedReader().use { it.readText() } val digest = JSON.parseToJsonElement(body).jsonObject["assets"]?.jsonArray ?.firstOrNull { it.jsonObject["name"]?.jsonPrimitive?.contentOrNull == name } @@ -127,6 +147,17 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() { } } + private fun rate(conn: HttpURLConnection): String { + val reset = conn.getHeaderField("X-RateLimit-Reset") + ?.toLongOrNull() + ?.let { Instant.ofEpochSecond(it).toString() } + return "limit=${conn.getHeaderField("X-RateLimit-Limit")} remaining=${conn.getHeaderField("X-RateLimit-Remaining")} " + + "used=${conn.getHeaderField("X-RateLimit-Used")} reset=$reset retryAfter=${conn.getHeaderField("Retry-After")}" + } + + private fun limited(conn: HttpURLConnection, code: Int) = + code == 429 || (code == 403 && conn.getHeaderField("X-RateLimit-Remaining") == "0") + private fun download(url: String, file: File) { logger.lifecycle("Downloading pinned Kilo CLI from $url") val conn = URI(url).toURL().openConnection() as HttpURLConnection