feat(jetbrains): add repo CLI dev mode

This commit is contained in:
kirillk
2026-07-08 12:31:37 -04:00
parent 2b8d19617d
commit 98d8d22ae8
15 changed files with 325 additions and 8 deletions
+9 -1
View File
@@ -40,6 +40,14 @@ Show the resolved `version`, `kind`, and default `fromTagDefault` to the user.
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.
Verify repo CLI dev mode is disabled on `main` before creating the immutable tag:
```bash
git show origin/main:packages/kilo-jetbrains/gradle.properties | grep '^kilo.cli.pinned=' || true
```
If `kilo.cli.pinned` is present and is not `true`, stop and ask the user to reset it to `true` on `main` before dispatching prepare. `kilo.cli.pinned=false` generates from and bundles the local repo CLI, so it is dev-only and non-releasable.
Read the pinned CLI version:
```bash
@@ -67,7 +75,7 @@ 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.
Show the resolved JetBrains plugin version, release kind, default `fromTagDefault`, `kilo.cli.pinned` status, pinned CLI version, and CLI release asset status to the user, then ask for confirmation before continuing.
## Prepare Workflow
+8 -4
View File
@@ -15,6 +15,7 @@
- `plugin.xml` `<content>` entries ↔ module XML descriptors (`kilo.jetbrains.{shared,frontend,backend}.xml`)
- Service classes ↔ `<applicationService>`/`<projectService>` entries in the corresponding module XML
- `packages/kilo-jetbrains/package.json` version ↔ GitHub CLI release tag consumed by the backend downloader
- `packages/kilo-jetbrains/gradle.properties` `kilo.cli.pinned` ↔ Gradle and release-script gates
## IntelliJ Platform Source Lookup
@@ -154,8 +155,10 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi
## CLI Integration
- CLI process spawning, download, extraction, and lifecycle belong in `backend`.
- The plugin does not bundle CLI binaries. At connect time the backend downloads the GitHub Release asset for the version pinned in `packages/kilo-jetbrains/package.json`; `backend` resources include `kilo.properties` with `cli.version` for split-mode RPC and runtime use.
- The generated API client is produced from the pinned release binary by running `kilo generate` during the Gradle OpenAPI generation task.
- By default, the plugin does not bundle CLI binaries. At connect time the backend downloads the GitHub Release asset for the version pinned in `packages/kilo-jetbrains/package.json`; `backend` resources include `kilo.properties` with `cli.version` and `cli.pinned` for split-mode RPC and runtime use.
- `kilo.cli.pinned=false` in `gradle.properties` is dev-only repo CLI mode: OpenAPI generation runs `bun run --conditions=browser ./src/index.ts generate` from `packages/opencode/`, and runtime extracts a staged local CLI resource instead of downloading.
- Repo CLI mode requires a local CLI build. Run `./gradlew :backend:buildRepoCli` from `packages/kilo-jetbrains/` or `bun run script/build.ts --single --skip-install` from `packages/opencode/`, then let `:backend:stageRepoCli` bundle the full `dist/@kilocode/cli-<os>-<arch>/bin/` directory.
- Production builds must keep `kilo.cli.pinned=true`; Gradle release mode, release scripts, and `script/build-version.sh` reject repo CLI mode.
- For OS and environment checks, prefer IntelliJ Platform classes over raw JVM APIs such as `System.getProperty(...)` or `System.getenv(...)`.
- Detect architecture with `com.intellij.util.system.CpuArch.CURRENT`, not `System.getProperty("os.arch")`.
- Detect OS with `com.intellij.openapi.util.SystemInfo.isMac` / `isLinux` / `isWindows`.
@@ -191,7 +194,8 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi
- **Marketplace version build**: Use `script/build-version.sh <version>` 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 <version> --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. 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.
- **Typecheck**: `bun run typecheck` or `./gradlew typecheck` from `packages/kilo-jetbrains/` — compiles all Kotlin sources including the generated API client. A cold pinned build downloads the pinned CLI release via `generateOpenApiSpec` and needs network access; Gradle-cached incremental runs skip the download. Repo CLI mode (`-Pkilo.cli.pinned=false`) generates the spec from local source and bundles the staged local CLI binary.
- **Build local repo CLI for JetBrains dev**: `./gradlew :backend:buildRepoCli` from `packages/kilo-jetbrains/` builds `packages/opencode/dist/@kilocode/cli-<os>-<arch>/bin/`. `stageRepoCli` intentionally does not depend on this task; missing binaries fail with instructions instead of silently starting a slow CLI build.
- **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.
@@ -202,7 +206,7 @@ For blocking I/O in coroutines, move the dispatcher switch inside the callee usi
### CLI/SDK Change Awareness
- JetBrains runtime behavior depends on the downloaded CLI release pinned by `packages/kilo-jetbrains/package.json`; local `packages/opencode/` changes are not used unless published and pinned.
- JetBrains runtime behavior normally depends on the downloaded CLI release pinned by `packages/kilo-jetbrains/package.json`; local `packages/opencode/` changes are used only with `kilo.cli.pinned=false` repo CLI mode.
- If there are relevant server/API changes outside `packages/kilo-jetbrains/`, warn the user that JetBrains may need a newly published/pinned CLI release and regenerated SDK artifacts.
## UI Guidelines
+2
View File
@@ -10,6 +10,8 @@ Maintainers can use the Kilo `release-jetbrains` skill to drive this process fro
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<version>` release before creating the release tag.
`kilo.cli.pinned=false` in `packages/kilo-jetbrains/gradle.properties` is local development mode only. It generates the client from `packages/opencode/` and bundles a locally built CLI into the plugin; production Gradle builds and release scripts fail until the property is restored to `true`.
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
@@ -1,4 +1,6 @@
import normalization.NormalizeOpenApiSpecTask
import org.gradle.api.GradleException
import org.gradle.api.tasks.Exec
import org.gradle.api.tasks.WriteProperties
plugins {
@@ -17,6 +19,10 @@ val generatedApi = layout.buildDirectory.dir("generated/openapi/src/main/kotlin"
val rawSpec = layout.buildDirectory.file("generated/openapi-spec/openapi.raw.json")
val generatedSpec = layout.buildDirectory.file("generated/openapi-spec/openapi.json")
val generatedProps = layout.buildDirectory.dir("generated/kilo-props")
val generatedCli = layout.buildDirectory.dir("generated/kilo-cli-res")
val pinned = providers.gradleProperty("kilo.cli.pinned").map { it.trim().toBoolean() }.orElse(true)
val repoCli = pinned.map { !it }
val repoRootDir = rootProject.layout.projectDirectory.dir("../opencode")
val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirectory.file("package.json")).asText.map { text ->
Regex("\"version\"\\s*:\\s*\"([^\"]+)\"").find(text)?.groupValues?.get(1)
@@ -26,6 +32,7 @@ val pinnedCliVersion = providers.fileContents(rootProject.layout.projectDirector
sourceSets {
main {
resources.srcDir(generatedProps)
if (repoCli.get()) resources.srcDir(generatedCli)
kotlin.srcDir(generatedApi)
}
}
@@ -35,11 +42,14 @@ val writeKiloProperties by tasks.registering(WriteProperties::class) {
val out = generatedProps.map { it.file("kilo.properties") }
destinationFile.set(out)
property("cli.version", pinnedCliVersion)
property("cli.pinned", pinned.map { it.toString() })
}
val generateOpenApiSpec by tasks.registering(GenerateOpenApiSpecTask::class) {
description = "Generate CLI OpenAPI spec into the build directory"
cliVersion.set(pinnedCliVersion)
repo.set(repoCli)
repoRoot.set(repoRootDir)
token.set(
providers.environmentVariable("GH_TOKEN")
.orElse(providers.environmentVariable("GITHUB_TOKEN"))
@@ -48,6 +58,36 @@ val generateOpenApiSpec by tasks.registering(GenerateOpenApiSpecTask::class) {
spec.set(rawSpec)
}
val buildRepoCli by tasks.registering(Exec::class) {
description = "Build the local repo CLI for the current platform"
workingDir = repoRootDir.asFile
commandLine("bun", "run", "script/build.ts", "--single", "--skip-install")
}
fun platform(): String {
val os = System.getProperty("os.name").lowercase()
val name = when {
os.contains("mac") || os.contains("darwin") -> "darwin"
os.contains("linux") -> "linux"
os.contains("windows") -> "windows"
else -> throw GradleException("Unsupported OS: ${System.getProperty("os.name")}")
}
val arch = when (System.getProperty("os.arch").lowercase()) {
"aarch64", "arm64" -> "arm64"
"x86_64", "amd64" -> "x64"
else -> throw GradleException("Unsupported architecture: ${System.getProperty("os.arch")}")
}
return "$name-$arch"
}
val stageRepoCli by tasks.registering(StageRepoCliTask::class) {
description = "Stage the local repo CLI into backend resources"
val bin = repoRootDir.dir("dist/@kilocode/cli-${platform()}/bin")
this.bin.set(bin)
archive.set(generatedCli.map { it.file("kilo-cli.zip") })
outputs.upToDateWhen { false }
}
val normalizeOpenApiSpec by tasks.registering(NormalizeOpenApiSpecTask::class) {
description = "Normalize upstream CLI OpenAPI metadata before Kotlin client generation"
dependsOn(generateOpenApiSpec)
@@ -102,11 +142,13 @@ val fixGeneratedApi by tasks.registering(FixGeneratedApiTask::class) {
tasks.named("compileKotlin") {
dependsOn(fixGeneratedApi, writeKiloProperties)
if (repoCli.get()) dependsOn(stageRepoCli)
inputs.dir(generatedApi)
}
tasks.named("processResources") {
dependsOn(writeKiloProperties)
if (repoCli.get()) dependsOn(stageRepoCli)
}
tasks.named("compileTestKotlin") {
@@ -87,6 +87,11 @@ class KiloBackendCliManager(
private suspend fun resolveCli(onProgress: (CliDownload) -> Unit): File {
val force = forceExtract
forceExtract = false
if (!KiloProps.pinned()) {
if (force) log.info("Force re-extracting local repo CLI ${KiloProps.cliVersion()}")
onProgress(CliDownload(100, KiloProps.cliVersion(), KiloCliPlatform.current()))
return KiloRepoCli.extract(force)
}
if (force) log.info("Force re-downloading CLI ${KiloProps.cliVersion()}")
return KiloCliDownloader(log = log).resolve(KiloProps.cliVersion(), force, onProgress)
}
@@ -13,4 +13,8 @@ object KiloProps {
fun cliVersion(): String = props.getProperty("cli.version")
?: throw IllegalStateException("cli.version missing from kilo.properties")
fun pinned(): Boolean = pinned(props)
internal fun pinned(props: Properties): Boolean = props.getProperty("cli.pinned")?.toBoolean() ?: true
}
@@ -0,0 +1,68 @@
package ai.kilocode.backend.cli
import com.intellij.openapi.application.PathManager
import com.intellij.openapi.util.SystemInfo
import java.io.File
import java.io.InputStream
import java.io.OutputStream
import java.util.zip.ZipInputStream
object KiloRepoCli {
fun extract(force: Boolean): File = extract(
force = force,
root = File(PathManager.getSystemPath(), "kilo/repo-cli"),
source = {
KiloRepoCli::class.java.classLoader.getResourceAsStream("kilo-cli.zip")
?: throw IllegalStateException("kilo-cli.zip resource not found; rebuild with kilo.cli.pinned=false")
},
)
internal fun extract(force: Boolean, root: File, source: () -> InputStream): File {
val exe = File(root, "bin/${KiloCliPlatform.exe()}")
val done = File(root, ".complete")
if (!force && done.isFile && exe.isFile) {
if (!SystemInfo.isWindows) exe.setExecutable(true)
return exe
}
if (root.exists() && !root.deleteRecursively()) {
throw IllegalStateException("Failed to delete local repo CLI under ${root.absolutePath}")
}
if (!root.isDirectory && !root.mkdirs()) {
throw IllegalStateException("Failed to create local repo CLI directory ${root.absolutePath}")
}
source().use { input ->
ZipInputStream(input.buffered()).use { zip ->
while (true) {
val entry = zip.nextEntry ?: break
write(root, entry.name, entry.isDirectory) { out -> zip.copyTo(out) }
zip.closeEntry()
}
}
}
if (!exe.isFile) throw IllegalStateException("Local repo CLI archive did not contain bin/${KiloCliPlatform.exe()}")
if (!SystemInfo.isWindows) exe.setExecutable(true)
done.writeText("ok\n")
return exe
}
private fun write(dir: File, name: String, directory: Boolean, copy: (OutputStream) -> Unit) {
val path = if (name.startsWith("bin/")) name else "bin/$name"
val target = File(dir, path).canonicalFile
val base = dir.canonicalFile
if (target != base && !target.path.startsWith(base.path + File.separator)) {
throw IllegalStateException("Archive entry escapes target directory: $name")
}
if (directory) {
target.mkdirs()
return
}
target.parentFile.mkdirs()
target.outputStream().use(copy)
if (!SystemInfo.isWindows && (target.name == "kilo" || target.name == "bwrap")) {
target.setExecutable(true)
}
}
}
@@ -0,0 +1,68 @@
package ai.kilocode.backend.cli
import org.junit.jupiter.api.io.TempDir
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.Properties
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.assertTrue
class KiloRepoCliTest {
@TempDir
lateinit var dir: File
@Test
fun `extracts cached repo cli and force re-extracts`() {
val first = archive("#!/bin/old\n")
val next = archive("#!/bin/new\n")
val cli = KiloRepoCli.extract(false, dir) { ByteArrayInputStream(first) }
assertTrue(cli.isFile)
assertEquals("#!/bin/old\n", cli.readText())
assertTrue(File(cli.parentFile, "kilo-sandbox-mutation-worker.js").isFile)
assertTrue(File(dir, ".complete").isFile)
val cached = KiloRepoCli.extract(false, dir) { ByteArrayInputStream(next) }
assertEquals(cli.absolutePath, cached.absolutePath)
assertEquals("#!/bin/old\n", cached.readText())
val forced = KiloRepoCli.extract(true, dir) { ByteArrayInputStream(next) }
assertEquals(cli.absolutePath, forced.absolutePath)
assertEquals("#!/bin/new\n", forced.readText())
}
@Test
fun `rejects archive entries that escape root`() {
val ex = assertFailsWith<IllegalStateException> {
KiloRepoCli.extract(false, dir) { ByteArrayInputStream(archive(entry = "../../../bad")) }
}
assertContains(ex.message.orEmpty(), "escapes target directory")
}
@Test
fun `pinned defaults true unless explicitly false`() {
assertEquals(true, KiloProps.pinned(Properties()))
assertEquals(true, KiloProps.pinned(Properties().apply { setProperty("cli.pinned", "true") }))
assertEquals(false, KiloProps.pinned(Properties().apply { setProperty("cli.pinned", "false") }))
}
private fun archive(script: String = "#!/bin/sh\n", entry: String = "bin/${KiloCliPlatform.exe()}"): ByteArray {
val out = ByteArrayOutputStream()
ZipOutputStream(out).use { zip ->
zip.putNextEntry(ZipEntry(entry))
zip.write(script.toByteArray())
zip.closeEntry()
zip.putNextEntry(ZipEntry("bin/kilo-sandbox-mutation-worker.js"))
zip.write("worker".toByteArray())
zip.closeEntry()
}
return out.toByteArray()
}
}
@@ -37,6 +37,12 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() {
@get:Input
abstract val cliVersion: Property<String>
@get:Input
abstract val repo: Property<Boolean>
@get:Internal
abstract val repoRoot: DirectoryProperty
@get:Internal
abstract val token: Property<String>
@@ -49,20 +55,51 @@ abstract class GenerateOpenApiSpecTask : DefaultTask() {
@get:Inject
abstract val exec: ExecOperations
init {
repo.convention(false)
outputs.upToDateWhen { !repo.getOrElse(false) }
}
@TaskAction
fun run() {
if (repo.getOrElse(false)) {
generateFromRepo()
return
}
val kilo = resolve()
generate(kilo.absolutePath)
}
private fun generateFromRepo() {
val root = repoRoot.asFile.get()
val out = ByteArrayOutputStream()
val err = ByteArrayOutputStream()
val result = exec.exec {
commandLine(kilo.absolutePath, "generate")
workingDir = root
commandLine("bun", "run", "--conditions=browser", "./src/index.ts", "generate")
standardOutput = out
errorOutput = err
isIgnoreExitValue = true
}
if (result.exitValue != 0) {
writeSpec(result.exitValue, out, err)
}
private fun generate(kilo: String) {
val out = ByteArrayOutputStream()
val err = ByteArrayOutputStream()
val result = exec.exec {
commandLine(kilo, "generate")
standardOutput = out
errorOutput = err
isIgnoreExitValue = true
}
writeSpec(result.exitValue, out, err)
}
private fun writeSpec(code: Int, out: ByteArrayOutputStream, err: ByteArrayOutputStream) {
if (code != 0) {
throw GradleException(
"kilo generate failed with exit code ${result.exitValue}.\n" +
"kilo generate failed with exit code $code.\n" +
err.toString(Charsets.UTF_8).take(2000)
)
}
@@ -0,0 +1,45 @@
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction
import java.io.File
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
abstract class StageRepoCliTask : DefaultTask() {
@get:Internal
abstract val bin: DirectoryProperty
@get:OutputFile
abstract val archive: RegularFileProperty
@TaskAction
fun run() {
val dir = bin.asFile.get()
val exe = File(dir, exe())
if (!exe.isFile) {
throw GradleException(
"Repo CLI binary not found at ${exe.absolutePath}. Run ./gradlew :backend:buildRepoCli " +
"(or bun run script/build.ts --single --skip-install in packages/opencode) first."
)
}
val out = archive.get().asFile
out.parentFile.mkdirs()
ZipOutputStream(out.outputStream().buffered()).use { zip ->
dir.walkTopDown()
.filter { it.isFile }
.forEach { file ->
val name = "bin/${file.relativeTo(dir).invariantSeparatorsPath}"
zip.putNextEntry(ZipEntry(name))
file.inputStream().use { it.copyTo(zip) }
zip.closeEntry()
}
}
}
private fun exe() = if (System.getProperty("os.name").lowercase().contains("windows")) "kilo.exe" else "kilo"
}
+5
View File
@@ -82,6 +82,7 @@ fun gitTag(): String? {
}
val release = providers.gradleProperty("production").map { it.toBoolean() }.orElse(false).get()
val pinned = providers.gradleProperty("kilo.cli.pinned").map { it.trim().toBoolean() }.orElse(true).get()
val override = providers.gradleProperty("kilo.version").orNull?.trim()?.takeIf { it.isNotEmpty() }
val prop = providers.gradleProperty("kilo.jetbrains.version").orNull?.trim()?.takeIf { it.isNotEmpty() }
val tag = gitTag()?.removePrefix("jetbrains/v")
@@ -89,6 +90,10 @@ val ver = override?.let(::checked) ?: prop?.let(::checked) ?: if (release) check
tag ?: error("Missing JetBrains plugin version. Publish builds must set kilo.jetbrains.version or run from a jetbrains/v<version> tag."),
) else checked(tag ?: "0.0.0-dev")
if (release && !pinned) error(
"kilo.cli.pinned=false is a dev-only mode and cannot be released. Set kilo.cli.pinned=true before a production/publish build."
)
val channel = providers.gradleProperty("kilo.channel").map { it.trim() }.orElse("default")
val splitPort = providers.gradleProperty("kilo.splitModeServerPort").map(::port).orElse(0)
val isolated = providers.gradleProperty("kilo.dev.storage.isolated").map { it.toBoolean() }.orElse(false)
@@ -1,5 +1,9 @@
kotlin.stdlib.default.dependency=false
kilo.jetbrains.version=7.0.2
# When true (default) the JetBrains plugin uses the pinned CLI release from package.json.
# Set to false ONLY for local dev: generate the client from local source + bundle the local binary.
# false is NOT releasable -- production builds fail unless this is true.
kilo.cli.pinned=true
org.gradle.configuration-cache=true
org.gradle.caching=true
org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m
@@ -86,6 +86,11 @@ if [[ ! -d "$plugin" ]]; then
exit 1
fi
if grep -q '^kilo\.cli\.pinned=false[[:space:]]*$' "$plugin/gradle.properties"; then
echo "kilo.cli.pinned=false is a dev-only mode and cannot be released. Set kilo.cli.pinned=true before building a version." >&2
exit 1
fi
if [[ "$sign" == "1" ]]; then
for file in "$chain" "$key" "$pass"; do
if [[ ! -s "$file" ]]; then
+10
View File
@@ -42,6 +42,9 @@ if (kind === "stable" && !/^\d+\.\d+\.\d+$/.test(ver)) throw new Error("Stable v
if (!semver.valid(ver)) throw new Error(`Invalid semver: ${ver}`)
await $`git fetch origin main --tags`
if (!(await pinned())) {
throw new Error("packages/kilo-jetbrains/gradle.properties has kilo.cli.pinned=false; JetBrains releases require kilo.cli.pinned=true")
}
const tag = `jetbrains/v${ver}`
const branch = `jetbrains/release/v${ver}`
@@ -214,6 +217,13 @@ async function writeprops(ver: string) {
await Bun.write(props, next.endsWith("\n") ? next : `${next}\n`)
}
async function pinned() {
const text = await Bun.file(props).text()
const line = text.split(/\r?\n/).find((item) => item.startsWith("kilo.cli.pinned="))
const value = line?.split("=", 2)[1]?.trim().toLowerCase()
return value !== "false"
}
async function writelog(ver: string, entry: string) {
const current = await Bun.file(log)
.text()
+10
View File
@@ -73,6 +73,9 @@ if (sha !== commit) throw new Error(`${tag} points at ${sha}, expected ${commit}
const prop = await props()
if (prop !== ver)
throw new Error(`packages/kilo-jetbrains/gradle.properties kilo.jetbrains.version is ${prop}, expected ${ver}`)
if (!(await pinned())) {
throw new Error("packages/kilo-jetbrains/gradle.properties has kilo.cli.pinned=false; JetBrains releases require kilo.cli.pinned=true")
}
const changelog = await Bun.file("packages/kilo-jetbrains/CHANGELOG.md").text()
if (!changelog.includes(`## [${ver}]`)) throw new Error(`CHANGELOG.md is missing section for ${ver}`)
@@ -117,3 +120,10 @@ async function props() {
if (!value) throw new Error("packages/kilo-jetbrains/gradle.properties is missing kilo.jetbrains.version")
return value
}
async function pinned() {
const text = await Bun.file("packages/kilo-jetbrains/gradle.properties").text()
const line = text.split(/\r?\n/).find((item) => item.startsWith("kilo.cli.pinned="))
const value = line?.split("=", 2)[1]?.trim().toLowerCase()
return value !== "false"
}