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
|
package work.slhaf.partner.ctl.commands
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
import picocli.CommandLine
|
import picocli.CommandLine
|
||||||
import work.slhaf.partner.ctl.commands.init.buildFromSource
|
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.Choice
|
||||||
import work.slhaf.partner.ctl.ui.Prompt
|
import work.slhaf.partner.ctl.ui.Prompt
|
||||||
|
import work.slhaf.partner.ctl.ui.PromptCancelledException
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import java.nio.file.Paths
|
import java.nio.file.Paths
|
||||||
@@ -84,7 +91,65 @@ class InitCommand : Runnable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun configureGateway(prompt: Prompt) {
|
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) {
|
private fun configureModel(prompt: Prompt) {
|
||||||
@@ -98,4 +163,17 @@ class InitCommand : Runnable {
|
|||||||
private enum class InstallChoice {
|
private enum class InstallChoice {
|
||||||
BUILD_FROM_SOURCE
|
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