diff --git a/PartnerCtl/pom.xml b/PartnerCtl/pom.xml
index c45cb564..b1d905e4 100644
--- a/PartnerCtl/pom.xml
+++ b/PartnerCtl/pom.xml
@@ -10,7 +10,7 @@
partnerctl
- 1.0.0
+ 1.0.1
21
diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/InitCommand.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/InitCommand.kt
index 91969dd0..60b70bae 100644
--- a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/InitCommand.kt
+++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/InitCommand.kt
@@ -2,13 +2,12 @@ package work.slhaf.partner.ctl.commands
import kotlinx.serialization.json.*
import picocli.CommandLine
+import work.slhaf.partner.ctl.commands.InitCommand.InstallChoice.BUILD_FROM_SOURCE
+import work.slhaf.partner.ctl.commands.InitCommand.InstallChoice.DOWNLOAD_JAR
import work.slhaf.partner.ctl.commands.data.GatewayConfig
import work.slhaf.partner.ctl.commands.data.OpenAiCompatible
import work.slhaf.partner.ctl.commands.data.ProviderConfig
-import work.slhaf.partner.ctl.commands.init.buildFromSource
-import work.slhaf.partner.ctl.commands.init.configureExternalGateway
-import work.slhaf.partner.ctl.commands.init.configureOpenAiCompatible
-import work.slhaf.partner.ctl.commands.init.configureWebSocketGateway
+import work.slhaf.partner.ctl.commands.init.*
import work.slhaf.partner.ctl.i18n.I18n.text
import work.slhaf.partner.ctl.support.CommandInterrupted
import work.slhaf.partner.ctl.support.inheritCommand
@@ -176,11 +175,15 @@ class InitCommand : Runnable {
val installChoice = prompt.select(
label = text("init.install.method.label"),
- choices = listOf(Choice(text("init.install.method.buildFromSource"), InstallChoice.BUILD_FROM_SOURCE))
+ choices = listOf(
+ Choice(text("init.install.method.buildFromSource"), BUILD_FROM_SOURCE),
+ Choice(text("init.install.method.downloadFromRelease"), DOWNLOAD_JAR)
+ )
)
when (installChoice) {
- InstallChoice.BUILD_FROM_SOURCE -> buildFromSource(home, prompt)
+ BUILD_FROM_SOURCE -> buildFromSource(home, prompt)
+ DOWNLOAD_JAR -> downloadFromRelease(home, prompt)
}
}
@@ -348,7 +351,8 @@ class InitCommand : Runnable {
}
private enum class InstallChoice {
- BUILD_FROM_SOURCE
+ BUILD_FROM_SOURCE,
+ DOWNLOAD_JAR
}
private enum class ModelProviderChoice(val display: String) {
diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/init/install.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/init/install.kt
index cf799c91..a5654f69 100644
--- a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/init/install.kt
+++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/init/install.kt
@@ -1,11 +1,15 @@
package work.slhaf.partner.ctl.commands.init
+import work.slhaf.partner.ctl.i18n.I18n.text
import work.slhaf.partner.ctl.support.SourceBuildInstallSpec
import work.slhaf.partner.ctl.support.buildAndInstallFromSource
+import work.slhaf.partner.ctl.support.downloadTo
+import work.slhaf.partner.ctl.support.registryIndex
import work.slhaf.partner.ctl.ui.Prompt
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
+import kotlin.io.path.exists
import kotlin.io.path.isDirectory
import kotlin.io.path.name
@@ -40,3 +44,41 @@ private fun findLargestJar(directory: Path): Path? {
.orElse(null)
}
}
+
+fun downloadFromRelease(home: Path, prompt: Prompt) {
+ prompt.info(text("init.install.method.downloadFromRelease.startDownloading"))
+ val path = home.resolve("resources/partner-core.jar").toAbsolutePath().normalize()
+ downloadTo(registryIndex.partner.latestRelease.url, path) { downloaded, total ->
+ if (total != null && total > 0) {
+ val percent = downloaded * 100 / total
+ updateLine(
+ text(
+ "init.install.method.downloadFromRelease.progress.percent",
+ percent
+ )
+ )
+ } else {
+ updateLine(
+ text(
+ "init.install.method.downloadFromRelease.progress.size",
+ downloaded / 1024
+ )
+ )
+ }
+ }
+ finishLine(text("init.install.method.downloadFromRelease.done"))
+ if (!path.exists()) {
+ throw IllegalStateException("Unable to find downloaded partner release at $path")
+ }
+ prompt.success(text("init.install.method.downloadFromRelease.success"))
+}
+
+fun updateLine(text: String) {
+ print("\r\u001B[2K$text")
+ System.out.flush()
+}
+
+fun finishLine(text: String) {
+ updateLine(text)
+ println()
+}
\ No newline at end of file
diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/support/external_module.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/support/external_module.kt
index 4ebed90b..2e191d3a 100644
--- a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/support/external_module.kt
+++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/support/external_module.kt
@@ -6,7 +6,7 @@ import kotlinx.serialization.json.Json
private const val registryUrl = "https://raw.githubusercontent.com/slhaf/Partner/refs/heads/master/registry"
private const val indexUrl = "$registryUrl/index.json"
-private val registryIndex = run {
+val registryIndex = run {
Json.decodeFromString(fetchText(indexUrl))
}
diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/support/web_fetch.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/support/web_fetch.kt
index a482c6c2..96ae5a4b 100644
--- a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/support/web_fetch.kt
+++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/support/web_fetch.kt
@@ -5,11 +5,15 @@ import java.net.InetSocketAddress
import java.net.ProxySelector
import java.net.URI
import java.net.http.*
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.StandardCopyOption
import java.time.Duration
+import kotlin.io.path.isDirectory
private val httpClient: HttpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(20))
- .followRedirects(HttpClient.Redirect.NEVER)
+ .followRedirects(HttpClient.Redirect.NORMAL)
.apply {
proxySelectorFromEnv()?.let(::proxy)
}
@@ -72,3 +76,60 @@ fun fetchText(url: String): String {
throw IOException("Failed to fetch $url after retries", lastError)
}
+
+fun downloadTo(
+ url: String,
+ targetPath: Path,
+ onProgress: (downloaded: Long, total: Long?) -> Unit = { _, _ -> }
+) {
+ if (targetPath.isDirectory()) {
+ throw IllegalArgumentException("Target path must be a file")
+ }
+ val targetPath = targetPath.toAbsolutePath().normalize()
+ val targetFile = targetPath.toFile()
+ val temp = Files.createTempFile(
+ "${targetFile.name}-${System.currentTimeMillis()}", ".${targetFile.extension}.download"
+ )
+
+ try {
+ val request = HttpRequest.newBuilder()
+ .uri(URI.create(url))
+ .GET()
+ .build()
+
+ val response = httpClient.send(
+ request,
+ HttpResponse.BodyHandlers.ofInputStream()
+ )
+
+ if (response.statusCode() !in 200..299) {
+ throw IllegalStateException("Failed to download from $url: HTTP ${response.statusCode()}")
+ }
+
+ val totalBytes = response.headers()
+ .firstValue("Content-Length")
+ .orElse(null)
+ ?.toLongOrNull()
+
+ response.body().use { input ->
+ Files.newOutputStream(temp).use { output ->
+ val buffer = ByteArray(8192)
+ var downloaded = 0L
+
+ while (true) {
+ val read = input.read(buffer)
+ if (read < 0) break
+
+ output.write(buffer, 0, read)
+ downloaded += read
+ onProgress(downloaded, totalBytes)
+ }
+ }
+ }
+
+ Files.move(temp, targetPath, StandardCopyOption.REPLACE_EXISTING)
+ } catch (e: Exception) {
+ Files.deleteIfExists(temp)
+ throw e
+ }
+}
diff --git a/PartnerCtl/src/main/resources/i18n/messages.properties b/PartnerCtl/src/main/resources/i18n/messages.properties
index 0da3c321..d68204d0 100644
--- a/PartnerCtl/src/main/resources/i18n/messages.properties
+++ b/PartnerCtl/src/main/resources/i18n/messages.properties
@@ -31,6 +31,12 @@ init.home.overwrite.refuseBroadDirectory=Refuse to overwrite suspiciously broad
init.install.section=Install Partner
init.install.method.label=Choose an installation method
init.install.method.buildFromSource=Build Partner from source
+init.install.method.downloadFromRelease=Download Partner release
+init.install.method.downloadFromRelease.startDownloading=Downloading Partner release...
+init.install.method.downloadFromRelease.success=Partner release downloaded successfully.
+init.install.method.downloadFromRelease.progress.percent=Downloading Partner release... {0}%
+init.install.method.downloadFromRelease.progress.size=Downloading Partner release... {0} KB
+init.install.method.downloadFromRelease.done=Downloading Partner release... Done
init.gateway.section=Configure Gateway
init.gateway.select.label=Select gateway
init.gateway.websocket.choice=WebSocket Gateway
diff --git a/PartnerCtl/src/main/resources/i18n/messages_zh_CN.properties b/PartnerCtl/src/main/resources/i18n/messages_zh_CN.properties
index 4c98e739..246a9b0e 100644
--- a/PartnerCtl/src/main/resources/i18n/messages_zh_CN.properties
+++ b/PartnerCtl/src/main/resources/i18n/messages_zh_CN.properties
@@ -31,6 +31,12 @@ init.home.overwrite.refuseBroadDirectory=拒绝覆盖范围过大的目录:{0}
init.install.section=安装 Partner
init.install.method.label=选择安装方式
init.install.method.buildFromSource=从源码构建 Partner
+init.install.method.downloadFromRelease=下载 Partner 发布包
+init.install.method.downloadFromRelease.startDownloading=正在下载 Partner 发布包...
+init.install.method.downloadFromRelease.success=Partner 发布包下载完成。
+init.install.method.downloadFromRelease.progress.percent=正在下载 Partner 发布包... {0}%
+init.install.method.downloadFromRelease.progress.size=正在下载 Partner 发布包... {0} KB
+init.install.method.downloadFromRelease.done=正在下载 Partner 发布包... 完成
init.gateway.section=配置网关
init.gateway.select.label=选择网关
init.gateway.websocket.choice=WebSocket Gateway