feat(partnerctl-init): add interactive gateway configuration and gateway.json generation

This commit is contained in:
2026-05-04 17:48:58 +08:00
parent 0fdc0038a5
commit 393dcff6df
2 changed files with 229 additions and 1 deletions

View File

@@ -1,9 +1,16 @@
package work.slhaf.partner.ctl.commands
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import picocli.CommandLine
import work.slhaf.partner.ctl.commands.init.buildFromSource
import work.slhaf.partner.ctl.commands.init.configureExternalGateway
import work.slhaf.partner.ctl.commands.init.configureWebSocketGateway
import work.slhaf.partner.ctl.support.loadAvailableGateway
import work.slhaf.partner.ctl.ui.Choice
import work.slhaf.partner.ctl.ui.Prompt
import work.slhaf.partner.ctl.ui.PromptCancelledException
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
@@ -84,7 +91,65 @@ class InitCommand : Runnable {
}
private fun configureGateway(prompt: Prompt) {
TODO("Not yet implemented")
prompt.section("Configure Gateway")
val providedGateways = loadAvailableGateway()
val selectedGateways = prompt.multiSelect(
label = "Select gateway",
choices = listOf(Choice("WebSocket Gateway", "websocket_channel")) +
providedGateways.map {
Choice(it.name, it.id)
}
)
val configuredChannels = selectedGateways.map { gateway ->
try {
if (gateway == "websocket_channel") {
return@map configureWebSocketGateway(prompt)
} else {
val manifest = providedGateways.find { it.id == gateway }
if (manifest != null) {
return@map configureExternalGateway(home, prompt, manifest)
} else {
prompt.warn("Could not find gateway with id $gateway")
return@map null
}
}
} catch (_: PromptCancelledException) {
prompt.warn("Gateway: $gateway configuration skipped")
return@map null
}
}.filterNotNull()
val defaultChannel = if (configuredChannels.isEmpty()) {
prompt.info("Skipped gateway configuration. Partner will use WebSocket as default gateway")
return
} else if (configuredChannels.size == 1) {
configuredChannels.first().channelName
} else {
prompt.select(
label = "Set default channel",
choices = configuredChannels.map { Choice(it.channelName, it.channelName) }
)
}
prompt.info("The default channel will be set to $defaultChannel")
val gatewayConfig = GatewayConfig(
defaultChannel = defaultChannel,
channels = configuredChannels
)
val json = Json {
prettyPrint = true
encodeDefaults = true
}
val gatewayStr = json.encodeToString(gatewayConfig)
val gatewayPath = home.resolve("config").resolve("gateway.json").toAbsolutePath().normalize()
Files.writeString(gatewayPath, gatewayStr)
prompt.success("Gateway config written to $gatewayPath")
}
private fun configureModel(prompt: Prompt) {
@@ -98,4 +163,17 @@ class InitCommand : Runnable {
private enum class InstallChoice {
BUILD_FROM_SOURCE
}
@Serializable
data class GatewayConfig(
val defaultChannel: String,
val channels: List<ChannelConfig>
) {
@Serializable
data class ChannelConfig(
val channelName: String,
val params: JsonObject
)
}
}

View File

@@ -0,0 +1,150 @@
package work.slhaf.partner.ctl.commands.init
import kotlinx.serialization.json.*
import work.slhaf.partner.ctl.commands.InitCommand.GatewayConfig
import work.slhaf.partner.ctl.support.*
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.isDirectory
fun configureWebSocketGateway(prompt: Prompt): GatewayConfig.ChannelConfig {
prompt.section("Configure Gateway: WebSocket Gateway")
val port = prompt.ask("port", "29600") {
val intValue = it.toIntOrNull() ?: return@ask "WebSocket port only accepts int value"
if (intValue !in 1..65565) "WebSocket port should be between 1 and 65565" else null
}.toInt()
val heartbeatInterval = prompt.ask("heartbeat interval", "10_000L") {
it.toLongOrNull() ?: return@ask "Heartbeat interval only accepts long value"
return@ask null
}.toLong()
val hostname = prompt.ask("Listening hostname", "127.0.0.1") {
val host = it.trim()
return@ask when {
host.isEmpty() -> "Hostname is required"
host.contains(Regex("\\s")) -> "Hostname must not contain whitespace."
host.contains("://") -> "Do not include protocol. Use hostname only, for example: 127.0.0.1"
host.contains("/") -> "Do not include path. Use hostname only."
looksLikeHostWithPort(host) -> "Do not include port here. Port is configured separately."
else -> null
}
}
return GatewayConfig.ChannelConfig(
"websocket_channel",
buildJsonObject {
put("port", port)
put("heartbeat_interval", heartbeatInterval)
put("hostname", hostname)
}
)
}
private fun looksLikeHostWithPort(value: String): Boolean {
// IPv6 contains ':', so only treat single-colon host:port as invalid.
val colonCount = value.count { it == ':' }
return colonCount == 1 && value.substringAfter(':').all { it.isDigit() }
}
fun configureExternalGateway(home: Path, prompt: Prompt, manifest: ModuleManifest): GatewayConfig.ChannelConfig? {
prompt.section("Configure Gateway: ${manifest.name}")
prompt.details(
title = "Gateway module details",
items = listOf(
"Description" to manifest.description,
"Source" to manifest.source.url,
"Build command" to manifest.source.buildCommand.joinToString(" "),
"Artifact" to "${manifest.source.artifactDirectory}/${manifest.source.artifactPattern}",
"Install target" to manifest.install.target,
"Config target" to (manifest.config?.target ?: "No config"),
),
)
if (!prompt.confirm("Continue installation?", true)) {
return null
}
buildAndInstallFromSource(
home,
prompt,
SourceBuildInstallSpec(
displayName = manifest.name,
repoUrl = manifest.source.url,
sourceDirName = manifest.source.sourceDirName,
buildCommand = manifest.source.buildCommand,
artifactDirectory = Paths.get(manifest.source.artifactDirectory),
artifactSelector = { artifactSelector(it, manifest.source.artifactPattern) },
installRelativePath = Paths.get(manifest.install.target)
)
)
val params = configureFields(prompt, manifest.config?.fields.orEmpty())
return GatewayConfig.ChannelConfig(
channelName = manifest.id,
params = params,
)
}
private fun artifactSelector(path: Path, pattern: String): Path? {
if (!path.isDirectory()) return null
return Files.newDirectoryStream(path, pattern).use { stream ->
stream
.asSequence()
.filter { Files.isRegularFile(it) }
.maxByOrNull { Files.size(it) }
}
}
private fun configureFields(prompt: Prompt, fields: List<Field>) = buildJsonObject {
fields.forEach { field ->
val value = askField(prompt, field) ?: return@forEach
put(field.name, value)
}
}
private fun askField(prompt: Prompt, field: Field): JsonElement? {
val rawValue = when (field.type) {
FieldType.BOOLEAN -> prompt.confirm(
label = field.label,
defaultValue = field.default?.toBooleanStrictOrNull() ?: true,
).toString()
else -> prompt.ask(
label = field.label,
defaultValue = field.default,
required = field.required,
) { value ->
validateFieldValue(field, value)
}
}
if (rawValue.isBlank() && !field.required) return null
return when (field.type) {
FieldType.STRING -> JsonPrimitive(rawValue)
FieldType.INT -> JsonPrimitive(rawValue.toInt())
FieldType.NUMBER -> JsonPrimitive(rawValue.toDouble())
FieldType.BOOLEAN -> JsonPrimitive(rawValue.toBooleanStrict())
FieldType.RAW_JSON -> Json.parseToJsonElement(rawValue)
}
}
@Suppress("KotlinConstantConditions")
private fun validateFieldValue(field: Field, value: String): String? {
if (value.isBlank() && !field.required) return null
return when (field.type) {
FieldType.STRING -> null
FieldType.INT -> value.toIntOrNull()?.let { null } ?: "${field.label} only accepts int value"
FieldType.NUMBER -> value.toDoubleOrNull()?.let { null } ?: "${field.label} only accepts number value"
FieldType.BOOLEAN -> value.toBooleanStrictOrNull()?.let { null } ?: "${field.label} only accepts true or false"
FieldType.RAW_JSON -> runCatching { Json.parseToJsonElement(value) }
.exceptionOrNull()
?.let { "${field.label} only accepts valid JSON" }
}
}