Restructure, beginning of CLI client

This commit is contained in:
2026-08-04 19:11:32 +02:00
parent 2a1487037b
commit a10423b015
36 changed files with 695 additions and 27 deletions
+43
View File
@@ -0,0 +1,43 @@
plugins {
alias(libs.plugins.jvm)
application
alias(libs.plugins.serialization)
alias(libs.plugins.shadow)
}
group = "com.jaytux.phoebench"
version = rootProject.version.toString()
dependencies {
implementation(kotlin("stdlib"))
implementation(libs.clikt)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.auth)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization)
implementation(project(":common"))
implementation(kotlin("reflect"))
implementation(libs.ktor.client.okhttp)
implementation(libs.slf4j.simple)
implementation(libs.java.keystore)
implementation(libs.ktor.serialization.kotlinx.json)
}
application {
mainClass = "com.jaytux.phoebench.clients.cli.MainKt"
}
tasks.withType<Jar> {
manifest {
attributes["Main-Class"] = application.mainClass
}
}
kotlin {
jvmToolchain(21)
compilerOptions {
freeCompilerArgs.add("-Xcontext-parameters")
optIn.add("kotlin.uuid.ExperimentalUuidApi")
}
}
@@ -0,0 +1,91 @@
package com.jaytux.phoebench.clients.cli
import com.jaytux.phoebench.common.*
import kotlinx.coroutines.runBlocking
import kotlin.system.exitProcess
import kotlin.uuid.Uuid
object AuthHandlers {
fun serverPrompt(server: String?): Either<ErrorResponse, Unit> {
val useServer = server.maybePrompt("server") { it }
Client.onSelectServer(useServer)
return runBlocking {
Client.callRoute(Routes.handshake, EmptyRequest()).bind { handshake ->
if (handshake.version != ProtocolVersion.VERSION) {
Client.onClearServer()
ErrorResponse("Mismatched protocol version (server: ${handshake.version}, client: ${ProtocolVersion.VERSION})").error()
} else Unit.value()
}
}
}
fun loginPrompt(server: String?, user: String?, pass: String?): Either<ErrorResponse, Unit> {
return runBlocking {
serverPrompt(server).bind {
val useUser = user.maybePrompt("username") { it }
val usePassword = pass.maybePrompt("password", isPassword = true) { it }
Client.callRoute(Routes.Auth.login, LoginRequest(useUser, usePassword))
}.bind {
Client.onLogin(it)
Unit.value()
}
}
}
private fun fmtHome(it: HomeResponse) {
println("Logged in as ${it.username}${if (it.isAdmin) " (admin)" else ""}.")
println("Your projects (${it.ownProjects.size}/${if (it.projectLimit == -1) "∞" else it.projectLimit}):")
it.ownProjects.forEach { p ->
println(" [${p.id}] ${p.name} (${if (p.isPublic) "public" else "private"} project)")
}
if (it.publicProjects.isNotEmpty()) {
println("\nPublicly accessible projects:")
it.publicProjects.forEach { p ->
println(" [${p.id}] ${p.name} by ${p.owner.name}")
}
} else {
println("\nNo publicly accessible projects.")
}
}
fun tryLogin(server: String?, user: String?, pass: String?) {
runBlocking {
loginPrompt(server, user, pass).bind {
Client.callRoute(Routes.home, EmptyRequest())
}.fold({
System.err.println("Failed to log in: ${it.msg}")
exitProcess(-1)
}) {
fmtHome(it)
}
exitProcess(0)
}
}
fun tryRegister(server: String?, invite: Uuid?, user: String?, pass: String?) {
runBlocking {
serverPrompt(server).bind {
val useInvite = invite.maybePrompt("invite code") {
Uuid.parseOrNull(it) ?: run {
System.err.println("Invalid invite code format (should be UUID format).")
exitProcess(-1)
}
}
val useUser = user.maybePrompt("username") { it }
val usePassword = pass.maybePrompt("password", isPassword = true) { it }
Client.callRoute(Routes.Auth.signup, SignupRequest(useInvite, useUser, usePassword))
}.bind {
System.err.println("Account created. Loading home...")
Client.onLogin(it)
Client.callRoute(Routes.home, EmptyRequest())
}.fold({
System.err.println("Registration pipeline failed: ${it.msg}")
}) {
fmtHome(it)
}
}
}
}
@@ -0,0 +1,195 @@
package com.jaytux.phoebench.clients.cli
import com.github.ajalt.clikt.core.*
import com.github.ajalt.clikt.parameters.groups.mutuallyExclusiveOptions
import com.github.ajalt.clikt.parameters.groups.single
import com.github.ajalt.clikt.parameters.options.*
import com.github.ajalt.clikt.parameters.types.float
import com.github.ajalt.clikt.parameters.types.inputStream
import com.jaytux.phoebench.common.TimeUnit
import io.ktor.util.reflect.*
import io.ktor.utils.io.*
import java.io.InputStream
import kotlin.reflect.full.isSubclassOf
import kotlin.reflect.full.primaryConstructor
import kotlin.system.exitProcess
import kotlin.uuid.Uuid
object CLI {
object Root : CliktCommand("phoebench-cli") {
override val invokeWithoutSubcommand: Boolean = true
val batchMode by option("--batch-mode", "--script-mode",
help = "Disable all interactive input (makes arguments marked with (*) mandatory)"
).flag(default = false)
override fun run() {
val sub = currentContext.invokedSubcommand
if(sub == null) {
System.err.println(getFormattedHelp())
System.err.println("\nNo subcommand given.")
exitProcess(-1)
}
}
}
interface ICommandContainer {
fun nestedCommands(): Array<CliktCommand>
}
annotation class IgnoreNestedCommand
internal fun ICommandContainer.fromReflection(): Array<CliktCommand> {
val nested = this::class.nestedClasses.filter {
it.isSubclassOf(CliktCommand::class) && it.annotations.none { a -> a.instanceOf(IgnoreNestedCommand::class) }
}
return nested.mapNotNull {
val ctor = it.primaryConstructor
if(ctor == null) {
System.err.println("Nested command ${it.simpleName} (for ${this::class.simpleName}) has no primary constructor.")
null
}
else if(ctor.parameters.isNotEmpty()) {
System.err.println("Primary constructor of nested command ${it.simpleName} (for ${this::class.simpleName}) requires arguments.")
null
}
else {
ctor.call() as CliktCommand
}
}.toTypedArray()
}
internal fun ICommandContainer.buildSubcommands(): Array<CliktCommand> {
val nested = nestedCommands()
nested.forEach {
if(it is ICommandContainer) {
it.subcommands(*it.buildSubcommands())
}
}
return nested
}
object Commands : ICommandContainer {
override fun nestedCommands(): Array<CliktCommand> = fromReflection()
@Suppress("unused")
class Login : CliktCommand(name = "login") {
val server by option("--server", help = "The server to connect to (*)")
val user by option("--user", help = "The username to log in with (*)")
val pass by option("--pass", help = "The password to log in with (*)")
override fun run() = AuthHandlers.tryLogin(server, user, pass)
}
@Suppress("unused")
class Register : CliktCommand(name = "register"){
val server by option("--server", help = "The server to connect to (*)")
val invite by option("--invite", help = "The invite code to register with (*)").convert { Uuid.parse(it) }
val user by option("--user", help = "The username for the new account (*)")
val pass by option("--pass", help = "The password for the new account (*)")
override fun run() = AuthHandlers.tryRegister(server, invite, user, pass)
}
class Project : CliktCommand(name = "project"), ICommandContainer {
override fun nestedCommands(): Array<CliktCommand> = fromReflection()
sealed interface IProjectIdentification
sealed interface ILabelIdentification
data class ProjectName(val user: String, val project: String) : IProjectIdentification
data class LabelName(val name: String): ILabelIdentification
data class ID(val id: Uuid) : IProjectIdentification, ILabelIdentification
sealed interface IData<T>
data class DirectData<T>(val data: List<T>) : IData<T>
data class FileData<T>(val file: InputStream, val parse: (String) -> T) : IData<T> {
companion object {
fun floatFile(file: InputStream) = FileData<Float>(file) { it.toFloat() }
}
}
val finder by mutuallyExclusiveOptions<IProjectIdentification>(
option("--id", help = "Find a project by UUID.").convert { ID(Uuid.parse(it)) },
option("--name", help = "Find a project by name (formatted [user]/[project])").convert {
val split = it.split('/')
if(split.size != 2) throw IllegalArgumentException("Invalid format (expected [user]/[project])")
ProjectName(split[0], split[1])
}
).single()
val project by findOrSetObject { this }
override fun run() {}
@Suppress("unused")
class ProjectList : CliktCommand(name = "list") {
override fun run() = ProjectHandlers.list()
}
@Suppress("unused")
class Details : CliktCommand(name = "details") {
val parent by requireObject<Project>()
override fun run() = ProjectHandlers.details(parent.finder)
}
@Suppress("unused")
class Create : CliktCommand(name = "create") {
val name by option("--name", help = "Set the project's name (*)")
val isPublic by option("--public", help = "Make the project publicly visible").flag(default = false)
override fun run() = ProjectHandlers.create(name, isPublic)
}
@Suppress("unused")
class AddLabel : CliktCommand(name = "add-label") {
val parent by requireObject<Project>()
val name by option("--name", help = "Set the label's name (*)")
val color by option("--color", help = "Set the label's color (*)").check("Color must be specified in RGB-hex-format (#ABCDEF)") {
it.length == 7 && it[0] == '#' && it.substring(1, it.length).all { c -> c.isDigit() || c in "ABCDEF" }
}
override fun run() = ProjectHandlers.newLabel(name, color, parent.finder)
}
@Suppress("unused")
class AddData : CliktCommand(name = "add-data") {
val parent by requireObject<Project>()
val label by mutuallyExclusiveOptions<ILabelIdentification>(
option("--label-id", help = "Set the label by UUID.").convert { ID(Uuid.parse(it)) },
option("--label", help = "Set the label by name.").convert { LabelName(it) }
).single()
val warmup by mutuallyExclusiveOptions<IData<Float>>(
option("--warmup", help = "Set warmup data directly.").float().split(",").transformAll { DirectData(it.flatten()) },
option("--warmup-file", help = "Read warmup data from file.").inputStream().convert { FileData.floatFile(it) }
).single()
val measurement by mutuallyExclusiveOptions<IData<Float>>(
option("--measure", help = "Set measurement data directly.").float().split(",").transformAll { DirectData(it.flatten()) },
option("--measure-file", help = "Read measurement data from file.").inputStream().convert { FileData.floatFile(it) }
).single()
val unit by mutuallyExclusiveOptions<TimeUnit>(
option("--ns", "--nano", "--nanosec", help = "Set the time unit to nanoseconds").flag().convert { TimeUnit.NANOS },
option("--us", "--micro", "--μs", "--microsec", help = "Set the time unit to microseconds").flag().convert { TimeUnit.MICROS },
option("--ms", "--milli", "--millis", "--millisec", help = "Set the time unit to milliseconds").flag().convert { TimeUnit.MILLIS },
option("--s", "--sec", "--second", help = "Set the time unit to seconds").flag().convert{ TimeUnit.SECONDS },
option("--min", "--m", "--minutes", help = "Set the time unit to minutes").flag().convert { TimeUnit.MINUTES },
option("--h", "--hour", help = "Set the time unit to hours").flag().convert { TimeUnit.HOURS }
).single()
override fun run() = ProjectHandlers.newData(parent.finder, label, warmup, measurement, unit)
}
}
}
fun main(args: Array<String>) {
try {
Root.subcommands(*Commands.buildSubcommands()).main(args)
}
catch(e: CancellationException) {}
catch(e: PromptException) {
System.err.println(e.message)
exitProcess(-1)
}
}
}
@@ -0,0 +1,125 @@
package com.jaytux.phoebench.clients.cli
import com.jaytux.phoebench.common.ApiRoute
import com.jaytux.phoebench.common.Either
import com.jaytux.phoebench.common.ErrorResponse
import com.jaytux.phoebench.common.IClient
import com.jaytux.phoebench.common.RefreshRequest
import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.TokenResponse
import com.jaytux.phoebench.common.error
import com.jaytux.phoebench.common.foldSuspend
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import io.ktor.utils.io.CancellationException
import kotlinx.coroutines.asExecutor
import kotlin.uuid.Uuid
object Client {
private var _refAcc = PersistentStorage.refreshToken()
private var _serverAcc = PersistentStorage.server()
private var _server: String? = _serverAcc.load()
private var _refreshToken: Uuid? = _server?.let { _refAcc.load() }
private var _accessToken: String? = null
private val _bearer: BearerTokens?
get() = _accessToken?.let { acc ->
_refreshToken?.let { ref ->
BearerTokens(
accessToken = acc,
refreshToken = ref.toString()
)
}
}
private val _authClient = HttpClient(OkHttp) {
install(ContentNegotiation) { json() }
}
private val _client = HttpClient(OkHttp) {
install(ContentNegotiation) { json() }
install(Auth) {
bearer {
loadTokens {
val res = _bearer
res
}
refreshTokens {
val ref = _refreshToken ?: return@refreshTokens null
val res = callRoute(_authClient, Routes.Auth.refresh, RefreshRequest(ref), true).foldSuspend({
System.err.println("ERROR while re-authenticating: ${it.msg}")
onLogout()
null
}) {
onLogin(it)
val res = _bearer
res
}
res
}
}
}
}
init {
ApiRoute.disablePrinting()
}
private suspend fun <TReq : Any, TRes : Any> callRoute(using: HttpClient, route: ApiRoute<TReq, TRes>, body: TReq, wasInternal: Boolean = false): Either<ErrorResponse, TRes> {
return try {
val client = IClient.Default(using, _server ?: throw IllegalStateException("No server URL set."))
val res = route.call(client, body)
res
} catch (e: CancellationException) {
ErrorResponse("Coroutine calling ${route.verb} ${route.pattern} was cancelled.").error()
} catch (e: Exception) {
// println("Call to ${route.pattern} ran into an exception")
ErrorResponse(e.message ?: "Unknown error.").error()
}
}
suspend fun <TReq: Any, TRes: Any> callRoute(route: ApiRoute<TReq, TRes>, body: TReq): Either<ErrorResponse, TRes> =
callRoute(_client, route, body)
fun onLogin(tokens: TokenResponse) {
_refreshToken = tokens.refresh
_accessToken = tokens.access
_refAcc.save(tokens.refresh)
}
fun onSelectServer(server: String) {
_server = server
_serverAcc.save(server)
}
fun onLogout() {
_refAcc.erase()
_refreshToken = null
_accessToken = null
}
fun onClearServer() {
_server = null
_serverAcc.erase()
}
fun isAuthenticated() = _bearer != null
fun getServer() = _server
fun forceGloballyInitialized() {}
fun shutdown() {
_authClient.close()
_client.close()
}
}
@@ -0,0 +1,7 @@
package com.jaytux.phoebench.clients.cli
fun main(args: Array<String>) {
Client.forceGloballyInitialized()
CLI.main(args)
Client.shutdown()
}
@@ -0,0 +1,44 @@
package com.jaytux.phoebench.clients.cli
import com.github.javakeyring.Keyring
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer
import kotlin.uuid.Uuid
object PersistentStorage {
private val json = Json
const val SERVICE = "com.jaytux.phoebench.cli"
class StoredProperty<T>(
private val _key: String,
private val _toString: (T) -> String, private val _fromString: (String) -> T
) {
constructor(key: String, serializer: KSerializer<T>) : this(key,
{ json.encodeToString(serializer, it) },
{ json.decodeFromString(serializer, it) }
)
fun load(): T? = runCatching {
Keyring.create().use {
it.getPassword(SERVICE, _key)
}
}.getOrNull()?.let { _fromString(it) }
fun save(value: T) = runCatching {
val keyring = Keyring.create().use {
it.setPassword(SERVICE, _key, _toString(value))
}
}.onFailure { println("Failed to write to OS keyring: ${it.message}") }.ignore()
fun erase() = runCatching {
val keyring = Keyring.create().use {
it.deletePassword(SERVICE, _key)
}
}.ignore()
}
fun refreshToken() = StoredProperty("refresh_token", serializer<Uuid>())
fun server() = StoredProperty("server_url", {it}, {it})
}
@@ -0,0 +1,37 @@
package com.jaytux.phoebench.clients.cli
import com.jaytux.phoebench.common.EmptyRequest
import com.jaytux.phoebench.common.Routes
import com.jaytux.phoebench.common.TimeUnit
import com.jaytux.phoebench.common.fold
object ProjectHandlers {
fun list() {
tryAuthenticated {
Client.callRoute(Routes.home, EmptyRequest())
}.fold({ err ->
System.err.println("Could not get projects list: ${err.msg}")
}) {
System.err.println("Logged in as ${it.username}")
(it.ownProjects + it.publicProjects).toSet().sortedBy { p -> p.name }.forEach { project ->
println("[${project.id}] ${project.owner.name}/${project.name} (${if(project.isPublic) "public" else "private"})")
}
}
}
fun details(find: CLI.Commands.Project.IProjectIdentification?) {
//
}
fun create(name: String?, isPublic: Boolean) {}
fun newLabel(name: String?, color: String?, project: CLI.Commands.Project.IProjectIdentification?) {}
fun newData(
project: CLI.Commands.Project.IProjectIdentification?,
label: CLI.Commands.Project.ILabelIdentification?,
warmup: CLI.Commands.Project.IData<Float>?,
measurement: CLI.Commands.Project.IData<Float>?,
unit: TimeUnit?
) {}
}
@@ -0,0 +1,41 @@
package com.jaytux.phoebench.clients.cli
import com.jaytux.phoebench.clients.cli.CLI.Root
import com.jaytux.phoebench.common.*
import kotlinx.coroutines.runBlocking
import java.util.Locale.getDefault
fun <T> T.ignore(): Unit {}
class PromptException(val what: String) : Exception("Missing $what (perhaps you forgot to disable batch-mode?)")
fun <T> T?.maybePrompt(what: String, isPassword: Boolean = false, converter: (String) -> T): T {
if(this != null) return this
if(Root.batchMode) throw PromptException(what)
print("${what.replaceFirstChar { if (it.isLowerCase()) it.titlecase(getDefault()) else it.toString() }}: ")
val got = if(isPassword) {
String(System.console().readPassword())
}
else readln()
return converter(got)
}
inline fun <reified V> tryAuthenticated(crossinline body: suspend () -> Either<ErrorResponse, V>) = runBlocking {
body().fold({
if(!Client.isAuthenticated()) {
System.err.println("Authentication failed. Please log in${Client.getServer()?.let { s -> " to $s" } ?: ""} again.")
if(!Root.batchMode) {
AuthHandlers.loginPrompt(Client.getServer(), null, null).fold({ err ->
err.error()
}) {
body()
}
}
else it.error()
} else it.error()
}) {
it.value()
}
}
@@ -0,0 +1 @@
org.slf4j.simpleLogger.defaultLogLevel = off