mirror of
https://github.com/slhaf/Partner.git
synced 2026-05-12 08:43:02 +08:00
feat(partnerctl-init): add interactive gateway configuration and gateway.json generation
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user