From 1451750bcb170f2854cd7b898a4da4923589ba00 Mon Sep 17 00:00:00 2001 From: Chaoscaot Date: Sat, 9 May 2026 16:46:59 +0200 Subject: [PATCH] Add SteamWar CLI module - add Clikt-based `sw` entrypoint and subcommands - include database, user, dev, and profiler commands - wire CLI build and CI install/release steps --- CLI/build.gradle.kts | 32 ++++ CLI/src/Main.kt | 22 +++ CLI/src/commands/SteamWar.kt | 10 ++ CLI/src/commands/database/DatabaseCommand.kt | 22 +++ CLI/src/commands/database/InfoCommand.kt | 25 +++ CLI/src/commands/database/ResetCommand.kt | 33 ++++ CLI/src/commands/dev/DevCommand.kt | 179 +++++++++++++++++++ CLI/src/commands/profiler/ProfilerCommand.kt | 42 +++++ CLI/src/commands/user/UserCommand.kt | 9 + CLI/src/commands/user/UserInfoCommand.kt | 65 +++++++ CLI/src/commands/user/UserSearchCommand.kt | 42 +++++ CLI/src/db/Database.kt | 84 +++++++++ CLI/src/logback.xml | 11 ++ settings.gradle.kts | 2 + steamwarci.yml | 3 + 15 files changed, 581 insertions(+) create mode 100644 CLI/build.gradle.kts create mode 100644 CLI/src/Main.kt create mode 100644 CLI/src/commands/SteamWar.kt create mode 100644 CLI/src/commands/database/DatabaseCommand.kt create mode 100644 CLI/src/commands/database/InfoCommand.kt create mode 100644 CLI/src/commands/database/ResetCommand.kt create mode 100644 CLI/src/commands/dev/DevCommand.kt create mode 100644 CLI/src/commands/profiler/ProfilerCommand.kt create mode 100644 CLI/src/commands/user/UserCommand.kt create mode 100644 CLI/src/commands/user/UserInfoCommand.kt create mode 100644 CLI/src/commands/user/UserSearchCommand.kt create mode 100644 CLI/src/db/Database.kt create mode 100644 CLI/src/logback.xml diff --git a/CLI/build.gradle.kts b/CLI/build.gradle.kts new file mode 100644 index 00000000..a7ace56c --- /dev/null +++ b/CLI/build.gradle.kts @@ -0,0 +1,32 @@ +plugins { + steamwar.kotlin + application +} + +kotlin { + jvmToolchain(21) +} + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +application { + mainClass.set("de.steamwar.MainKt") + applicationName = "sw" +} + +dependencies { + implementation(project(":CommonCore:SQL")) + + implementation("com.github.ajalt.clikt:clikt:5.0.3") + implementation("com.github.ajalt.mordant:mordant:3.0.2") + implementation(libs.logback) + implementation("org.mariadb.jdbc:mariadb-java-client:3.3.1") + + implementation(libs.exposedCore) + implementation(libs.exposedDao) + implementation(libs.exposedJdbc) + implementation(libs.exposedTime) +} diff --git a/CLI/src/Main.kt b/CLI/src/Main.kt new file mode 100644 index 00000000..fd4a9eb6 --- /dev/null +++ b/CLI/src/Main.kt @@ -0,0 +1,22 @@ +package de.steamwar + +import com.github.ajalt.clikt.core.main +import com.github.ajalt.clikt.core.subcommands +import de.steamwar.commands.SteamWar +import de.steamwar.commands.database.DatabaseCommand +import de.steamwar.commands.database.InfoCommand +import de.steamwar.commands.database.ResetCommand +import de.steamwar.commands.dev.DevCommand +import de.steamwar.commands.profiler.ProfilerCommand +import de.steamwar.commands.user.UserCommand +import de.steamwar.commands.user.UserInfoCommand +import de.steamwar.commands.user.UserSearchCommand + +fun main(args: Array) = SteamWar() + .subcommands( + DatabaseCommand().subcommands(InfoCommand(), ResetCommand()), + UserCommand().subcommands(UserInfoCommand(), UserSearchCommand()), + DevCommand(), + ProfilerCommand() + ) + .main(args) \ No newline at end of file diff --git a/CLI/src/commands/SteamWar.kt b/CLI/src/commands/SteamWar.kt new file mode 100644 index 00000000..4335c141 --- /dev/null +++ b/CLI/src/commands/SteamWar.kt @@ -0,0 +1,10 @@ +package de.steamwar.commands + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.mordant.rendering.TextStyles + +class SteamWar: CliktCommand(name = "sw") { + override fun run() { + echo(TextStyles.bold("SteamWar-CLI")) + } +} \ No newline at end of file diff --git a/CLI/src/commands/database/DatabaseCommand.kt b/CLI/src/commands/database/DatabaseCommand.kt new file mode 100644 index 00000000..d00068f3 --- /dev/null +++ b/CLI/src/commands/database/DatabaseCommand.kt @@ -0,0 +1,22 @@ +package de.steamwar.commands.database + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.CliktError +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.core.findOrSetObject +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.option +import de.steamwar.db.Database + +class DatabaseCommand: CliktCommand(name = "db") { + val useProduction by option().flag() + val db by findOrSetObject { Database } + + override fun help(context: Context): String = "Run database commands" + + override fun run() { + if (!useProduction && db.database == "production") { + throw CliktError("You should not use the production database!") + } + } +} diff --git a/CLI/src/commands/database/InfoCommand.kt b/CLI/src/commands/database/InfoCommand.kt new file mode 100644 index 00000000..f14d29a8 --- /dev/null +++ b/CLI/src/commands/database/InfoCommand.kt @@ -0,0 +1,25 @@ +package de.steamwar.commands.database + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.requireObject +import com.github.ajalt.mordant.table.table +import de.steamwar.db.Database +import de.steamwar.db.execute +import de.steamwar.db.useDb + +class InfoCommand: CliktCommand() { + val db by requireObject() + + override fun run() = useDb { + val tables = execute("SHOW TABLES") { it.getString(1) } + + echo( + table { + header { row("Name") } + body { + tables.map { row(it) } + } + } + ) + } +} \ No newline at end of file diff --git a/CLI/src/commands/database/ResetCommand.kt b/CLI/src/commands/database/ResetCommand.kt new file mode 100644 index 00000000..b78e3e1a --- /dev/null +++ b/CLI/src/commands/database/ResetCommand.kt @@ -0,0 +1,33 @@ +package de.steamwar.commands.database + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.CliktError +import com.github.ajalt.clikt.core.requireObject +import com.github.ajalt.mordant.rendering.TextColors +import com.github.ajalt.mordant.rendering.TextStyles +import de.steamwar.db.Database +import de.steamwar.db.execute +import de.steamwar.db.useDb +import java.io.File + +class ResetCommand: CliktCommand() { + val db by requireObject() + + override fun run() = useDb { + val schemaFile = File("/var/Schema.sql") + if (!schemaFile.exists()) { + throw CliktError("Schema file not found!") + } + + val schema = schemaFile.readText() + + val tables = execute("SHOW TABLES;") { it.getString(1) } + for (table in tables) { + execute("DROP TABLE IF EXISTS $table;") { } + } + + execute(schema) { } + + echo(TextColors.brightGreen(TextStyles.bold("Database reset!"))) + } +} \ No newline at end of file diff --git a/CLI/src/commands/dev/DevCommand.kt b/CLI/src/commands/dev/DevCommand.kt new file mode 100644 index 00000000..43099a3e --- /dev/null +++ b/CLI/src/commands/dev/DevCommand.kt @@ -0,0 +1,179 @@ +package de.steamwar.commands.dev + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.CliktError +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.arguments.help +import com.github.ajalt.clikt.parameters.arguments.multiple +import com.github.ajalt.clikt.parameters.options.default +import com.github.ajalt.clikt.parameters.options.defaultLazy +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.help +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.types.file +import com.github.ajalt.clikt.parameters.types.long +import com.github.ajalt.clikt.parameters.types.path +import com.sun.security.auth.module.UnixSystem +import java.io.File +import kotlin.io.path.absolute +import kotlin.io.path.absolutePathString + +const val LOG4J_CONFIG = """ + + + + + + + + + + + + + + + + + + + + + + + + +""" + +class DevCommand : CliktCommand("dev") { + override fun help(context: Context): String = "Start a dev Server" + + override val treatUnknownOptionsAsArgs = true + + val server by argument().help("Server Template") + val port by option("--port").long().defaultLazy { UnixSystem().uid + 1010 }.help("Port for Server") + val world by option("--world", "-w").path(canBeFile = false).help("User World") + val plugins by option("--plugins", "-p").path(true, canBeFile = false).help("Plugin Dir") + val profile by option().flag().help("Add Profiling Arguments") + val forceUpgrade by option().flag().help("Force Upgrade") + val jar by option().file(true, canBeDir = false).help("Jar File") + val jvm by option().file(true, canBeDir = false).help("Java Executable") + val jvmArgs by argument().multiple() + + override val printHelpOnEmptyArgs = true + + val workingDir = File("").absoluteFile + val log4jConfig = File(workingDir, "log4j2.xml") + + override fun run() { + val args = mutableListOf() + + val serverDirectory = File(workingDir, server) + val serverDir = + if (serverDirectory.exists() && serverDirectory.isDirectory) serverDirectory else File(workingDir, server) + + if (isVelocity(server)) { + runServer(args, jvmArgs, listOf(jar?.absolutePath ?: File("/jar/Velocity.jar").absolutePath), serverDir) + } else { + setLogConfig(args) + val version = findVersion(server) ?: throw CliktError("Unknown Server Version") + val worldFile = world?.absolute()?.toFile() ?: File(serverDir, "devtempworld") + val jarFile = jar?.absolutePath ?: additionalVersions[server]?.let { supportedVersionJars[it] } ?: supportedVersionJars[version] + ?: throw CliktError("Unknown Server Version") + + if (!worldFile.exists()) { + val templateFile = File(serverDir, "Bauwelt") + if (!templateFile.exists()) { + throw CliktError("World Template not found!") + } + templateFile.copyRecursively(worldFile) + } + + val devFile = File("/configs/DevServer/${System.getProperty("user.name")}.$port.$version") + if (System.getProperty("user.name") != "minecraft") { + devFile.createNewFile() + } + + runServer( + args, jvmArgs, listOf( + jarFile, + *(if (forceUpgrade) arrayOf("-forceUpgrade") else arrayOf()), + "--port", port.toString(), + "--level-name", worldFile.name, + "--world-dir", workingDir.absolutePath, + "--nogui", + *(if (plugins != null) arrayOf("--plugins", plugins!!.absolutePathString()) else arrayOf()) + ), serverDir + ) + + try { + devFile.delete() + } catch (_: Exception) { /* ignored */ } + } + } + + val jvmDefaultParams = arrayOf( + "-Xmx1G", + "-Xgc:excessiveGCratio=80", + "-Xsyslog:none", + "-Xtrace:none", + "-Xnoclassgc", + "-Xdisableexplicitgc", + "-XX:+AlwaysPreTouch", + "-XX:+CompactStrings", + "-XX:-HeapDumpOnOutOfMemory", + "-XX:+ExitOnOutOfMemoryError" + ) + + val jvmArgOverrides = arrayOf("--add-opens", "java.base/jdk.internal.misc=ALL-UNNAMED") + + val supportedVersionJars = mapOf( + 8 to "/jars/paper-1.8.8.jar", + 9 to "/jars/spigot-1.9.4.jar", + 10 to "/jars/paper-1.10.2.jar", + 12 to "/jars/spigot-1.12.2.jar", + 14 to "/jars/spigot-1.14.4.jar", + 15 to "/jars/spigot-1.15.2.jar", + 18 to "/jars/paper-1.18.2.jar", + 19 to "/jars/paper-1.19.3.jar", + 20 to "/jars/paper-1.20.1.jar", + 21 to "/jars/paper-1.21.6.jar" + ) + + val additionalVersions = mapOf( + "Tutorial" to 15, + "Lobby" to 20 + ) + + fun findVersion(server: String): Int? = server.dropWhile { !it.isDigit() }.toIntOrNull() + + fun isJava8(server: String): Boolean = findVersion(server)?.let { it <= 10 } ?: false + + fun isVelocity(server: String): Boolean = server.endsWith("Velocity") + + fun setLogConfig(args: MutableList) { + args += "-DlogPath=${workingDir.absolutePath}/logs" + args += "-Dlog4j.configurationFile=${log4jConfig.absolutePath}" + + if (!log4jConfig.exists()) { + log4jConfig.writeText(LOG4J_CONFIG) + } + } + + fun runServer(args: List, jvmArgs: List, cmd: List, serverDir: File) { + val process = ProcessBuilder( + jvm?.absolutePath ?: if (isJava8(server)) "/usr/lib/jvm/openj9-8/bin/java" else "java", + *jvmArgs.toTypedArray(), + *args.toTypedArray(), + *jvmDefaultParams, + *(if (isJava8(server)) arrayOf() else jvmArgOverrides), + *(if (profile) arrayOf("-javaagent:/jars/LixfelsProfiler.jar=start") else arrayOf()), + "-Xshareclasses:nonfatal,name=$server", + "-jar", + *cmd.toTypedArray() + ).directory(serverDir).inheritIO().start() + Runtime.getRuntime().addShutdownHook(Thread { if (process.isAlive) process.destroyForcibly() }) + process.waitFor() + } +} \ No newline at end of file diff --git a/CLI/src/commands/profiler/ProfilerCommand.kt b/CLI/src/commands/profiler/ProfilerCommand.kt new file mode 100644 index 00000000..d3ce1e64 --- /dev/null +++ b/CLI/src/commands/profiler/ProfilerCommand.kt @@ -0,0 +1,42 @@ +package de.steamwar.commands.profiler + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.arguments.help +import com.github.ajalt.clikt.parameters.arguments.optional +import com.github.ajalt.clikt.parameters.options.default +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.types.int + +const val SPARK = "/jars/spark.jar" + +class ProfilerCommand: CliktCommand("profiler") { + val pid by argument().help("Process id").int().optional() + val port by option("--port", "-p").int().default(8543) + + override fun run() { + if (pid != null) { + ProcessBuilder() + .command("java", "-jar", SPARK, pid.toString(), "port=$port") + .start() + .waitFor() + + Thread.sleep(1000) + + ProcessBuilder() + .command("ssh", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-p", port.toString(), "spark@localhost") + .inheritIO() + .start() + .waitFor() + } else { + ProcessBuilder() + .command("java", "-jar", SPARK) + .inheritIO() + .start() + .waitFor() + } + } + + override fun help(context: Context): String = "Start a profiler" +} \ No newline at end of file diff --git a/CLI/src/commands/user/UserCommand.kt b/CLI/src/commands/user/UserCommand.kt new file mode 100644 index 00000000..370cbe06 --- /dev/null +++ b/CLI/src/commands/user/UserCommand.kt @@ -0,0 +1,9 @@ +package de.steamwar.commands.user + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.Context + +class UserCommand: CliktCommand("user") { + override fun run() = Unit + override fun help(context: Context): String = "User related commands" +} \ No newline at end of file diff --git a/CLI/src/commands/user/UserInfoCommand.kt b/CLI/src/commands/user/UserInfoCommand.kt new file mode 100644 index 00000000..bced9b96 --- /dev/null +++ b/CLI/src/commands/user/UserInfoCommand.kt @@ -0,0 +1,65 @@ +package de.steamwar.commands.user + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.CliktError +import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.arguments.help +import com.github.ajalt.mordant.table.table +import de.steamwar.db.findUser +import de.steamwar.db.useDb +import de.steamwar.sql.Punishment +import de.steamwar.sql.SessionTable +import de.steamwar.sql.SteamwarUser +import de.steamwar.sql.Team +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.jdbc.selectAll +import java.time.Duration + +class UserInfoCommand : CliktCommand("info") { + val userId by argument().help("Id, Name, UUID or DiscordId") + val user by lazy { findUser(userId) ?: throw CliktError("User not found") } + + override val printHelpOnEmptyArgs = true + + override fun run() = useDb { + val sessions = + SessionTable.selectAll().where { SessionTable.userId eq user.id.value } + .map { it[SessionTable.startTime] to it[SessionTable.endTime] } + + val totalPlayed = sessions.sumOf { Duration.between(it.first, it.second).toMinutes() } / 60.0 + val firstJoin = sessions.minByOrNull { it.first }?.first + val lastJoin = sessions.maxByOrNull { it.second }?.second + + val punishments = Punishment.getAllPunishmentsOfPlayer(user.id.value) + + echo( + table { + body { + row("Name", user.userName) + row("UUID", user.uuid) + row("Team", Team.byId(user.team).teamName) + row("Leader", user.leader) + row("Locale", user.locale) + row("Beigetreten am", firstJoin) + row("Zuletzt gesehen am", lastJoin) + row("Spielzeit", totalPlayed.toString() + "h") + row("Punishments", if (punishments.isEmpty()) "Keine" else table { + header { row("Typ", "Ersteller", "Von", "Bis", "Grund") } + body { + punishments.map { + row( + it.type, + SteamwarUser.byId(it.punisher)?.userName ?: it.punisher, + it.startTime.toString(), + if (it.perma) "Perma" else it.endTime.toString(), + it.reason + ) + } + } + }) + } + } + ) + + } +} diff --git a/CLI/src/commands/user/UserSearchCommand.kt b/CLI/src/commands/user/UserSearchCommand.kt new file mode 100644 index 00000000..55459baf --- /dev/null +++ b/CLI/src/commands/user/UserSearchCommand.kt @@ -0,0 +1,42 @@ +package de.steamwar.commands.user + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.arguments.help +import com.github.ajalt.mordant.table.table +import de.steamwar.db.joinedOr +import de.steamwar.db.useDb +import de.steamwar.sql.SteamwarUser +import de.steamwar.sql.SteamwarUserTable +import de.steamwar.sql.Team +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.like + +class UserSearchCommand : CliktCommand("search") { + val query by argument().help("Name, Id, UUID or DiscordId") + + override val printHelpOnEmptyArgs = true + + override fun help(context: Context): String = "Search for users" + + override fun run() = useDb { + val users = SteamwarUser.find { + joinedOr( + SteamwarUserTable.username like "%$query%", + SteamwarUserTable.uuid like "%$query%", + query.toLongOrNull()?.let { SteamwarUserTable.discordId eq it }, + query.toIntOrNull()?.let { SteamwarUserTable.id eq it } + ) + } + + val teams = mutableMapOf() + + echo(table { + header { row("Id", "Username", "UUID", "Team", "DiscordId") } + body { + users.map { row(it.id.value, it.userName, it.uuid, teams.computeIfAbsent(it.team) { teamId -> Team.byId(teamId) }.teamName, it.discordId) } + } + }) + } +} diff --git a/CLI/src/db/Database.kt b/CLI/src/db/Database.kt new file mode 100644 index 00000000..7735f29a --- /dev/null +++ b/CLI/src/db/Database.kt @@ -0,0 +1,84 @@ +package de.steamwar.db + +import com.github.ajalt.clikt.core.BaseCliktCommand +import com.github.ajalt.clikt.core.CliktError +import de.steamwar.sql.SteamwarUser +import de.steamwar.sql.SteamwarUserTable +import org.jetbrains.exposed.v1.core.Expression +import org.jetbrains.exposed.v1.core.Op +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.or +import org.jetbrains.exposed.v1.jdbc.Database +import org.jetbrains.exposed.v1.jdbc.JdbcTransaction +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import java.io.File +import java.sql.ResultSet +import java.util.Properties + +object Database { + lateinit var host: String + lateinit var port: String + lateinit var database: String + lateinit var db: Database + + fun ensureConnected() { + if (::db.isInitialized) { + return + } + val config = File(System.getProperty("user.home"), "mysql.properties") + + if (!config.exists()) { + throw CliktError("Config file not found!") + } + + val props = Properties(); + + props.load(config.inputStream()) + + host = props.getProperty("host") + port = props.getProperty("port") + database = props.getProperty("database") + + val username = props.getProperty("user") + val password = props.getProperty("password") + + val url = "jdbc:mariadb://$host:$port/$database" + + db = Database.connect(url, driver = "org.mariadb.jdbc.Driver", user = username, password = password) + return + } +} + +fun > BaseCliktCommand.findUser(query: String): SteamwarUser? = transaction { + SteamwarUser.find { joinedOr(query.toIntOrNull()?.let { SteamwarUserTable.id eq it }, (SteamwarUserTable.username eq query), SteamwarUserTable.uuid eq query, query.toLongOrNull()?.let { SteamwarUserTable.discordId eq it }) } + .firstOrNull() + ?.let { return@transaction it } +} + +fun joinedOr(vararg expressions: Expression?): Op = + expressions.filterNotNull().reduce { acc, expression -> acc or expression } as Op + + +fun JdbcTransaction.execute(sql: String, transform: (ResultSet) -> T): List { + val result = mutableListOf() + exec(sql) { rs -> + while (rs.next()) { + result += transform(rs) + } + } + return result +} + +fun JdbcTransaction.executeSingle(sql: String, transform: (ResultSet) -> T): T? { + return execute(sql) { rs -> + if (!rs.next()) { + return@execute null + } + transform(rs) + }.single() +} + +fun useDb(statement: JdbcTransaction.() -> Unit) { + de.steamwar.db.Database.ensureConnected() + transaction(de.steamwar.db.Database.db, statement) +} diff --git a/CLI/src/logback.xml b/CLI/src/logback.xml new file mode 100644 index 00000000..1ac3bfc0 --- /dev/null +++ b/CLI/src/logback.xml @@ -0,0 +1,11 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index ea47eac1..351c4343 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -183,6 +183,8 @@ include( include("CommandFramework") +include("CLI") + include( "CommonCore", "CommonCore:Data", diff --git a/steamwarci.yml b/steamwarci.yml index 758d179e..0024a1c3 100644 --- a/steamwarci.yml +++ b/steamwarci.yml @@ -1,5 +1,6 @@ build: - "./gradlew build --no-daemon" + - "./gradlew :CLI:installDist --no-daemon" artifacts: "/jars/BauSystem.jar": "BauSystem/build/libs/BauSystem-all.jar" @@ -33,4 +34,6 @@ artifacts: "/jars/website-api.jar": "WebsiteBackend/build/libs/WebsiteBackend-all.jar" release: + - "rm -r /jars/sw" + - "cp -r CLI/build/install/sw /jars" - "sudo systemctl restart api.service"