From 393dcff6dfb4aebb0acc3a63077b2e5587c69413 Mon Sep 17 00:00:00 2001 From: slhafzjw Date: Mon, 4 May 2026 17:48:58 +0800 Subject: [PATCH] feat(partnerctl-init): add interactive gateway configuration and gateway.json generation --- .../slhaf/partner/ctl/commands/InitCommand.kt | 80 +++++++++- .../partner/ctl/commands/init/configure.kt | 150 ++++++++++++++++++ 2 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/init/configure.kt 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 e3d8981f..f5d9e21f 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 @@ -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 + ) { + @Serializable + data class ChannelConfig( + val channelName: String, + val params: JsonObject + ) + } + } \ No newline at end of file diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/init/configure.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/init/configure.kt new file mode 100644 index 00000000..f7244934 --- /dev/null +++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/commands/init/configure.kt @@ -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) = 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" } + } +}