├── gradle.properties ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── libs.versions.toml ├── .idea ├── .gitignore ├── codeStyles │ ├── codeStyleConfig.xml │ └── Project.xml ├── vcs.xml ├── kotlinc.xml └── misc.xml ├── kobweb ├── src │ └── main │ │ └── kotlin │ │ ├── com │ │ └── varabyte │ │ │ └── kobweb │ │ │ └── cli │ │ │ ├── common │ │ │ ├── ProgramUtils.kt │ │ │ ├── OsUtils.kt │ │ │ ├── KotterAnims.kt │ │ │ ├── RuntimeUtils.kt │ │ │ ├── RepoUtils.kt │ │ │ ├── PathUtils.kt │ │ │ ├── Globals.kt │ │ │ ├── Validations.kt │ │ │ ├── kotter │ │ │ │ ├── YesNoChoice.kt │ │ │ │ └── KotterUtils.kt │ │ │ ├── version │ │ │ │ ├── VersionUtils.kt │ │ │ │ └── SemVer.kt │ │ │ ├── template │ │ │ │ ├── KobwebTemplate.kt │ │ │ │ └── Instructions.kt │ │ │ ├── GitUtils.kt │ │ │ ├── KobwebUtils.kt │ │ │ └── GradleUtils.kt │ │ │ ├── version │ │ │ └── Version.kt │ │ │ ├── create │ │ │ ├── freemarker │ │ │ │ ├── methods │ │ │ │ │ ├── StringMethods.kt │ │ │ │ │ ├── YamlMethods.kt │ │ │ │ │ ├── SingleArgMethodModel.kt │ │ │ │ │ ├── PathMethods.kt │ │ │ │ │ └── BoolMethods.kt │ │ │ │ └── FreemarkerState.kt │ │ │ └── Create.kt │ │ │ ├── conf │ │ │ └── Conf.kt │ │ │ ├── list │ │ │ └── List.kt │ │ │ ├── stop │ │ │ └── Stop.kt │ │ │ ├── export │ │ │ └── Export.kt │ │ │ ├── help │ │ │ └── KotterHelpFormatter.kt │ │ │ └── run │ │ │ └── Run.kt │ │ └── main.kt ├── README.md ├── jreleaser │ └── templates │ │ └── brew │ │ └── README.md.tpl └── build.gradle.kts ├── settings.gradle.kts ├── .gitignore ├── .github └── workflows │ ├── build.yml │ └── publish.yml ├── gradlew.bat ├── README.md ├── gradlew └── LICENSE /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official 2 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/varabyte/kobweb-cli/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # GitHub Copilot persisted chat sessions 5 | /copilot/chatSessions 6 | -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/com/varabyte/kobweb/cli/common/ProgramUtils.kt: -------------------------------------------------------------------------------- 1 | package com.varabyte.kobweb.cli.common 2 | 3 | val ProgramArgsKey by Globals.Key>() 4 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | } 5 | } 6 | 7 | rootProject.name = "kobweb-cli" 8 | 9 | include(":kobweb") -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/com/varabyte/kobweb/cli/common/OsUtils.kt: -------------------------------------------------------------------------------- 1 | package com.varabyte.kobweb.cli.common 2 | 3 | object Os { 4 | fun isWindows() = System.getProperty("os.name").lowercase().contains("windows") 5 | } -------------------------------------------------------------------------------- /kobweb/README.md: -------------------------------------------------------------------------------- 1 | A binary that helps you create and run your Kobweb app / site. 2 | 3 | You will use it to create project templates, build your site, run a webserver, and more. 4 | 5 | Run `$ kobweb --help` for more information. -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/com/varabyte/kobweb/cli/version/Version.kt: -------------------------------------------------------------------------------- 1 | package com.varabyte.kobweb.cli.version 2 | 3 | import com.varabyte.kobweb.cli.common.version.kobwebCliVersion 4 | 5 | fun handleVersion() { 6 | println("kobweb $kobwebCliVersion") 7 | } -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/com/varabyte/kobweb/cli/create/freemarker/methods/StringMethods.kt: -------------------------------------------------------------------------------- 1 | package com.varabyte.kobweb.cli.create.freemarker.methods 2 | 3 | import com.varabyte.kobweb.cli.common.Validations 4 | 5 | class IsNotEmptyMethod : SingleArgMethodModel() { 6 | override fun exec(value: String): String? { 7 | return Validations.isNotEmpty(value) 8 | } 9 | } -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/com/varabyte/kobweb/cli/common/KotterAnims.kt: -------------------------------------------------------------------------------- 1 | package com.varabyte.kobweb.cli.common 2 | 3 | import com.varabyte.kotter.foundation.anim.TextAnim 4 | import kotlin.time.Duration.Companion.milliseconds 5 | 6 | object Anims { 7 | val ELLIPSIS = TextAnim.Template(listOf("", ".", "..", "..."), 250.milliseconds) 8 | val SPINNER = TextAnim.Template(listOf("⠋", "⠙", "⠸", "⠴", "⠦", "⠇"), 150.milliseconds) 9 | } 10 | -------------------------------------------------------------------------------- /kobweb/jreleaser/templates/brew/README.md.tpl: -------------------------------------------------------------------------------- 1 | # Homebrew Tap 2 | 3 | This project is a list of formulae for exposing Varabyte artifacts to Homebrew. 4 | 5 | ## Formulae 6 | Install a formula either by listing the tap path explicitly: 7 | 8 | ```sh 9 | $ brew install varabyte/tap/ 10 | ``` 11 | 12 | Or by installing our tap first: 13 | 14 | ```sh 15 | $ brew tap varabyte/tap # you only need to do this the first time 16 | $ brew install 17 | ``` 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | /*.ipr 3 | 4 | .gradle 5 | /local.properties 6 | 7 | /.idea/caches 8 | /.idea/libraries 9 | /.idea/modules.xml 10 | /.idea/workspace.xml 11 | /.idea/gradle.xml 12 | /.idea/navEditor.xml 13 | /.idea/assetWizardSettings.xml 14 | /.idea/artifacts 15 | /.idea/compiler.xml 16 | /.idea/jarRepositories.xml 17 | /.idea/*.iml 18 | /.idea/modules 19 | /.idea/libraries-with-intellij-classes.xml 20 | 21 | .DS_Store 22 | .kotlin/ 23 | kotlin-js-store/ 24 | build 25 | out 26 | /captures 27 | .externalNativeBuild 28 | .cxx 29 | local.properties -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 11 | 12 | -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/com/varabyte/kobweb/cli/create/freemarker/methods/YamlMethods.kt: -------------------------------------------------------------------------------- 1 | package com.varabyte.kobweb.cli.create.freemarker.methods 2 | 3 | /** 4 | * Utility method for escaping relevant control characters before inserting text into a yaml string. 5 | */ 6 | class EscapeYamlStringMethod : SingleArgMethodModel() { 7 | override fun exec(value: String): String { 8 | return value 9 | // Replace backslash first, else we'd modify any quote substitutions (e.g. '"' -> '\"' -> '\\"') 10 | .replace("\\", """\\""") 11 | .replace("\"", """\"""") 12 | } 13 | } -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/com/varabyte/kobweb/cli/create/freemarker/methods/SingleArgMethodModel.kt: -------------------------------------------------------------------------------- 1 | package com.varabyte.kobweb.cli.create.freemarker.methods 2 | 3 | import freemarker.template.TemplateMethodModelEx 4 | import freemarker.template.TemplateModelException 5 | 6 | abstract class SingleArgMethodModel : TemplateMethodModelEx { 7 | final override fun exec(arguments: List): Any? { 8 | if (arguments.size != 1) { 9 | throw TemplateModelException("Expected a single value, got: [${arguments.joinToString()}]") 10 | } 11 | return exec(arguments[0].toString()) 12 | } 13 | 14 | abstract fun exec(value: String): String? 15 | } -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build Kobweb CLI 2 | 3 | on: 4 | push: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - uses: actions/setup-java@v4 15 | with: 16 | distribution: temurin 17 | java-version: 11 18 | 19 | - name: Setup Gradle 20 | uses: gradle/actions/setup-gradle@v4 21 | 22 | - name: Build Kobweb CLI 23 | run: ./gradlew :kobweb:assembleShadowDist 24 | 25 | - name: Upload artifacts 26 | uses: actions/upload-artifact@v4 27 | with: 28 | name: kobweb-cli-artifacts 29 | path: kobweb/build/distributions 30 | if-no-files-found: error 31 | retention-days: 1 32 | -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/com/varabyte/kobweb/cli/common/RuntimeUtils.kt: -------------------------------------------------------------------------------- 1 | package com.varabyte.kobweb.cli.common 2 | 3 | import com.varabyte.kobweb.common.io.consumeAsync 4 | 5 | fun Runtime.git(vararg args: String): Process { 6 | val finalArgs = mutableListOf("git") 7 | finalArgs.addAll(args) 8 | return exec(finalArgs.toTypedArray()) 9 | } 10 | 11 | private fun defaultOutputHandler(line: String, isError: Boolean) { 12 | if (isError) { 13 | System.err.println(line) 14 | } else { 15 | println(line) 16 | } 17 | } 18 | 19 | fun Process.consumeProcessOutput(onLineRead: (line: String, isError: Boolean) -> Unit = ::defaultOutputHandler) { 20 | inputStream.consumeAsync { line -> onLineRead(line, false) } 21 | errorStream.consumeAsync { line -> onLineRead(line, true) } 22 | } 23 | -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/com/varabyte/kobweb/cli/create/freemarker/methods/PathMethods.kt: -------------------------------------------------------------------------------- 1 | package com.varabyte.kobweb.cli.create.freemarker.methods 2 | 3 | import com.varabyte.kobweb.cli.common.Validations 4 | import java.util.* 5 | 6 | class FileToTitleMethod : SingleArgMethodModel() { 7 | override fun exec(value: String): String { 8 | return value 9 | .split(Regex("""[^\w]""")) 10 | .filter { it.isNotBlank() } 11 | .joinToString(" ") { it -> 12 | it.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } 13 | } 14 | } 15 | } 16 | 17 | class FileToPackageMethod : SingleArgMethodModel() { 18 | override fun exec(value: String): String { 19 | return value.lowercase().replace(Regex("""[^\w_]"""), "") 20 | } 21 | } 22 | 23 | class IsPackageMethod : SingleArgMethodModel() { 24 | override fun exec(value: String): String? { 25 | return Validations.isValidPackage(value) 26 | } 27 | } 28 | 29 | class PackageToPathMethod : SingleArgMethodModel() { 30 | override fun exec(value: String): String { 31 | return value.replace(".", "/") 32 | } 33 | } -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | clikt = "5.0.1" 3 | freemarker = "2.3.31" 4 | jreleaser = "1.14.0" 5 | kobweb-cli = "0.9.21" 6 | kobweb-libs = "0.23.0" 7 | kotter = "1.2.1" 8 | kotlin = "2.2.0" 9 | kotlinx-coroutines = "1.8.1" 10 | okhttp = "4.12.0" 11 | shadow = "8.1.1" 12 | 13 | [libraries] 14 | clikt-core = { module = "com.github.ajalt.clikt:clikt-core", version.ref = "clikt" } 15 | freemarker = { module = "org.freemarker:freemarker", version.ref = "freemarker" } 16 | kobweb-common = { module = "com.varabyte.kobweb:kobweb-common", version.ref = "kobweb-libs" } 17 | kotter = { module = "com.varabyte.kotter:kotter", version.ref = "kotter" } 18 | kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } 19 | okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } 20 | 21 | [plugins] 22 | jreleaser = { id = "org.jreleaser", version.ref = "jreleaser" } 23 | kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } 24 | kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } 25 | shadow = { id = "com.github.johnrengelman.shadow", version.ref = "shadow" } 26 | -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/com/varabyte/kobweb/cli/common/RepoUtils.kt: -------------------------------------------------------------------------------- 1 | package com.varabyte.kobweb.cli.common 2 | 3 | import com.varabyte.kobweb.cli.common.kotter.processing 4 | import com.varabyte.kobweb.cli.common.kotter.textError 5 | import com.varabyte.kotter.foundation.text.textLine 6 | import com.varabyte.kotter.runtime.Session 7 | import com.varabyte.kotter.runtime.concurrent.createKey 8 | import java.nio.file.Files 9 | import java.nio.file.Path 10 | 11 | const val DEFAULT_REPO = "https://github.com/varabyte/kobweb-templates" 12 | 13 | private val TempDirKey = Session.Lifecycle.createKey() 14 | 15 | /** 16 | * Fetch the target repository into a temporary location, returning it, or null if the fetch failed. 17 | * 18 | * The temp directory will be automatically deleted when the program exits. 19 | */ 20 | fun Session.handleFetch(gitClient: GitClient, repo: String, branch: String?): Path? { 21 | val tempDir = Files.createTempDirectory("kobweb") 22 | data.set(TempDirKey, tempDir, dispose = { tempDir.toFile().deleteRecursively() }) 23 | if (!processing("Cloning \"$repo\"") { 24 | gitClient.clone(repo, branch, tempDir) 25 | }) { 26 | section { 27 | textError("We were unable to fetch templates. Confirm the specified repository/branch and try again.") 28 | }.run() 29 | 30 | return null 31 | } 32 | section { textLine() }.run() 33 | 34 | return tempDir 35 | } 36 | -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/com/varabyte/kobweb/cli/common/PathUtils.kt: -------------------------------------------------------------------------------- 1 | package com.varabyte.kobweb.cli.common 2 | 3 | import java.nio.file.Path 4 | import java.nio.file.Paths 5 | import kotlin.io.path.relativeTo 6 | 7 | object PathUtils { 8 | /** 9 | * Given a path, e.g. "myproject", return it OR the path with a number appended on it if there are already existing 10 | * folders at that path with that name, e.g. "myproject4" 11 | */ 12 | fun generateEmptyPathName(name: String): String { 13 | require(Validations.isFileName(name.substringAfterLast('/')) == null) 14 | var finalName = name 15 | var i = 2 16 | while (Validations.isEmptyPath(finalName) != null) { 17 | finalName = "$name$i" 18 | i++ 19 | } 20 | return finalName 21 | } 22 | } 23 | 24 | /** Convert a string like "**.txt" to a regex that would match "a.txt", "a/b.txt", "a/b/c.txt", etc. */ 25 | fun String.wildcardToRegex(): Regex { 26 | require(!this.contains("***")) { "Invalid wildcard string passed to regex generator: $this" } 27 | require(!this.contains("\\")) { "No backslashes allowed in string passed to regex generator: $this" } 28 | 29 | val regexStr = this 30 | .replace(".", """\.""") 31 | .replace("?", ".") 32 | .replace("**", "||") // Temporarily get out of way of single star replacement 33 | .replace("*", """[^/]*""") 34 | .replace("||", ".*") 35 | 36 | return Regex("^$regexStr\$") 37 | } 38 | 39 | /** 40 | * Given a path, return it relative to the current directory. 41 | * 42 | * This might not be possible, e.g. if in Windows and the path have different drives, so in that case, this will return 43 | * null. 44 | */ 45 | fun Path.relativeToCurrentDirectory(): Path? = try { 46 | this.relativeTo(Paths.get(".").toAbsolutePath()) 47 | } catch (ex: Exception) { 48 | null 49 | } 50 | -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/com/varabyte/kobweb/cli/common/Globals.kt: -------------------------------------------------------------------------------- 1 | package com.varabyte.kobweb.cli.common 2 | 3 | import kotlin.reflect.KProperty 4 | 5 | /** 6 | * A collection of global values. 7 | * 8 | * This is essentially the service locator pattern. It's a way to support global values while still keeping them 9 | * all collected in one central place. This is also a useful pattern for unit testing. 10 | * 11 | * To add or fetch a value, you must create a key first and associated data with it: 12 | * 13 | * ``` 14 | * val ExampleKey by Globals.Key() // Key names must be globally unique within a program 15 | * 16 | * // Set a value 17 | * Globals[ExampleKey] = true 18 | * 19 | * // Fetch a value 20 | * val value = Globals[ExampleKey] // Returns Boolean? 21 | * val value = Globals.getValue(ExampleKey) // Returns Boolean 22 | *``` 23 | */ 24 | @Suppress("UNCHECKED_CAST") // key/value types are guaranteed correct by get/set signatures 25 | object Globals { 26 | /** 27 | * A factory method to create a [Key] using the property name as the key name. 28 | */ 29 | @Suppress("FunctionName") // Factory method 30 | fun Key() = KeyProvider() 31 | 32 | @Suppress("unused") // T used at compile time when adding / fetching data 33 | data class Key(val name: String) { 34 | operator fun getValue(thisRef: Any?, prop: KProperty<*>): Key { 35 | return Key(prop.name) 36 | } 37 | } 38 | 39 | class KeyProvider { 40 | companion object { 41 | private val cache = mutableMapOf>() 42 | } 43 | 44 | operator fun getValue(thisRef: Any?, prop: KProperty<*>): Key { 45 | return cache.getOrPut(prop.name) { Key(prop.name) } as Key 46 | } 47 | } 48 | 49 | private val data = mutableMapOf, Any?>() 50 | 51 | operator fun get(key: Key): T? = data[key] as T? 52 | fun getValue(key: Key): T = 53 | get(key) ?: error("Expected key \"${key.name}\" to be set already but it wasn't.") 54 | 55 | operator fun set(key: Key, value: T) { 56 | data[key] = value 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/com/varabyte/kobweb/cli/create/freemarker/methods/BoolMethods.kt: -------------------------------------------------------------------------------- 1 | package com.varabyte.kobweb.cli.create.freemarker.methods 2 | 3 | class IsYesNoMethod : SingleArgMethodModel() { 4 | override fun exec(value: String): String? { 5 | val valueLower = value.lowercase() 6 | return if (listOf("yes", "no", "true", "false").any { it.startsWith(valueLower) }) { 7 | null 8 | } else { 9 | "Answer must be yes or no" 10 | } 11 | } 12 | } 13 | 14 | class YesNoToBoolMethod : SingleArgMethodModel() { 15 | override fun exec(value: String): String { 16 | val valueLower = value.lowercase() 17 | return (listOf("yes", "true").any { it.startsWith(valueLower) }).toString() 18 | } 19 | } 20 | 21 | class IsIntMethod : SingleArgMethodModel() { 22 | override fun exec(value: String): String? { 23 | return if (value.toIntOrNull() != null) { 24 | null 25 | } else { 26 | "Answer must be an integer number" 27 | } 28 | } 29 | } 30 | 31 | class IsPositiveIntMethod : SingleArgMethodModel() { 32 | override fun exec(value: String): String? { 33 | return if (value.toIntOrNull()?.takeIf { it >= 0 } != null) { 34 | null 35 | } else { 36 | "Answer must be a positive integer number" 37 | } 38 | } 39 | } 40 | 41 | class IsNumberMethod : SingleArgMethodModel() { 42 | override fun exec(value: String): String? { 43 | return if (value.toDoubleOrNull()?.takeIf { it.isFinite() } != null) { 44 | null 45 | } else { 46 | "Answer must be a number" 47 | } 48 | } 49 | } 50 | 51 | class IsPositiveNumberMethod : SingleArgMethodModel() { 52 | override fun exec(value: String): String? { 53 | return if (value.toDoubleOrNull()?.takeIf { it.isFinite() && it >= 0.0 } != null) { 54 | null 55 | } else { 56 | "Answer must be a positive number" 57 | } 58 | } 59 | } 60 | 61 | class NotMethod : SingleArgMethodModel() { 62 | override fun exec(value: String): String { 63 | return (!value.toBoolean()).toString() 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/com/varabyte/kobweb/cli/common/Validations.kt: -------------------------------------------------------------------------------- 1 | package com.varabyte.kobweb.cli.common 2 | 3 | import com.varabyte.kobweb.common.lang.isHardKeyword 4 | import java.nio.file.Files 5 | import java.nio.file.Path 6 | 7 | object Validations { 8 | fun isNotEmpty(value: String): String? { 9 | return when { 10 | value.isBlank() -> "The value must not be blank" 11 | else -> null 12 | } 13 | } 14 | 15 | fun isFileName(name: String): String? { 16 | return when { 17 | name != "." && !name.all { it.isLetterOrDigit() || it == '-' || it == '_' || it == '.' } -> 18 | "Invalid name. Can only contain: letters, digits, dash, dots, and underscore" 19 | 20 | else -> null 21 | } 22 | } 23 | 24 | fun isEmptyPath(pathStr: String): String? { 25 | Path.of(pathStr).let { path -> 26 | return when { 27 | Files.exists(path) -> { 28 | when { 29 | Files.isRegularFile(path) -> "A file already exists at that location" 30 | Files.newDirectoryStream(path).any() -> "The specified directory is not empty" 31 | else -> null 32 | } 33 | } 34 | 35 | else -> null 36 | } 37 | } 38 | } 39 | 40 | fun isValidPackage(value: String): String? { 41 | if (value.isBlank()) return null 42 | 43 | if (value.startsWith('.') || value.endsWith('.') || value.contains("..")) { 44 | return "Package should not contain any empty parts." 45 | } 46 | 47 | value.split(".").forEach { part -> 48 | if (part.isHardKeyword()) { 49 | return "\"$part\" is a reserved keyword in Kotlin and cannot be used. Suggestion: Use \"${part}_\" instead." 50 | } else if (part.first().isDigit()) { 51 | return "Package parts cannot start with digits. Suggestion: Use \"_$part\" instead." 52 | } else if (!part.all { it.isLetterOrDigit() || it == '_' }) { 53 | return "Package parts can only use letters, numbers, and underscores." 54 | } 55 | } 56 | 57 | return null 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/com/varabyte/kobweb/cli/conf/Conf.kt: -------------------------------------------------------------------------------- 1 | package com.varabyte.kobweb.cli.conf 2 | 3 | import com.charleskorn.kaml.Yaml 4 | import com.charleskorn.kaml.YamlMap 5 | import com.charleskorn.kaml.YamlNode 6 | import com.charleskorn.kaml.YamlScalar 7 | import com.charleskorn.kaml.yamlMap 8 | import com.varabyte.kobweb.cli.common.assertKobwebApplication 9 | import com.varabyte.kobweb.cli.common.assertKobwebConfIn 10 | import com.varabyte.kobweb.project.conf.KobwebConfFile 11 | import java.io.File 12 | import kotlin.io.path.readText 13 | 14 | private fun YamlMap.collectValuesInto(map: MutableMap, pathPrefix: List) { 15 | this.entries.forEach { (key, value) -> 16 | if (value is YamlScalar) { 17 | val keyPrefix = if (pathPrefix.isNotEmpty()) pathPrefix.joinToString(".") + "." else "" 18 | map["$keyPrefix${key.content}"] = value.content 19 | } else if (value is YamlMap) { 20 | value.collectValuesInto(map, pathPrefix + key.content) 21 | } 22 | } 23 | } 24 | 25 | private fun YamlNode.collectValues(): Map { 26 | val scalars = mutableMapOf() 27 | yamlMap.collectValuesInto(scalars, emptyList()) 28 | return scalars 29 | } 30 | 31 | fun handleConf(query: String?, projectDir: File) { 32 | val kobwebApplication = assertKobwebApplication(projectDir.toPath()) 33 | 34 | // Even though we don't use its return value, we use this as a side effect to show a useful error message to users 35 | // if the conf.yaml file is not found. 36 | assertKobwebConfIn(kobwebApplication.kobwebFolder) 37 | 38 | // Instead of using Kobweb's KobwebConf deserialized class, we use a generically parsed Yaml result. This ensures 39 | // that this command will work flexibly with any version of any conf.yaml file, even if a new field has since been 40 | // added. 41 | val yamlNode = Yaml.default.parseToYamlNode(KobwebConfFile(kobwebApplication.kobwebFolder).path.readText()) 42 | 43 | val yamlValues = yamlNode.collectValues() 44 | 45 | fun StringBuilder.appendPossibleQueries() { 46 | appendLine("Possible queries are:") 47 | yamlValues.keys.sorted().forEach { key -> 48 | append(" • ") 49 | appendLine(key) 50 | } 51 | } 52 | 53 | if (query.isNullOrBlank()) { 54 | println(buildString { appendPossibleQueries() }) 55 | } else { 56 | val answer = yamlValues[query] 57 | 58 | if (answer != null) { 59 | println(answer) 60 | } else { 61 | System.err.println(buildString { 62 | appendLine("Invalid query.") 63 | appendLine() 64 | appendPossibleQueries() 65 | }) 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/com/varabyte/kobweb/cli/common/kotter/YesNoChoice.kt: -------------------------------------------------------------------------------- 1 | package com.varabyte.kobweb.cli.common.kotter 2 | 3 | import com.varabyte.kotter.foundation.input.Keys 4 | import com.varabyte.kotter.foundation.input.onKeyPressed 5 | import com.varabyte.kotter.foundation.text.bold 6 | import com.varabyte.kotter.foundation.text.text 7 | import com.varabyte.kotter.foundation.text.textLine 8 | import com.varabyte.kotter.runtime.MainRenderScope 9 | import com.varabyte.kotter.runtime.RunScope 10 | import com.varabyte.kotter.runtime.Section 11 | import com.varabyte.kotter.runtime.concurrent.createKey 12 | import com.varabyte.kotter.runtime.render.RenderScope 13 | 14 | private val YesNoStateKey = Section.Lifecycle.createKey() 15 | 16 | private fun RenderScope.wrapIf(condition: Boolean, before: Char, after: Char, block: RenderScope.() -> Unit) { 17 | text(if (condition) before else ' ') 18 | scopedState { 19 | block() 20 | } 21 | text(if (condition) after else ' ') 22 | } 23 | 24 | private fun RenderScope.choiceStyle(isDefault: Boolean) { 25 | if (isDefault) bold() 26 | } 27 | 28 | // NOTE: In this current (lazy) implementation, only one yes-no block is allowed per section. 29 | // We can revisit this later if we need something more complex. 30 | fun MainRenderScope.yesNo(isYes: Boolean, default: Boolean = true) { 31 | data[YesNoStateKey] = isYes 32 | wrapIf(isYes, '[', ']') { 33 | choiceStyle(default) 34 | text("Yes") 35 | } 36 | text(' ') 37 | wrapIf(!isYes, '[', ']') { 38 | choiceStyle(!default) 39 | text("No") 40 | } 41 | textLine() 42 | } 43 | 44 | class YesNoScope(val isYes: Boolean, val shouldAccept: Boolean = false) 45 | 46 | // NOTE: This registers onKeyPressed meaning you can't use this AND onKeyPressed in your own code. 47 | // Pass null into `valueOnCancel` to disable cancelling. 48 | fun RunScope.onYesNoChanged(valueOnCancel: Boolean? = false, block: YesNoScope.() -> Unit) { 49 | fun isYes() = data[YesNoStateKey]!! 50 | 51 | onKeyPressed { 52 | val yesNoScope = when (key) { 53 | Keys.LEFT, Keys.RIGHT -> YesNoScope(!isYes()) 54 | Keys.HOME -> YesNoScope(true) 55 | Keys.END -> YesNoScope(false) 56 | 57 | Keys.Y, Keys.Y_UPPER -> YesNoScope(true, shouldAccept = true) 58 | Keys.N, Keys.N_UPPER -> YesNoScope(false, shouldAccept = true) 59 | 60 | // Q included because Kobweb users might be used to pressing it in other contexts 61 | Keys.ESC, Keys.Q, Keys.Q_UPPER -> { 62 | if (valueOnCancel != null) YesNoScope(valueOnCancel, shouldAccept = true) else null 63 | } 64 | 65 | Keys.ENTER -> YesNoScope(isYes(), shouldAccept = true) 66 | 67 | else -> null 68 | } 69 | 70 | yesNoScope?.block() 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/com/varabyte/kobweb/cli/list/List.kt: -------------------------------------------------------------------------------- 1 | package com.varabyte.kobweb.cli.list 2 | 3 | import com.varabyte.kobweb.cli.common.DEFAULT_REPO 4 | import com.varabyte.kobweb.cli.common.findGit 5 | import com.varabyte.kobweb.cli.common.handleFetch 6 | import com.varabyte.kobweb.cli.common.kotter.textError 7 | import com.varabyte.kobweb.cli.common.template.KobwebTemplateFile 8 | import com.varabyte.kobweb.cli.common.template.getName 9 | import com.varabyte.kobweb.cli.common.version.versionIsSupported 10 | import com.varabyte.kotter.foundation.session 11 | import com.varabyte.kotter.foundation.text.cyan 12 | import com.varabyte.kotter.foundation.text.text 13 | import com.varabyte.kotter.foundation.text.textLine 14 | import com.varabyte.kotter.foundation.text.yellow 15 | import com.varabyte.kotter.runtime.render.RenderScope 16 | import java.nio.file.Path 17 | 18 | private fun RenderScope.renderTemplateItem( 19 | rootPath: Path, 20 | templateFile: KobwebTemplateFile, 21 | ) { 22 | val templatePath = templateFile.getName(rootPath) 23 | val description = templateFile.template.metadata.description 24 | 25 | text("• ") 26 | cyan { text(templatePath) } 27 | textLine(": $description") 28 | } 29 | 30 | private fun List.renderTemplateItems( 31 | rootPath: Path, 32 | renderScope: RenderScope, 33 | ) { 34 | this 35 | .sortedBy { template -> template.folder } 36 | .forEach { template -> renderScope.renderTemplateItem(rootPath, template) } 37 | } 38 | 39 | fun handleList(repo: String, branch: String?) = session { 40 | val gitClient = findGit() ?: return@session 41 | val tempDir = handleFetch(gitClient, repo, branch) ?: return@session 42 | 43 | val templates = tempDir.toFile().walkTopDown() 44 | .filter { it.isDirectory } 45 | .mapNotNull { dir -> KobwebTemplateFile.inPath(dir.toPath()) } 46 | .filter { it.template.versionIsSupported } 47 | .toList() 48 | 49 | section { 50 | if (templates.isNotEmpty()) { 51 | text("You can create the following Kobweb projects by typing `kobweb create ") 52 | cyan { text("...") } 53 | if (repo != DEFAULT_REPO) { 54 | text(" --repo $repo") 55 | } 56 | if (branch != null) { 57 | text(" --branch $branch") 58 | } 59 | textLine("`") 60 | textLine() 61 | 62 | run { 63 | val highlightedTemplates = templates.filter { it.template.metadata.shouldHighlight } 64 | if (highlightedTemplates.isNotEmpty()) { 65 | yellow { textLine("Highlighted projects") } 66 | textLine() 67 | highlightedTemplates.renderTemplateItems(tempDir, this) 68 | textLine() 69 | yellow { textLine("All projects") } 70 | textLine() 71 | } 72 | } 73 | 74 | templates.renderTemplateItems(tempDir, this) 75 | } else { 76 | textError("No templates were found in the specified repository.") 77 | } 78 | }.run() 79 | } 80 | -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/com/varabyte/kobweb/cli/common/version/VersionUtils.kt: -------------------------------------------------------------------------------- 1 | package com.varabyte.kobweb.cli.common.version 2 | 3 | import com.varabyte.kobweb.cli.common.template.KobwebTemplate 4 | import com.varabyte.kotter.foundation.text.black 5 | import com.varabyte.kotter.foundation.text.cyan 6 | import com.varabyte.kotter.foundation.text.green 7 | import com.varabyte.kotter.foundation.text.text 8 | import com.varabyte.kotter.foundation.text.textLine 9 | import com.varabyte.kotter.foundation.text.white 10 | import com.varabyte.kotter.foundation.text.yellow 11 | import com.varabyte.kotter.runtime.Session 12 | import com.varabyte.kotterx.decorations.BorderCharacters 13 | import com.varabyte.kotterx.decorations.bordered 14 | 15 | private val kobwebCliVersionString get() = System.getProperty("kobweb.version", "0.0.0-SNAPSHOT") 16 | 17 | val kobwebCliVersion: SemVer.Parsed by lazy { SemVer.parse(kobwebCliVersionString) } 18 | 19 | object KobwebServerFeatureVersions { 20 | val toggleLiveReloading by lazy { SemVer.parse("0.23.0") } 21 | } 22 | 23 | /** 24 | * Returns true if the given template is supported by the current version of the Kobweb CLI. 25 | * 26 | * This assumes that the "minimumVersion" value in the template metadata was properly set. If it can't be parsed, 27 | * we silently hide it instead of crashing. 28 | */ 29 | @Suppress("RedundantIf") // Code is more readable when symmetric 30 | val KobwebTemplate.versionIsSupported: Boolean 31 | get() { 32 | // Don't consider pre-release versions for compatibility checks 33 | // e.g. `1.2.3-SNAPSHOT` should be able to create `1.2.3` templates 34 | val ourVersion = kobwebCliVersion.withoutPreRelease() 35 | 36 | val minVersion = metadata.minimumVersion?.let { SemVer.tryParse(it) } 37 | if (minVersion != null && minVersion > ourVersion) { 38 | return false 39 | } 40 | 41 | val maxVersion = metadata.maximumVersion?.let { SemVer.tryParse(it) } 42 | if (maxVersion != null && maxVersion < ourVersion) { 43 | return false 44 | } 45 | 46 | return true 47 | } 48 | 49 | fun Session.reportUpdateAvailable(oldVersion: SemVer.Parsed, newVersion: SemVer.Parsed) { 50 | section { 51 | textLine() 52 | yellow { 53 | bordered(borderCharacters = BorderCharacters.CURVED, paddingLeftRight = 2, paddingTopBottom = 1) { 54 | white() 55 | text("Update available: ") 56 | black(isBright = true) { 57 | text(oldVersion.toString()) 58 | } 59 | text(" → ") 60 | green { 61 | text(newVersion.toString()) 62 | } 63 | textLine() 64 | cyan(isBright = false) { text("https://github.com/varabyte/kobweb-cli/releases/tag/v${newVersion}") } 65 | textLine(); textLine() 66 | text("Please review ") 67 | cyan(isBright = false) { text("https://github.com/varabyte/kobweb#update-the-kobweb-binary") } 68 | textLine() 69 | textLine("for instructions.") 70 | } 71 | } 72 | }.run() 73 | } 74 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH= 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Kobweb CLI to package managers 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | dryRun: 7 | description: 'Dry run' 8 | type: boolean 9 | required: true 10 | default: true 11 | jobs: 12 | publish: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - uses: actions/setup-java@v4 19 | with: 20 | distribution: temurin 21 | java-version: 11 22 | 23 | - name: Setup Gradle 24 | uses: gradle/actions/setup-gradle@v4 25 | 26 | - name: Add secret Gradle properties 27 | env: 28 | GRADLE_PROPERTIES: ${{ secrets.VARABYTE_GRADLE_PROPERTIES }} 29 | run: | 30 | mkdir -p ~/.gradle/ 31 | echo "GRADLE_USER_HOME=${HOME}/.gradle" >> $GITHUB_ENV 32 | echo "${GRADLE_PROPERTIES}" > ~/.gradle/gradle.properties 33 | 34 | - name: Set dry run option 35 | run: | 36 | echo "kobweb.cli.jreleaser.dryrun=${{ github.event.inputs.dryRun }}" >> ~/.gradle/gradle.properties 37 | 38 | # Force Gradle to run early so we don't capture the "Downloading Gradle" message in 39 | # the output of our following steps 40 | - name: Force Gradle download 41 | run: ./gradlew --version 42 | 43 | - name: Get Kobweb CLI Version 44 | id: cli_version 45 | run: | 46 | echo "VERSION=$(./gradlew -q :kobweb:printVersion)" >> "$GITHUB_OUTPUT" 47 | 48 | - name: Download Release Assets 49 | env: 50 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | CLI_VERSION: ${{ steps.cli_version.outputs.VERSION }} 52 | run: | 53 | ASSET_ZIP="kobweb-$CLI_VERSION.zip" 54 | ASSET_TAR="kobweb-$CLI_VERSION.tar" 55 | # Need to put files here so jreleaser can find them 56 | ASSET_PATH="kobweb/build/distributions" 57 | mkdir -p $ASSET_PATH 58 | # Download assets using GitHub API 59 | curl -sL -H "Authorization: token $GITHUB_TOKEN" -H "Accept: application/octet-stream" \ 60 | "$(curl -sL -H "Authorization: token $GITHUB_TOKEN" \ 61 | "https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/tags/v$CLI_VERSION" | jq -r ".assets[] | select(.name==\"$ASSET_ZIP\").url")" \ 62 | -o "$ASSET_PATH/$ASSET_ZIP" 63 | curl -sL -H "Authorization: token $GITHUB_TOKEN" -H "Accept: application/octet-stream" \ 64 | "$(curl -sL -H "Authorization: token $GITHUB_TOKEN" \ 65 | "https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/tags/v$CLI_VERSION" | jq -r ".assets[] | select(.name==\"$ASSET_TAR\").url")" \ 66 | -o "$ASSET_PATH/$ASSET_TAR" 67 | 68 | echo "Zip checksum $(sha256sum $ASSET_PATH/$ASSET_ZIP)" 69 | echo "Tar checksum $(sha256sum $ASSET_PATH/$ASSET_TAR)" 70 | 71 | - name: Publish Kobweb CLI to package managers 72 | run: ./gradlew :kobweb:jreleaserPublish 73 | 74 | - name: Update AUR Package 75 | uses: varabyte/update-aur-package@v1.0.4 76 | with: 77 | dry_run: ${{ github.event.inputs.dryRun }} 78 | version: ${{ steps.cli_version.outputs.VERSION }} 79 | package_name: kobweb 80 | commit_username: phi1309 81 | commit_email: phi1309@protonmail.com 82 | ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This project is the CLI binary for the [Kobweb framework](https://github.com/varabyte/kobweb). 2 | 3 | ## Building 4 | 5 | ### Dev 6 | 7 | For local development... 8 | 9 | * Check your `libs.versions.toml` file. 10 | * `kobweb-cli` should have a version with a `-SNAPSHOT` suffix. 11 | 12 | * Install the binary: `./gradlew :kobweb:installShadowDist` 13 | * This will create `kobweb/build/install/kobweb/bin/kobweb`. 14 | * You are encouraged to create a symlink to it which lives in your path, so you can run the `kobweb` command from 15 | anywhere. 16 | 17 | ### Prod 18 | 19 | For a release... 20 | 21 | * Check your `libs.versions.toml` file. 22 | * `kobweb-cli` should have a version that does NOT end with a `-SNAPSHOT` suffix. 23 | 24 | * Assemble the tar and zip files: `./gradlew :kobweb:assembleShadowDist` 25 | * Files live under `kobweb/build/distributions`. 26 | 27 | > [!IMPORTANT] 28 | > The Kobweb CLI project has a [build workflow](.github/workflows/build.yml) which generates CLI artifacts every time a 29 | > new commit is pushed. 30 | > 31 | > You can find these by clicking on the relevant [build run](https://github.com/varabyte/kobweb-cli/actions/workflows/build.yml) 32 | > and then downloading the `kobweb-cli-artifacts` zip from the `Artifacts` section). 33 | > 34 | > You should consider using these instead of ones you built yourself, as the CI environment is guaranteed to be pure, 35 | > whereas local environments may be contaminated by things you've installed or set up on your own system. 36 | 37 | ## Releasing 38 | 39 | * Create a new release on GitHub. 40 | * Choose a tag: "vX.Y.Z", then "Create a new tag on publish" 41 | * Set that tag for the release title as well 42 | * Fill out the release, using previous releases as guidance (and comparing changes to main since last time to see what's 43 | new) 44 | * Add the .zip and .tar files downloaded from GitHub actions or, if built manually, from `kobweb/build/distributions` 45 | * Confirm the release. 46 | 47 | ## Publishing 48 | 49 | > [!IMPORTANT] 50 | > To successfully publish the CLI, the version must NOT be set to a SNAPSHOT version. 51 | 52 | > [!CAUTION] 53 | > Be very careful with this step. If you publish things from the wrong branch, you could make a mess that could take a 54 | > while to clean up. 55 | 56 | * From https://github.com/varabyte/kobweb-cli/actions choose the "Publish" workflow. 57 | * Be sure to select the correct target (should be a branch or tag targeting the version you just released). 58 | * Uncheck the "Dry run" checkbox. (Although you may want to do a dry run first to make sure everything is set up 59 | correctly.) 60 | * Run the workflow. 61 | 62 | ### Manual publishing 63 | 64 | * Set the Gradle property `kobweb.cli.jreleaser.dryrun` to false. 65 | * Run `./gradlew :kobweb:jreleaserPublish` 66 | 67 | Publishing from your machine requires you have defined the following secrets locally: 68 | 69 | * varabyte.github.username 70 | * varabyte.github.token 71 | * sdkman.key 72 | 73 | and the github user specified must have access to edit the `varabyte` organization, as the publish process modifies 74 | other sibling repositories as a side effect. 75 | 76 | ## Informing users about the release 77 | 78 | * Update the badge version at the top of the main Kobweb README 79 | * Update the version file: https://github.com/varabyte/data/blob/main/kobweb/cli-version.txt 80 | * Create an announcement in all relevant Kobweb communities (Discord, Slack, etc.) 81 | -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/com/varabyte/kobweb/cli/common/template/KobwebTemplate.kt: -------------------------------------------------------------------------------- 1 | package com.varabyte.kobweb.cli.common.template 2 | 3 | import com.charleskorn.kaml.Yaml 4 | import com.varabyte.kobweb.common.path.invariantSeparatorsPath 5 | import com.varabyte.kobweb.common.yaml.nonStrictDefault 6 | import kotlinx.serialization.Serializable 7 | import java.nio.file.Path 8 | import kotlin.io.path.exists 9 | import kotlin.io.path.readBytes 10 | import kotlin.io.path.relativeTo 11 | 12 | /** 13 | * @property name If specified, the name of this template that can be used to reference it, e.g. "site/example". If not 14 | * specified, this name will come from the template's path to the root. See also: [KobwebTemplateFile.getName]. 15 | * @property shouldHighlight This template is considered important and should be called out 16 | * separately when all templates for a repository are listed. 17 | * @property minimumVersion The minimum version of the Kobweb CLI that this template is compatible with. Should be in 18 | * the format "x.y.z" (e.g. "1.2.3"). If not specified, there is no minimum version. 19 | * @property maximumVersion The maximum version of the Kobweb CLI that should open this template. Should be in the 20 | * format "x.y.z" (e.g. "1.2.3"). If not specified, there is no maximum version. Including a maximum version can 21 | * allow us in the future to create alternate templates for different versions of the CLI. For example, let's say we 22 | * add a new feature in CLI 1.2.3 that we want to use in a template. We can set the existing (soon legacy) template 23 | * to max-version 1.2.2 and the new template to min-version 1.2.3. In this way, old CLIs will only see the old 24 | * template and new CLIs will only see the new one. 25 | */ 26 | @Serializable 27 | class Metadata( 28 | val description: String, 29 | val name: String? = null, 30 | val shouldHighlight: Boolean = false, 31 | val minimumVersion: String? = null, 32 | val maximumVersion: String? = null, 33 | ) 34 | 35 | @Serializable 36 | class KobwebTemplate( 37 | val metadata: Metadata, 38 | val instructions: List = emptyList(), 39 | ) 40 | 41 | private val Path.templateFile get() = this.resolve(".kobweb-template.yaml") 42 | 43 | class KobwebTemplateFile private constructor(val folder: Path = Path.of("")) { 44 | val path = folder.templateFile 45 | val template = Yaml.nonStrictDefault.decodeFromString( 46 | KobwebTemplate.serializer(), 47 | folder.templateFile.readBytes().toString(Charsets.UTF_8) 48 | ) 49 | 50 | companion object { 51 | fun inPath(path: Path): KobwebTemplateFile? { 52 | return try { 53 | if (path.templateFile.exists()) KobwebTemplateFile(path) else null 54 | } catch (_: Exception) { 55 | // Could happen due to serialization issues, e.g. incompatible format 56 | null 57 | } 58 | } 59 | 60 | fun isFoundIn(path: Path): Boolean = inPath(path) != null 61 | } 62 | } 63 | 64 | /** 65 | * Return the name for this template file. 66 | * 67 | * This is done either by finding it explicitly configured or else using some root directory as a way to extract the 68 | * name automatically from the template's path. 69 | */ 70 | fun KobwebTemplateFile.getName(rootPath: Path): String { 71 | return template.metadata.name ?: folder.relativeTo(rootPath).toString() 72 | // Even on Windows, show Unix-style slashes, as `kobweb create` expects that format 73 | .invariantSeparatorsPath 74 | } 75 | -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/com/varabyte/kobweb/cli/common/GitUtils.kt: -------------------------------------------------------------------------------- 1 | package com.varabyte.kobweb.cli.common 2 | 3 | import com.varabyte.kobweb.cli.common.kotter.informError 4 | import com.varabyte.kobweb.common.error.KobwebException 5 | import com.varabyte.kotter.runtime.Session 6 | import kotlinx.coroutines.CompletableDeferred 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.runBlocking 9 | import java.io.IOException 10 | import java.nio.file.Path 11 | import kotlin.io.path.absolutePathString 12 | 13 | fun Session.findGit(): GitClient? { 14 | return try { 15 | return GitClient() 16 | } catch (ex: KobwebException) { 17 | informError(ex.message!!) 18 | null 19 | } 20 | } 21 | 22 | private val GIT_VERSION_REGEX = Regex("""git.* ((\d+).(\d+).(\d+))""") 23 | 24 | sealed interface GitVersion { 25 | data class Parsed(val major: Int, val minor: Int, val patch: Int) : GitVersion 26 | 27 | // If we detect git (e.g. we're able to run the process), we should always move forward 28 | // if we can, even if we can't parse the version. 29 | data class Unparsed(val text: String) : GitVersion 30 | } 31 | 32 | class GitClient { 33 | val version: GitVersion = runBlocking(Dispatchers.IO) { 34 | val process = try { 35 | Runtime.getRuntime().git("version") 36 | } catch (ex: IOException) { 37 | throw KobwebException("git must be installed and present on the path") 38 | } 39 | val versionDeferred = CompletableDeferred() 40 | process.consumeProcessOutput { line, _ -> 41 | val result = GIT_VERSION_REGEX.find(line) 42 | versionDeferred.complete( 43 | if (result != null) { 44 | GitVersion.Parsed( 45 | result.groupValues[2].toInt(), 46 | result.groupValues[3].toInt(), 47 | result.groupValues[4].toInt(), 48 | ) 49 | } else { 50 | GitVersion.Unparsed(line) 51 | } 52 | ) 53 | } 54 | versionDeferred.await() 55 | } 56 | 57 | private fun git(vararg args: String) { 58 | Runtime.getRuntime().git(*args).waitFor() 59 | } 60 | 61 | fun clone( 62 | repo: String, 63 | branch: String?, 64 | into: Path, 65 | shallow: Boolean = true, 66 | ) { 67 | val args = mutableListOf("clone") 68 | if (shallow) { 69 | args.add("--depth") 70 | args.add("1") 71 | } 72 | args.add(repo) 73 | if (branch != null) { 74 | args.add("-b") 75 | args.add(branch) 76 | } 77 | args.add(into.absolutePathString()) 78 | 79 | git(*args.toTypedArray()) 80 | } 81 | 82 | fun init(rootDir: Path? = null) { 83 | val args = mutableListOf("init") 84 | if (rootDir != null) { 85 | args.add(rootDir.absolutePathString()) 86 | } 87 | 88 | git(*args.toTypedArray()) 89 | } 90 | 91 | fun add(filePattern: String, rootDir: Path? = null) { 92 | val args = mutableListOf("add", filePattern) 93 | if (rootDir != null) { 94 | args.addAll(0, listOf("-C", rootDir.absolutePathString())) 95 | } 96 | git(*args.toTypedArray()) 97 | } 98 | 99 | fun commit(message: String, rootDir: Path? = null) { 100 | val args = mutableListOf("commit", "-m", message) 101 | if (rootDir != null) { 102 | args.addAll(0, listOf("-C", rootDir.absolutePathString())) 103 | } 104 | git(*args.toTypedArray()) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/com/varabyte/kobweb/cli/stop/Stop.kt: -------------------------------------------------------------------------------- 1 | package com.varabyte.kobweb.cli.stop 2 | 3 | import com.github.ajalt.clikt.core.CliktError 4 | import com.varabyte.kobweb.cli.common.Anims 5 | import com.varabyte.kobweb.cli.common.KobwebExecutionEnvironment 6 | import com.varabyte.kobweb.cli.common.KobwebGradle 7 | import com.varabyte.kobweb.cli.common.findKobwebExecutionEnvironment 8 | import com.varabyte.kobweb.cli.common.isServerAlreadyRunning 9 | import com.varabyte.kobweb.cli.common.kotter.handleConsoleOutput 10 | import com.varabyte.kobweb.cli.common.kotter.informGradleStarting 11 | import com.varabyte.kobweb.cli.common.kotter.newline 12 | import com.varabyte.kobweb.cli.common.kotter.trySession 13 | import com.varabyte.kobweb.cli.common.kotter.warnFallingBackToPlainText 14 | import com.varabyte.kobweb.cli.common.waitForAndCheckForException 15 | import com.varabyte.kobweb.server.api.ServerEnvironment 16 | import com.varabyte.kotter.foundation.anim.textAnimOf 17 | import com.varabyte.kotter.foundation.liveVarOf 18 | import com.varabyte.kotter.foundation.text.textLine 19 | import com.varabyte.kotter.runtime.Session 20 | import java.io.File 21 | 22 | private enum class StopState { 23 | STOPPING, 24 | STOPPED, 25 | } 26 | 27 | fun handleStop(projectDir: File, useAnsi: Boolean, gradleArgsCommon: List, gradleArgsStop: List) { 28 | // Server environment doesn't really matter for "stop". Still, let's default to prod because that's usually the case 29 | // where a server is left running for a long time. 30 | findKobwebExecutionEnvironment( 31 | ServerEnvironment.PROD, 32 | projectDir.toPath(), 33 | useAnsi 34 | )?.use { kobwebExecutionEnvironment -> 35 | handleStop(useAnsi, kobwebExecutionEnvironment, gradleArgsCommon, gradleArgsStop) 36 | } 37 | } 38 | 39 | fun Session.handleStop(kobwebGradle: KobwebGradle) = handleStop(kobwebGradle, emptyList(), emptyList()) 40 | 41 | fun Session.handleStop( 42 | kobwebGradle: KobwebGradle, 43 | gradleArgsCommon: List, 44 | gradleArgsStop: List 45 | ) { 46 | val ellipsisAnim = textAnimOf(Anims.ELLIPSIS) 47 | var stopState by liveVarOf(StopState.STOPPING) 48 | section { 49 | textLine() // Add text line between this block and Gradle output above 50 | 51 | when (stopState) { 52 | StopState.STOPPING -> { 53 | textLine("Stopping a Kobweb server$ellipsisAnim") 54 | } 55 | 56 | StopState.STOPPED -> { 57 | textLine("Server was stopped.") 58 | } 59 | } 60 | }.run { 61 | kobwebGradle.onStarting = ::informGradleStarting 62 | val stopServerProcess = kobwebGradle.stopServer(gradleArgsCommon + gradleArgsStop) 63 | stopServerProcess.lineHandler = ::handleConsoleOutput 64 | stopServerProcess.waitFor() 65 | stopState = StopState.STOPPED 66 | } 67 | } 68 | 69 | private fun handleStop( 70 | useAnsi: Boolean, 71 | kobwebExecutionEnvironment: KobwebExecutionEnvironment, 72 | gradleArgsCommon: List, 73 | gradleArgsStop: List, 74 | ) { 75 | var runInPlainMode = !useAnsi 76 | val kobwebApplication = kobwebExecutionEnvironment.application 77 | val kobwebGradle = kobwebExecutionEnvironment.gradle 78 | 79 | if (useAnsi && !trySession { 80 | if (kobwebApplication.isServerAlreadyRunning()) { 81 | newline() // Put space between user prompt and eventual first line of Gradle output 82 | handleStop(kobwebGradle, gradleArgsCommon, gradleArgsStop) 83 | } else { 84 | section { 85 | textLine() 86 | textLine("Did not detect a running server.") 87 | }.run() 88 | } 89 | }) { 90 | warnFallingBackToPlainText() 91 | runInPlainMode = true 92 | } 93 | 94 | if (runInPlainMode) { 95 | if (!kobwebApplication.isServerAlreadyRunning()) { 96 | println("Did not detect a running server.") 97 | return 98 | } 99 | 100 | val stopFailed = kobwebGradle 101 | .stopServer(gradleArgsCommon + gradleArgsStop) 102 | .waitForAndCheckForException() != null 103 | 104 | if (stopFailed) { 105 | throw CliktError("Failed to stop a Kobweb server. Please check Gradle output and resolve any errors before retrying.") 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/com/varabyte/kobweb/cli/common/version/SemVer.kt: -------------------------------------------------------------------------------- 1 | package com.varabyte.kobweb.cli.common.version 2 | 3 | import kotlin.math.max 4 | 5 | /** 6 | * A simple SemVar parser. 7 | * 8 | * See also: https://semver.org/ 9 | * 10 | * Use [SemVer.tryParse] or [SemVer.parse] to create an instance. 11 | */ 12 | sealed interface SemVer { 13 | class Parsed(val major: Int, val minor: Int, val patch: Int, val preRelease: String? = null) : SemVer, Comparable { 14 | init { 15 | require(major >= 0) { "Major version must be >= 0" } 16 | require(minor >= 0) { "Minor version must be >= 0" } 17 | require(patch >= 0) { "Patch version must be >= 0" } 18 | } 19 | 20 | private fun comparePreReleaseIdentifiers(first: String?, second: String?): Int { 21 | return when { 22 | // If present vs. not present, the one with takes less precedence 23 | // e.g. 1.0.0-alpha < 1.0.0 24 | first != null && second == null -> -1 25 | first == null && second != null -> 1 26 | 27 | first != null && second != null -> { 28 | val firstParts = first.split('.') 29 | val secondParts = second.split('.') 30 | 31 | var compareResult = 0 32 | for (i in 0 .. max(firstParts.lastIndex, secondParts.lastIndex)) { 33 | // If here, one identifier has more parts than the other, but otherwise they are equal 34 | // For example: "1.0.0-alpha" vs "1.0.0-alpha.1" 35 | // In that case, the one with more parts is considered larger 36 | if (i > firstParts.lastIndex) { 37 | compareResult = -1 38 | } else if (i > secondParts.lastIndex) { 39 | compareResult = 1 40 | } 41 | 42 | if (compareResult != 0) break 43 | 44 | // Check individual parts for comparison, e.g. the "alpha" and "beta" in 45 | // "1.0.0-alpha.2" vs "1.0.0-beta.11" 46 | val firstPart = firstParts[i] 47 | val secondPart = secondParts[i] 48 | if (firstPart == secondPart) { 49 | continue 50 | } 51 | 52 | val firstPartAsInt = firstPart.toIntOrNull() 53 | val secondPartAsInt = secondPart.toIntOrNull() 54 | 55 | compareResult = if (firstPartAsInt != null && secondPartAsInt != null) { 56 | firstPartAsInt.compareTo(secondPartAsInt) 57 | } else { 58 | firstPart.compareTo(secondPart) 59 | } 60 | break 61 | } 62 | compareResult 63 | } 64 | else -> 0 65 | } 66 | } 67 | 68 | override fun compareTo(other: Parsed): Int { 69 | if (major != other.major) return major.compareTo(other.major) 70 | if (minor != other.minor) return minor.compareTo(other.minor) 71 | if (patch != other.patch) return patch.compareTo(other.patch) 72 | 73 | return comparePreReleaseIdentifiers(preRelease, other.preRelease) 74 | } 75 | 76 | override fun toString(): String = "$major.$minor.$patch" + preRelease?.let { "-$it" }.orEmpty() 77 | } 78 | 79 | class Unparsed(val text: String) : SemVer { 80 | override fun toString() = text 81 | } 82 | 83 | companion object { 84 | /** 85 | * Attempt to parse a simple SemVer string, returning null otherwise. 86 | * 87 | * Note that this is a very simple parser, and doesn't support pre-release suffixes (outside of just returning 88 | * it as a raw string). 89 | */ 90 | fun tryParse(versionStr: String): Parsed? { 91 | val (versionPart, preReleasePart) = versionStr.split('-', limit = 2).let { parts -> 92 | // There may not be a pre-release suffix... 93 | parts[0] to parts.getOrNull(1) 94 | } 95 | 96 | val versionParts = versionPart.split('.') 97 | if (versionParts.size != 3) { 98 | return null 99 | } 100 | return try { 101 | Parsed( 102 | major = versionParts[0].toIntOrNull() ?: return null, 103 | minor = versionParts[1].toIntOrNull() ?: return null, 104 | patch = versionParts[2].toIntOrNull() ?: return null, 105 | preRelease = preReleasePart, 106 | ) 107 | } catch (_: IllegalArgumentException) { 108 | null 109 | } 110 | } 111 | 112 | /** 113 | * Like [tryParse] but return an [Unparsed] result as a fallback. 114 | */ 115 | fun tryParseOrUnparsed(versionStr: String): SemVer = tryParse(versionStr) ?: Unparsed(versionStr) 116 | 117 | /** 118 | * Like [tryParse] but for when you are confident that [versionStr] is a valid SemVer value. 119 | */ 120 | fun parse(versionStr: String): Parsed = tryParse(versionStr) ?: throw IllegalArgumentException("Invalid semver value: $versionStr") 121 | } 122 | } 123 | 124 | val SemVer.Parsed.isSnapshot: Boolean 125 | get() = preRelease == "SNAPSHOT" 126 | 127 | fun SemVer.Parsed.withoutPreRelease() = if (this.preRelease == null) this else SemVer.Parsed(major, minor, patch) -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/com/varabyte/kobweb/cli/common/template/Instructions.kt: -------------------------------------------------------------------------------- 1 | package com.varabyte.kobweb.cli.common.template 2 | 3 | import com.varabyte.kobweb.cli.create.freemarker.FreemarkerState 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | 7 | /** 8 | * Disable directory dot operations, e.g. "test/../../../../system" 9 | * to prevent template commands from escaping out of their root directories. 10 | */ 11 | private fun String.requireNoDirectoryDots() { 12 | require(this.split("/").none { it == "." || it == ".." }) 13 | } 14 | 15 | /** 16 | * The base class for all instructions. Note that an instruction can be skipped if its condition evaluates to false. 17 | * 18 | * @param condition A value that should ultimately evaluate to "true" or "false". If "false", the instruction will be 19 | * skipped. This value will be processed by freemarker and can be dynamic! 20 | */ 21 | @Serializable 22 | sealed class Instruction( 23 | val condition: String? = null, 24 | ) { 25 | /** 26 | * A collection of related instructions, useful for example when a single condition applies to two or more 27 | * instructions. 28 | */ 29 | @Serializable 30 | @SerialName("Group") 31 | class Group( 32 | val instructions: List 33 | ) : Instruction() 34 | 35 | /** 36 | * Inform the user about something. 37 | * 38 | * @param message The message to show to the user. This value will be processed by freemarker and can be dynamic! 39 | */ 40 | @Serializable 41 | @SerialName("Inform") 42 | class Inform( 43 | val message: String, 44 | ) : Instruction() 45 | 46 | /** 47 | * Prompt the user to specify a value for a variable. 48 | * 49 | * @param name The name of this variable, which can be referenced in freemarker expressions later. 50 | * @param prompt The prompt to show the user. 51 | * @param note If set, added as extra contextual information after the the prompt but before the area where the user 52 | * types their answer. 53 | * @param default The default value to use if nothing is typed. This value will be processed by freemarker and can 54 | * be dynamic! 55 | * @param validation One of a set of built-in Kobweb validators. See the "Validators" region inside 56 | * [FreemarkerState.model] for the list. 57 | * @param transform Logic to convert a user's answer before assigning it to a variable, e.g. "Yes" -> "true". 58 | * An automatic variable called "value" will be provided for the scope of this function. See the "Converters" 59 | * region inside [FreemarkerState.model] for the list. 60 | */ 61 | @Serializable 62 | @SerialName("QueryVar") 63 | class QueryVar( 64 | val name: String, 65 | val prompt: String, 66 | val note: String? = null, 67 | val default: String? = null, 68 | val validation: String? = null, 69 | val transform: String? = null, 70 | ) : Instruction() 71 | 72 | /** 73 | * Directly define a variable, useful if the user already defined another variable elsewhere and this is just a 74 | * transformation on top of it. 75 | * 76 | * @param name The name of this variable, which can be referenced in freemarker expressions later. 77 | * @param value The value of the variable. This value will be processed by freemarker and can be dynamic! 78 | */ 79 | @Serializable 80 | @SerialName("DefineVar") 81 | class DefineVar( 82 | val name: String, 83 | val value: String, 84 | ) : Instruction() 85 | 86 | /** 87 | * Search the project for all files that end in ".ftl", process them, and discard them. 88 | */ 89 | @Serializable 90 | @SerialName("ProcessFreemarker") 91 | class ProcessFreemarker : Instruction() 92 | 93 | /** 94 | * Move files and/or folders to a destination directory. 95 | * 96 | * This instruction does not change the name of any of the files. Use the [Rename] instruction first if you need to 97 | * accomplish that. 98 | * 99 | * @param from The files to copy. This can use standard wildcard syntax, e.g. "*.txt" and "a/b/**/README.md" 100 | * @param to The directory location to copy to. This value will be processed by freemarker and can be dynamic! 101 | * @param description An optional description to show to users, if set, instead of the default message, which 102 | * may be too detailed. 103 | */ 104 | @Serializable 105 | @SerialName("Move") 106 | class Move( 107 | val from: String, 108 | val to: String, 109 | val description: String? = null, 110 | ) : Instruction() { 111 | init { 112 | from.requireNoDirectoryDots() 113 | to.requireNoDirectoryDots() 114 | } 115 | } 116 | 117 | /** 118 | * Rename a file in place. 119 | * 120 | * @param name The new filename. This value will be processed by freemarker and can be dynamic! 121 | * @param description An optional description to show to users, if set, instead of the default message, which 122 | * may be too detailed. 123 | */ 124 | @Serializable 125 | @SerialName("Rename") 126 | class Rename( 127 | val file: String, 128 | val name: String, 129 | val description: String? = null, 130 | ) : Instruction() { 131 | init { 132 | file.requireNoDirectoryDots() 133 | require(name.contains("/")) { "Rename value must be a filename only without directories. " } 134 | } 135 | } 136 | 137 | /** 138 | * Specify files for deletion. Directories will be deleted recursively. 139 | * 140 | * @param files The list of files to delete 141 | * @param description An optional description to show to users, if set, instead of the default message. 142 | */ 143 | @Serializable 144 | @SerialName("Delete") 145 | class Delete( 146 | val files: String, 147 | val description: String? = null, 148 | ) : Instruction() { 149 | init { 150 | files.requireNoDirectoryDots() 151 | } 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /kobweb/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jreleaser.model.Active 2 | 3 | plugins { 4 | kotlin("jvm") 5 | kotlin("plugin.serialization") 6 | application 7 | alias(libs.plugins.jreleaser) 8 | alias(libs.plugins.shadow) 9 | } 10 | 11 | group = "com.varabyte.kobweb.cli" 12 | version = libs.versions.kobweb.cli.get() 13 | 14 | repositories { 15 | // For Gradle Tooling API 16 | maven { url = uri("https://repo.gradle.org/gradle/libs-releases") } 17 | // TODO: Remove this repository once clikt-core module is published https://github.com/ajalt/clikt/issues/523 18 | maven { 19 | url = uri("https://oss.sonatype.org/content/repositories/snapshots/") 20 | } 21 | } 22 | 23 | dependencies { 24 | implementation(kotlin("stdlib")) 25 | implementation(libs.clikt.core) 26 | implementation(libs.kotlinx.coroutines) 27 | implementation(libs.kotter) 28 | implementation(libs.freemarker) 29 | implementation(libs.okhttp) 30 | implementation(libs.kobweb.common) 31 | 32 | // For Gradle Tooling API (used for starting up / communicating with a gradle daemon) 33 | implementation("org.gradle:gradle-tooling-api:${gradle.gradleVersion}") 34 | runtimeOnly("org.slf4j:slf4j-nop:2.0.6") // Needed by gradle tooling 35 | } 36 | 37 | application { 38 | applicationDefaultJvmArgs = listOf( 39 | "-Dkobweb.version=${version}", 40 | // JDK24 started reporting warnings for libraries that use protected native methods, at least one (System.load) 41 | // which Kotter uses (via jline/jansi). Since Java fat jars built by Kotlin don't really use Java's module 42 | // system, we unfortunately have to whitelist all unnamed modules. We also enable the 43 | // IgnoreUnrecognizedVMOptions flag to avoid causing users running older versions of the JVM to crash. 44 | // See also: https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/lang/doc-files/RestrictedMethods.html 45 | "-XX:+IgnoreUnrecognizedVMOptions", 46 | "--enable-native-access=ALL-UNNAMED", 47 | ) 48 | mainClass.set("MainKt") 49 | } 50 | 51 | // Useful for CLI 52 | tasks.register("printVersion") { 53 | doLast { 54 | println(version.toString()) 55 | } 56 | } 57 | 58 | // We used to minimize this jar, but it kept causing us problems. At our most recent check a minimized jar was 11MB vs. 59 | // 12MB not minimized. It's just not worth the surprising crashes! 60 | // Just in case we decide to minimize again someday, here's what we were doing. 61 | //tasks.withType().configureEach { 62 | // minimize { 63 | // // Leave Jansi deps in place, or else Windows won't work 64 | // exclude(dependency("org.fusesource.jansi:.*:.*")) 65 | // exclude(dependency("org.jline:jline-terminal-jansi:.*")) 66 | // // Leave SLF4J in place, or else a warning is spit out 67 | // exclude(dependency("org.slf4j.*:.*:.*")) 68 | // } 69 | //} 70 | 71 | distributions { 72 | named("shadow") { 73 | // We choose to make the output names of "assembleShadowDist" the same as "assembleDist" here, since ideally 74 | // they should be interchangeable (the shadow version just has dead code removed). However, this means if you 75 | // run "assembleDist" and then "assembleShadowDist" (or the other way around), the latter command will overwrite 76 | // the output of the prior one. 77 | distributionBaseName.set("kobweb") 78 | } 79 | } 80 | 81 | // Avoid ambiguity / add clarity in generated artifacts 82 | tasks.jar { 83 | archiveFileName.set("kobweb-cli.jar") 84 | } 85 | 86 | // These values are specified in ~/.gradle/gradle.properties; otherwise sorry, no jreleasing for you :P 87 | val (githubUsername, githubToken) = listOf("varabyte.github.username", "varabyte.github.token") 88 | .map { key -> findProperty(key) as? String } 89 | 90 | if (githubUsername != null && githubToken != null) { 91 | // Read about JReleaser at https://jreleaser.org/guide/latest/index.html 92 | jreleaser { 93 | val isDryRun = (findProperty("kobweb.cli.jreleaser.dryrun") as? String)?.toBoolean() ?: true 94 | dryrun.set(isDryRun) // Specified explicitly for convenience - set dryrun to false when ready to publish! 95 | gitRootSearch.set(true) 96 | dependsOnAssemble.set(false) // We pre-assemble ourselves (using shadow jar) 97 | 98 | project { 99 | links { 100 | homepage.set("https://kobweb.varabyte.com/") 101 | documentation.set("https://kobweb.varabyte.com/docs") 102 | license.set("http://www.apache.org/licenses/LICENSE-2.0") 103 | bugTracker.set("https://github.com/varabyte/kobweb/issues") 104 | } 105 | description.set("Set up and manage your Kobweb (Compose HTML) app") 106 | longDescription.set( 107 | """ 108 | Kobweb CLI provides commands to handle the tedious parts of building a Kobweb (Compose HTML) app, 109 | including project setup and configuration. 110 | """.trimIndent() 111 | ) 112 | vendor.set("Varabyte") 113 | authors.set(listOf("David Herman")) 114 | license.set("Apache-2.0") 115 | copyright.set("Copyright © 2024 Varabyte. All rights reserved.") 116 | 117 | // Set the Java version explicitly, even though in theory this value should be coming from our root 118 | // build.gradle file, but it does not seem to when I run "jreleaserPublish" from the command line. 119 | // See also: https://github.com/jreleaser/jreleaser/issues/785 120 | java { 121 | version.set(JavaVersion.VERSION_11.toString()) 122 | } 123 | } 124 | release { 125 | github { 126 | repoOwner.set("varabyte") 127 | tagName.set("v{{projectVersion}}") 128 | username.set(githubUsername) 129 | token.set(githubToken) 130 | 131 | // Tags and releases are handled manually via the GitHub UI for now. 132 | // TODO(https://github.com/varabyte/kobweb/issues/104) 133 | skipTag.set(true) 134 | skipRelease.set(true) 135 | 136 | overwrite.set(true) 137 | uploadAssets.set(Active.RELEASE) 138 | commitAuthor { 139 | name.set("David Herman") 140 | email.set("bitspittle@gmail.com") 141 | } 142 | changelog { 143 | enabled.set(false) 144 | } 145 | milestone { 146 | // milestone management handled manually for now 147 | close.set(false) 148 | } 149 | prerelease { 150 | enabled.set(false) 151 | } 152 | } 153 | } 154 | packagers { 155 | brew { 156 | active.set(Active.RELEASE) 157 | templateDirectory.set(File("jreleaser/templates/brew")) 158 | // The following changes the line `depends_on "openjdk@11"` to `depends_on "openjdk" 159 | // See also: https://jreleaser.org/guide/latest/reference/packagers/homebrew.html#_jdk_dependency 160 | extraProperties.put("useVersionedJava", false) 161 | } 162 | scoop { 163 | active.set(Active.RELEASE) 164 | } 165 | 166 | val (key, token) = listOf(findProperty("sdkman.key") as? String, findProperty("sdkman.token") as? String) 167 | if (key != null && token != null) { 168 | sdkman { 169 | consumerKey.set(key) 170 | consumerToken.set(token) 171 | active.set(Active.RELEASE) 172 | } 173 | } else { 174 | println("SDKMAN! packager disabled on this machine since key and/or token are not defined") 175 | } 176 | } 177 | 178 | distributions { 179 | create("kobweb") { 180 | listOf("zip", "tar").forEach { artifactExtension -> 181 | artifact { 182 | setPath("build/distributions/{{distributionName}}-{{projectVersion}}.$artifactExtension") 183 | } 184 | } 185 | } 186 | } 187 | } 188 | } else { 189 | println( 190 | """ 191 | NOTE: JReleaser disabled for this machine due to missing github username and/or token properties. 192 | This is expected (unless you intentionally configured these values). 193 | """.trimIndent() 194 | ) 195 | } 196 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH="\\\"\\\"" 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | if ! command -v java >/dev/null 2>&1 137 | then 138 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 139 | 140 | Please set the JAVA_HOME variable in your environment to match the 141 | location of your Java installation." 142 | fi 143 | fi 144 | 145 | # Increase the maximum file descriptors if we can. 146 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 147 | case $MAX_FD in #( 148 | max*) 149 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 150 | # shellcheck disable=SC2039,SC3045 151 | MAX_FD=$( ulimit -H -n ) || 152 | warn "Could not query maximum file descriptor limit" 153 | esac 154 | case $MAX_FD in #( 155 | '' | soft) :;; #( 156 | *) 157 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 158 | # shellcheck disable=SC2039,SC3045 159 | ulimit -n "$MAX_FD" || 160 | warn "Could not set maximum file descriptor limit to $MAX_FD" 161 | esac 162 | fi 163 | 164 | # Collect all arguments for the java command, stacking in reverse order: 165 | # * args from the command line 166 | # * the main class name 167 | # * -classpath 168 | # * -D...appname settings 169 | # * --module-path (only if needed) 170 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 171 | 172 | # For Cygwin or MSYS, switch paths to Windows format before running java 173 | if "$cygwin" || "$msys" ; then 174 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 175 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 176 | 177 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 178 | 179 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 180 | for arg do 181 | if 182 | case $arg in #( 183 | -*) false ;; # don't mess with options #( 184 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 185 | [ -e "$t" ] ;; #( 186 | *) false ;; 187 | esac 188 | then 189 | arg=$( cygpath --path --ignore --mixed "$arg" ) 190 | fi 191 | # Roll the args list around exactly as many times as the number of 192 | # args, so each arg winds up back in the position where it started, but 193 | # possibly modified. 194 | # 195 | # NB: a `for` loop captures its iteration list before it begins, so 196 | # changing the positional parameters here affects neither the number of 197 | # iterations, nor the values presented in `arg`. 198 | shift # remove old arg 199 | set -- "$@" "$arg" # push replacement arg 200 | done 201 | fi 202 | 203 | 204 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 205 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 206 | 207 | # Collect all arguments for the java command: 208 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 209 | # and any embedded shellness will be escaped. 210 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 211 | # treated as '${Hostname}' itself on the command line. 212 | 213 | set -- \ 214 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 215 | -classpath "$CLASSPATH" \ 216 | -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ 217 | "$@" 218 | 219 | # Stop when "xargs" is not available. 220 | if ! command -v xargs >/dev/null 2>&1 221 | then 222 | die "xargs is not available" 223 | fi 224 | 225 | # Use "xargs" to parse quoted args. 226 | # 227 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 228 | # 229 | # In Bash we could simply go: 230 | # 231 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 232 | # set -- "${ARGS[@]}" "$@" 233 | # 234 | # but POSIX shell has neither arrays nor command substitution, so instead we 235 | # post-process each arg (as a line of input to sed) to backslash-escape any 236 | # character that might be a shell metacharacter, then use eval to reverse 237 | # that process (while maintaining the separation between arguments), and wrap 238 | # the whole thing up as a single "set" statement. 239 | # 240 | # This will of course break if any of these variables contains a newline or 241 | # an unmatched quote. 242 | # 243 | 244 | eval "set -- $( 245 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 246 | xargs -n1 | 247 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 248 | tr '\n' ' ' 249 | )" '"$@"' 250 | 251 | exec "$JAVACMD" "$@" 252 | -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/com/varabyte/kobweb/cli/export/Export.kt: -------------------------------------------------------------------------------- 1 | package com.varabyte.kobweb.cli.export 2 | 3 | import com.github.ajalt.clikt.core.CliktError 4 | import com.varabyte.kobweb.cli.common.Anims 5 | import com.varabyte.kobweb.cli.common.Globals 6 | import com.varabyte.kobweb.cli.common.GradleAlertBundle 7 | import com.varabyte.kobweb.cli.common.KobwebExecutionEnvironment 8 | import com.varabyte.kobweb.cli.common.ProgramArgsKey 9 | import com.varabyte.kobweb.cli.common.assertServerNotAlreadyRunning 10 | import com.varabyte.kobweb.cli.common.findKobwebExecutionEnvironment 11 | import com.varabyte.kobweb.cli.common.handleGradleOutput 12 | import com.varabyte.kobweb.cli.common.isServerAlreadyRunningFor 13 | import com.varabyte.kobweb.cli.common.kotter.chooseFromList 14 | import com.varabyte.kobweb.cli.common.kotter.handleConsoleOutput 15 | import com.varabyte.kobweb.cli.common.kotter.informGradleStarting 16 | import com.varabyte.kobweb.cli.common.kotter.informInfo 17 | import com.varabyte.kobweb.cli.common.kotter.newline 18 | import com.varabyte.kobweb.cli.common.kotter.trySession 19 | import com.varabyte.kobweb.cli.common.kotter.warnFallingBackToPlainText 20 | import com.varabyte.kobweb.cli.common.relativeToCurrentDirectory 21 | import com.varabyte.kobweb.cli.common.showStaticSiteLayoutWarning 22 | import com.varabyte.kobweb.cli.common.waitForAndCheckForException 23 | import com.varabyte.kobweb.server.api.ServerEnvironment 24 | import com.varabyte.kobweb.server.api.SiteLayout 25 | import com.varabyte.kotter.foundation.anim.textAnimOf 26 | import com.varabyte.kotter.foundation.input.Keys 27 | import com.varabyte.kotter.foundation.input.onKeyPressed 28 | import com.varabyte.kotter.foundation.liveVarOf 29 | import com.varabyte.kotter.foundation.text.cyan 30 | import com.varabyte.kotter.foundation.text.red 31 | import com.varabyte.kotter.foundation.text.text 32 | import com.varabyte.kotter.foundation.text.textLine 33 | import com.varabyte.kotter.foundation.text.yellow 34 | import com.varabyte.kotter.runtime.Session 35 | import java.io.File 36 | 37 | private enum class ExportState { 38 | EXPORTING, 39 | FINISHING, 40 | FINISHED, 41 | CANCELLING, 42 | CANCELLED, 43 | INTERRUPTED, 44 | } 45 | 46 | // Query the export layout if the user didn't pass it in explicitly using `--layout $layout` 47 | private fun Session.queryUserForSiteLayout(): SiteLayout? { 48 | return chooseFromList( 49 | "Specify what kind of export layout you want to use.", 50 | SiteLayout.entries.toList(), 51 | itemToString = { @Suppress("DEPRECATION") it.name.lowercase().capitalize() }, 52 | produceInitialIndex = { SiteLayout.entries.indexOf(SiteLayout.STATIC) } 53 | ) { selectedLayout -> 54 | when (selectedLayout) { 55 | SiteLayout.FULLSTACK -> "Use for a project that provides both frontend (js) and backend (jvm) code." 56 | SiteLayout.STATIC -> "Use for a project that only provides only frontend (js) code (no backend) and whose output is compatible with static site hosting providers." 57 | } 58 | }?.also { chosenLayout -> 59 | newline() 60 | informInfo { 61 | val argsCopy = Globals.getValue(ProgramArgsKey).toMutableList() 62 | // Add layout args immediately after command (i.e. "export") 63 | argsCopy.addAll(1, listOf("--layout", chosenLayout.name.lowercase())) 64 | 65 | text("Running: ") 66 | cyan { text("kobweb ${argsCopy.joinToString(" ")}") } 67 | 68 | Globals[ProgramArgsKey] = argsCopy.toTypedArray() 69 | } 70 | } 71 | } 72 | 73 | fun handleExport( 74 | projectDir: File, 75 | siteLayout: SiteLayout?, 76 | useAnsi: Boolean, 77 | gradleArgsCommon: List, 78 | gradleArgsExport: List, 79 | gradleArgsStop: List 80 | ) { 81 | // exporting is a production-only action 82 | findKobwebExecutionEnvironment( 83 | ServerEnvironment.PROD, 84 | projectDir.toPath(), 85 | useAnsi 86 | )?.use { kobwebExecutionEnvironment -> 87 | handleExport( 88 | siteLayout, 89 | useAnsi, 90 | kobwebExecutionEnvironment, 91 | gradleArgsCommon, 92 | gradleArgsExport, 93 | gradleArgsStop 94 | ) 95 | } 96 | } 97 | 98 | private fun handleExport( 99 | siteLayout: SiteLayout?, 100 | useAnsi: Boolean, 101 | kobwebExecutionEnvironment: KobwebExecutionEnvironment, 102 | gradleArgsCommon: List, 103 | gradleArgsExport: List, 104 | gradleArgsStop: List 105 | ) { 106 | val kobwebApplication = kobwebExecutionEnvironment.application 107 | val kobwebGradle = kobwebExecutionEnvironment.gradle 108 | 109 | var runInPlainMode = !useAnsi 110 | 111 | if (useAnsi && !trySession { 112 | if (isServerAlreadyRunningFor(kobwebApplication, kobwebGradle)) return@trySession 113 | 114 | val siteLayout = siteLayout ?: queryUserForSiteLayout() ?: return@trySession 115 | 116 | newline() // Put space between user prompt and eventual first line of Gradle output 117 | 118 | if (siteLayout.isStatic) { 119 | showStaticSiteLayoutWarning() 120 | } 121 | 122 | var exportState by liveVarOf(ExportState.EXPORTING) 123 | val gradleAlertBundle = GradleAlertBundle(this) 124 | 125 | var cancelReason by liveVarOf("") 126 | val ellipsis = textAnimOf(Anims.ELLIPSIS) 127 | var exception by liveVarOf(null) // Set if ExportState.INTERRUPTED 128 | section { 129 | textLine() // Add space between this block and Gradle text which will appear above 130 | gradleAlertBundle.renderInto(this) 131 | when (exportState) { 132 | ExportState.EXPORTING -> textLine("Exporting$ellipsis") 133 | ExportState.FINISHING -> textLine("Finishing up$ellipsis") 134 | ExportState.FINISHED -> { 135 | textLine("Export finished successfully.") 136 | textLine() 137 | 138 | text("You can run ") 139 | cyan { 140 | text( 141 | buildString { 142 | append("kobweb run") 143 | kobwebApplication.path.relativeToCurrentDirectory()?.takeUnless { it.toString().isBlank() }?.let { relativePath -> 144 | append(" -p $relativePath") 145 | } 146 | append(" --layout ${siteLayout.name.lowercase()}") 147 | append(" --env prod") 148 | } 149 | ) 150 | } 151 | textLine(" to preview your site.") 152 | } 153 | ExportState.CANCELLING -> yellow { textLine("Cancelling export: $cancelReason$ellipsis") } 154 | ExportState.CANCELLED -> yellow { textLine("Export cancelled: $cancelReason") } 155 | ExportState.INTERRUPTED -> { 156 | red { textLine("Interrupted by exception:") } 157 | textLine() 158 | textLine(exception!!.stackTraceToString()) 159 | } 160 | } 161 | }.run { 162 | kobwebGradle.onStarting = ::informGradleStarting 163 | 164 | val exportProcess = try { 165 | kobwebGradle.export(siteLayout, gradleArgsCommon + gradleArgsExport) 166 | } catch (ex: Exception) { 167 | exception = ex 168 | exportState = ExportState.INTERRUPTED 169 | return@run 170 | } 171 | exportProcess.lineHandler = { line, isError -> 172 | handleGradleOutput(line, isError) { alert -> gradleAlertBundle.handleAlert(alert) } 173 | } 174 | 175 | onKeyPressed { 176 | if (exportState == ExportState.EXPORTING && (key == Keys.Q || key == Keys.Q_UPPER)) { 177 | cancelReason = "User requested cancellation" 178 | exportProcess.cancel() 179 | exportState = ExportState.CANCELLING 180 | } else { 181 | gradleAlertBundle.handleKey(key) 182 | } 183 | } 184 | 185 | if (exportProcess.waitForAndCheckForException() != null) { 186 | if (exportState != ExportState.CANCELLING) { 187 | cancelReason = 188 | "Server failed to build. Please check Gradle output and fix the errors before retrying." 189 | exportState = ExportState.CANCELLING 190 | } 191 | } 192 | if (exportState == ExportState.EXPORTING) { 193 | exportState = ExportState.FINISHING 194 | } 195 | check(exportState in listOf(ExportState.FINISHING, ExportState.CANCELLING)) 196 | 197 | val stopProcess = kobwebGradle.stopServer(gradleArgsCommon + gradleArgsStop) 198 | stopProcess.lineHandler = ::handleConsoleOutput 199 | stopProcess.waitFor() 200 | 201 | exportState = if (exportState == ExportState.FINISHING) ExportState.FINISHED else ExportState.CANCELLED 202 | } 203 | }) { 204 | warnFallingBackToPlainText() 205 | runInPlainMode = true 206 | } 207 | 208 | if (runInPlainMode) { 209 | kobwebApplication.assertServerNotAlreadyRunning() 210 | 211 | val exportFailed = kobwebGradle 212 | // default to fullstack for legacy reasons 213 | .export(siteLayout ?: SiteLayout.FULLSTACK, gradleArgsCommon + gradleArgsExport) 214 | .waitForAndCheckForException() != null 215 | 216 | kobwebGradle.stopServer(gradleArgsCommon + gradleArgsStop).waitFor() 217 | 218 | if (exportFailed) throw CliktError("Export failed. Please check Gradle output and resolve any errors before retrying.") 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/com/varabyte/kobweb/cli/common/kotter/KotterUtils.kt: -------------------------------------------------------------------------------- 1 | package com.varabyte.kobweb.cli.common.kotter 2 | 3 | import com.varabyte.kobweb.cli.common.Anims 4 | import com.varabyte.kobweb.cli.common.KobwebGradle 5 | import com.varabyte.kobweb.cli.common.Validations 6 | import com.varabyte.kotter.foundation.anim.text 7 | import com.varabyte.kotter.foundation.anim.textAnimOf 8 | import com.varabyte.kotter.foundation.input.Completions 9 | import com.varabyte.kotter.foundation.input.Keys 10 | import com.varabyte.kotter.foundation.input.input 11 | import com.varabyte.kotter.foundation.input.onInputChanged 12 | import com.varabyte.kotter.foundation.input.onInputEntered 13 | import com.varabyte.kotter.foundation.input.onKeyPressed 14 | import com.varabyte.kotter.foundation.input.runUntilInputEntered 15 | import com.varabyte.kotter.foundation.liveVarOf 16 | import com.varabyte.kotter.foundation.render.aside 17 | import com.varabyte.kotter.foundation.runUntilSignal 18 | import com.varabyte.kotter.foundation.session 19 | import com.varabyte.kotter.foundation.text.black 20 | import com.varabyte.kotter.foundation.text.bold 21 | import com.varabyte.kotter.foundation.text.cyan 22 | import com.varabyte.kotter.foundation.text.green 23 | import com.varabyte.kotter.foundation.text.invert 24 | import com.varabyte.kotter.foundation.text.red 25 | import com.varabyte.kotter.foundation.text.text 26 | import com.varabyte.kotter.foundation.text.textLine 27 | import com.varabyte.kotter.foundation.text.yellow 28 | import com.varabyte.kotter.runtime.RunScope 29 | import com.varabyte.kotter.runtime.Session 30 | import com.varabyte.kotter.runtime.render.RenderScope 31 | 32 | private enum class ProcessingState { 33 | IN_PROGRESS, 34 | FAILED, 35 | SUCCEEDED 36 | } 37 | 38 | fun RenderScope.cmd(name: String) { 39 | val parts = name.split(' ') 40 | parts.forEachIndexed { i, part -> 41 | if (i == 0) { 42 | cyan { text(part) } 43 | } else { 44 | text(part) 45 | } 46 | if (i < parts.lastIndex) { 47 | text(' ') 48 | } 49 | } 50 | } 51 | 52 | fun Session.processing(message: String, blockingWork: () -> Unit): Boolean { 53 | val spinner = textAnimOf(Anims.SPINNER) 54 | val ellipsis = textAnimOf(Anims.ELLIPSIS) 55 | var state by liveVarOf(ProcessingState.IN_PROGRESS) 56 | section { 57 | when (state) { 58 | ProcessingState.IN_PROGRESS -> yellow { text(spinner) } 59 | ProcessingState.FAILED -> red { text("✗") } 60 | ProcessingState.SUCCEEDED -> green { text("✓") } 61 | } 62 | 63 | text(' ') 64 | text(message) 65 | 66 | when (state) { 67 | ProcessingState.IN_PROGRESS -> text(ellipsis) 68 | ProcessingState.FAILED -> textLine("${Anims.ELLIPSIS.frames.last()} Failed.") 69 | ProcessingState.SUCCEEDED -> textLine("${Anims.ELLIPSIS.frames.last()} Done!") 70 | } 71 | }.run { 72 | state = try { 73 | blockingWork() 74 | ProcessingState.SUCCEEDED 75 | } catch (ex: Exception) { 76 | ex.printStackTrace() 77 | ProcessingState.FAILED 78 | } 79 | } 80 | 81 | return state == ProcessingState.SUCCEEDED 82 | } 83 | 84 | fun RenderScope.textErrorPrefix() { 85 | red { text("✗") } 86 | text(' ') 87 | } 88 | 89 | fun RenderScope.textError(message: String) { 90 | textErrorPrefix() 91 | textLine(message) 92 | } 93 | 94 | fun Session.informError(block: RenderScope.() -> Unit) { 95 | section { 96 | textErrorPrefix() 97 | block() 98 | }.run() 99 | } 100 | 101 | fun Session.informError(message: String) { 102 | informError { textLine(message) } 103 | } 104 | 105 | fun RenderScope.textInfoPrefix() { 106 | yellow { text('!') } 107 | text(' ') 108 | } 109 | 110 | // Note: Newlines in text will create multiple "!" lines 111 | fun RenderScope.textInfo(message: String) { 112 | message.split("\n").forEach {line -> 113 | textInfoPrefix() 114 | textLine(line) 115 | } 116 | } 117 | 118 | fun Session.informInfo(block: RenderScope.() -> Unit) { 119 | section { 120 | textInfoPrefix() 121 | this.block() 122 | }.run() 123 | } 124 | 125 | // Note: Newlines in text will create multiple "!" lines 126 | fun Session.informInfo(message: String) { 127 | section { 128 | textInfo(message) 129 | }.run() 130 | } 131 | 132 | fun Session.warn(message: String) { 133 | section { 134 | yellow { textLine(message) } 135 | }.run() 136 | } 137 | 138 | fun RenderScope.textQuestionPrefix() { 139 | cyan { text('?') } 140 | text(' ') 141 | } 142 | 143 | private fun RenderScope.promptQuestion(query: String, extra: RenderScope.() -> Unit = {}) { 144 | textQuestionPrefix() 145 | bold { textLine("$query ") } 146 | extra() 147 | text("> ") 148 | } 149 | 150 | fun Session.askYesNo( 151 | query: String, 152 | defaultAnswer: Boolean = true, 153 | ): Boolean { 154 | return askYesNo(query, null, defaultAnswer) 155 | } 156 | 157 | fun Session.askYesNo( 158 | query: String, 159 | note: String?, 160 | defaultAnswer: Boolean = true 161 | ): Boolean { 162 | var answer by liveVarOf(defaultAnswer) 163 | section { 164 | promptQuestion(query) { 165 | note?.let { textInfo(it) } 166 | } 167 | yesNo(answer, defaultAnswer) 168 | textLine() 169 | }.runUntilSignal { 170 | onYesNoChanged(valueOnCancel = null) { 171 | answer = isYes 172 | if (shouldAccept) signal() 173 | } 174 | } 175 | return answer 176 | } 177 | 178 | /** 179 | * @param validateAnswer Take a string (representing a user's answer), returning a new string which represents an error 180 | * message, or null if no error. 181 | */ 182 | fun Session.queryUser( 183 | query: String, 184 | defaultAnswer: String?, 185 | validateAnswer: (String) -> String? = Validations::isNotEmpty 186 | ): String { 187 | return queryUser(query, null, defaultAnswer, validateAnswer) 188 | } 189 | 190 | fun Session.queryUser( 191 | query: String, 192 | note: String?, 193 | defaultAnswer: String?, 194 | validateAnswer: (String) -> String? = Validations::isNotEmpty 195 | ): String { 196 | var answer by liveVarOf("") 197 | var error by liveVarOf(null) 198 | section { 199 | promptQuestion(query) { 200 | note?.let { textInfo(it) } 201 | } 202 | if (answer.isNotEmpty()) { 203 | textLine(answer) 204 | } else { 205 | input(defaultAnswer?.let { Completions(it) }) 206 | textLine() 207 | error?.let { error -> 208 | scopedState { 209 | red() 210 | invert() 211 | textLine(error) 212 | } 213 | } 214 | } 215 | textLine() 216 | }.runUntilInputEntered { 217 | lateinit var possibleAnswer: String 218 | fun validateInput(input: String) { 219 | possibleAnswer = input.takeIf { it.isNotBlank() } ?: defaultAnswer.orEmpty() 220 | error = validateAnswer(possibleAnswer) 221 | } 222 | validateInput("") 223 | onInputChanged { validateInput(input) } 224 | onInputEntered { 225 | if (error == null) { 226 | answer = possibleAnswer 227 | } else { 228 | rejectInput() 229 | } 230 | } 231 | } 232 | return answer 233 | } 234 | 235 | fun Session.chooseFromList(message: String, items: List, itemToString: (T) -> String = { it.toString() }, produceInitialIndex: () -> Int = { 0 }, extra: ((T) -> String)? = null): T? { 236 | var choiceIndex by liveVarOf(produceInitialIndex()) 237 | var canceled by liveVarOf(false) 238 | fun selectedChoice() = items[choiceIndex].takeUnless { canceled } 239 | 240 | section { 241 | textLine() 242 | textLine("$message Choose one or press Q to cancel.") 243 | textLine() 244 | items.forEachIndexed { index, candidate -> 245 | text(if (index == choiceIndex) '>' else ' ') 246 | text(' ') 247 | cyan { textLine(itemToString(candidate)) } 248 | } 249 | textLine() 250 | if (extra != null) { 251 | yellow { 252 | textLine(" " + extra(items[choiceIndex])) 253 | } 254 | } 255 | }.runUntilSignal { 256 | onKeyPressed { 257 | when (key) { 258 | Keys.UP -> choiceIndex = 259 | (choiceIndex - 1).let { if (it < 0) items.size - 1 else it } 260 | 261 | Keys.DOWN -> choiceIndex = (choiceIndex + 1) % items.size 262 | Keys.HOME -> choiceIndex = 0 263 | Keys.END -> choiceIndex = items.size - 1 264 | // Q included because Kobweb users might be used to pressing it in other contexts 265 | Keys.ESC, Keys.Q, Keys.Q_UPPER -> { 266 | canceled = true; signal() 267 | } 268 | 269 | Keys.ENTER -> signal() 270 | } 271 | } 272 | } 273 | 274 | return selectedChoice() 275 | } 276 | 277 | /** 278 | * Convenience method for adding a single line, useful to do before or after queries or information messages. 279 | */ 280 | fun Session.newline() { 281 | section { textLine() }.run() 282 | } 283 | 284 | fun RunScope.informGradleStarting(onStarting: KobwebGradle.OnStartingEvent) { 285 | aside { 286 | black(isBright = true) { 287 | textLine(onStarting.fullCommand) 288 | } 289 | } 290 | } 291 | 292 | fun RunScope.handleConsoleOutput(line: String, isError: Boolean) { 293 | aside { 294 | if (isError) red() else black(isBright = true) 295 | textLine(line) 296 | } 297 | } 298 | 299 | fun warnFallingBackToPlainText() { 300 | println("Kobweb could not initialize an ANSI terminal session. Falling back to plain text.") 301 | println("You can run Kobweb with `--notty` to avoid seeing this message.") 302 | println() 303 | } 304 | 305 | /** 306 | * Try running a session, returning false if it could not start. 307 | * 308 | * The main reason a session would not start is if the terminal environment is not interactive, which is common in 309 | * environments like docker containers and CIs. In that case, the code that calls this method can handle the boolean 310 | * signal and run some fallback code that doesn't require interactivity. 311 | */ 312 | fun trySession(block: Session.() -> Unit): Boolean { 313 | var sessionStarted = false 314 | try { 315 | session { 316 | sessionStarted = true 317 | block() 318 | } 319 | } catch (ex: Exception) { 320 | if (!sessionStarted) { 321 | return false 322 | } else { 323 | // This exception came from after startup, when the user was 324 | // interacting with Kotter. Crashing with an informative stack 325 | // is probably the best thing we can do at this point. 326 | throw ex 327 | } 328 | } 329 | 330 | return true 331 | } 332 | -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/com/varabyte/kobweb/cli/create/Create.kt: -------------------------------------------------------------------------------- 1 | package com.varabyte.kobweb.cli.create 2 | 3 | import com.varabyte.kobweb.cli.common.PathUtils 4 | import com.varabyte.kobweb.cli.common.Validations 5 | import com.varabyte.kobweb.cli.common.findGit 6 | import com.varabyte.kobweb.cli.common.handleFetch 7 | import com.varabyte.kobweb.cli.common.kotter.askYesNo 8 | import com.varabyte.kobweb.cli.common.kotter.cmd 9 | import com.varabyte.kobweb.cli.common.kotter.newline 10 | import com.varabyte.kobweb.cli.common.kotter.queryUser 11 | import com.varabyte.kobweb.cli.common.kotter.textError 12 | import com.varabyte.kobweb.cli.common.template.KobwebTemplateFile 13 | import com.varabyte.kobweb.cli.common.template.getName 14 | import com.varabyte.kobweb.cli.common.version.versionIsSupported 15 | import com.varabyte.kobweb.cli.create.freemarker.FreemarkerState 16 | import com.varabyte.kobweb.cli.create.freemarker.methods.IsNotEmptyMethod 17 | import com.varabyte.kobweb.project.KobwebFolder 18 | import com.varabyte.kotter.foundation.input.Keys 19 | import com.varabyte.kotter.foundation.input.onKeyPressed 20 | import com.varabyte.kotter.foundation.input.runUntilKeyPressed 21 | import com.varabyte.kotter.foundation.liveVarOf 22 | import com.varabyte.kotter.foundation.session 23 | import com.varabyte.kotter.foundation.text.blue 24 | import com.varabyte.kotter.foundation.text.bold 25 | import com.varabyte.kotter.foundation.text.cyan 26 | import com.varabyte.kotter.foundation.text.green 27 | import com.varabyte.kotter.foundation.text.magenta 28 | import com.varabyte.kotter.foundation.text.text 29 | import com.varabyte.kotter.foundation.text.textLine 30 | import com.varabyte.kotter.foundation.text.yellow 31 | import com.varabyte.kotter.runtime.render.RenderScope 32 | import com.varabyte.kotterx.decorations.BorderCharacters 33 | import com.varabyte.kotterx.decorations.bordered 34 | import java.io.File 35 | import java.nio.file.Path 36 | import kotlin.io.path.absolutePathString 37 | import kotlin.io.path.deleteExisting 38 | import kotlin.io.path.deleteIfExists 39 | import kotlin.io.path.name 40 | 41 | private fun RenderScope.renderTemplateItem( 42 | rootPath: Path, 43 | templateFile: KobwebTemplateFile, 44 | isSelected: Boolean, 45 | ) { 46 | val templatePath = templateFile.getName(rootPath) 47 | val description = templateFile.template.metadata.description 48 | val isImportant = templateFile.template.metadata.shouldHighlight 49 | 50 | text(if (isSelected) '>' else ' ') 51 | text(' ') 52 | cyan(isBright = isImportant) { text(templatePath) } 53 | textLine(": $description") 54 | } 55 | 56 | 57 | fun handleCreate(repo: String, branch: String?, templateName: String?) = session { 58 | val gitClient = findGit() ?: return@session 59 | val tempDir = handleFetch(gitClient, repo, branch) ?: return@session 60 | 61 | val templateRoots = tempDir.toFile().walkTopDown() 62 | .filter { it.isDirectory } 63 | .mapNotNull { dir -> KobwebTemplateFile.inPath(dir.toPath()) } 64 | .filter { it.template.versionIsSupported } 65 | .toList() 66 | 67 | if (templateRoots.isEmpty()) { 68 | section { 69 | textError("No templates were found in the specified repository.") 70 | }.run() 71 | return@session 72 | } 73 | 74 | val templateFile = 75 | (if (templateName != null) { 76 | templateRoots 77 | .firstOrNull { templateFile -> templateFile.getName(tempDir) == templateName } 78 | ?: run { 79 | section { 80 | textError("Unable to locate a template named \"$templateName\". Falling back to choosing...") 81 | textLine() 82 | }.run() 83 | 84 | null 85 | } 86 | } else null) 87 | // If we can't find the template OR if no template is specified, offer a list to choose from 88 | ?: run { 89 | @Suppress("NAME_SHADOWING") 90 | val templateRoots = templateRoots 91 | .sortedBy { templateFile -> templateFile.folder } 92 | .sortedByDescending { templateFile -> templateFile.template.metadata.shouldHighlight } 93 | 94 | var selectedIndex by liveVarOf( 95 | // If the user typed in a template name and we couldn't find it, maybe we can find what they were 96 | // intending to use (e.g. "clock" will match "examples/clock") 97 | if (templateName != null) { 98 | templateRoots.indexOfFirst { templateFile -> 99 | templateFile.getName(tempDir).contains(templateName) 100 | }.takeIf { it >= 0 } ?: 101 | templateRoots.indexOfFirst { templateFile -> 102 | // If here, there was no name match, but maybe a description will match? e.g. "worker" will 103 | // find the "imageprocessor" template which mentions it demonstrates how workers work. 104 | templateFile.template.metadata.description.contains(templateName, ignoreCase = true) 105 | }.takeIf { it >= 0 } ?: 0 106 | } else 0 107 | ) 108 | var finished by liveVarOf(false) 109 | section { 110 | bold { textLine("Press ENTER to select a project to instantiate:") } 111 | textLine() 112 | templateRoots.forEachIndexed { i, templateFile -> 113 | this.renderTemplateItem(tempDir, templateFile, selectedIndex == i) 114 | } 115 | textLine() 116 | if (!finished) { 117 | if (templateRoots[selectedIndex].template.metadata.shouldHighlight) { 118 | yellow { 119 | textLine(" Note: This project has been highlighted as important by the template creator.") 120 | } 121 | } 122 | } 123 | } 124 | .onFinishing { finished = true } 125 | .runUntilKeyPressed(Keys.ENTER) { 126 | onKeyPressed { 127 | when (key) { 128 | Keys.UP -> selectedIndex = 129 | if (selectedIndex == 0) templateRoots.lastIndex else selectedIndex - 1 130 | 131 | Keys.DOWN -> selectedIndex = 132 | if (selectedIndex == templateRoots.lastIndex) 0 else selectedIndex + 1 133 | 134 | Keys.HOME -> selectedIndex = 0 135 | Keys.END -> selectedIndex = templateRoots.lastIndex 136 | } 137 | } 138 | } 139 | 140 | templateRoots[selectedIndex] 141 | } 142 | 143 | 144 | // Convert full template name to folder name, e.g. "site" -> "site" and "examples/clock" -> "clock". 145 | val defaultFolderName = 146 | PathUtils.generateEmptyPathName(templateFile.getName(tempDir).substringAfterLast('/')) 147 | 148 | val dstPath = 149 | queryUser( 150 | "Specify a folder for your project:", 151 | "The folder you choose here will be created under your current path.\nYou can enter `.` if you want to use the current directory.", 152 | defaultFolderName 153 | ) { answer -> 154 | Validations.isFileName(answer) ?: Validations.isEmptyPath(answer) 155 | }.let { answer -> 156 | Path.of(if (answer != ".") answer else "").toAbsolutePath() 157 | } 158 | val srcPath = templateFile.folder 159 | val kobwebTemplate = templateFile.template // We already checked this was set, above 160 | // We've parsed the template and don't need it anymore. Delete it so we don't copy it over 161 | templateFile.path.deleteExisting() 162 | // Delete legacy template.yaml file, if found. TODO(#188): Delete this line before 1.0 163 | KobwebFolder.inPath(templateFile.folder)?.path?.resolve("template.yaml")?.deleteIfExists() 164 | 165 | // If a user wants to create a template underneath another template for naming purposes, e.g. 166 | // `demosite` and `demosite/dark`, they can just nest one template project underneath another, and Kobweb will 167 | // remove it after syncing. 168 | run { 169 | val subTemplates = mutableListOf() 170 | val root = srcPath.toFile() 171 | root.walkTopDown() 172 | .filter { file -> file != root } 173 | .forEach { file -> 174 | if (file.isDirectory && KobwebTemplateFile.isFoundIn(file.toPath())) { 175 | subTemplates.add(file) 176 | } 177 | } 178 | subTemplates.forEach { subTemplate -> subTemplate.deleteRecursively() } 179 | } 180 | 181 | val state = FreemarkerState(srcPath, dstPath) 182 | state.execute(this, kobwebTemplate.instructions) 183 | 184 | val projectFolder = dstPath.name 185 | 186 | newline() 187 | askYesNo("Would you like to initialize git for this project?").let { shouldInitialize -> 188 | if (shouldInitialize) { 189 | gitClient.init(dstPath) 190 | 191 | val shouldCommit = askYesNo("Would you like to create an initial commit?") 192 | if (shouldCommit) { 193 | val isNotEmpty = IsNotEmptyMethod() 194 | val commitMessage = queryUser( 195 | "Commit message:", 196 | "Initial commit", 197 | validateAnswer = { answer -> isNotEmpty.exec(answer) } 198 | ).trim() 199 | 200 | gitClient.add(".", dstPath) 201 | gitClient.commit(commitMessage, dstPath) 202 | } 203 | } 204 | } 205 | 206 | section { 207 | fun indent() { 208 | text(" ") 209 | } 210 | bold { 211 | green { text("Success! ") } 212 | textLine("Created $projectFolder at ${dstPath.absolutePathString()}") 213 | } 214 | textLine() 215 | bordered(BorderCharacters.CURVED, paddingLeftRight = 1) { 216 | text("Consider downloading "); magenta(isBright = true) { textLine("IntelliJ IDEA Community Edition") } 217 | text("using "); blue(isBright = true) { textLine("https://www.jetbrains.com/toolbox-app/") } 218 | } 219 | textLine() 220 | 221 | // Search for the root where you can run `kobweb run` within. This is usually at the root of a Kobweb project, 222 | // but it might be inside a submodule in some cases. We always expect to find it, but if we can't, no big deal 223 | // -- just don't show the suggestion. 224 | val kobwebRootPath = dstPath.toFile().walkTopDown() 225 | .filter { file -> file.isDirectory && KobwebFolder.isFoundIn(file.toPath()) } 226 | .firstOrNull()?.toPath() 227 | 228 | if (kobwebRootPath != null) { 229 | textLine("We suggest that you begin by typing:") 230 | textLine() 231 | 232 | val currPath = Path.of("").toAbsolutePath() 233 | if (kobwebRootPath != currPath) { 234 | indent(); cmd("cd ${currPath.relativize(kobwebRootPath)}"); textLine() 235 | } 236 | indent(); cmd("kobweb run"); textLine() 237 | } 238 | }.run() 239 | } 240 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2021 Varabyte 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/com/varabyte/kobweb/cli/create/freemarker/FreemarkerState.kt: -------------------------------------------------------------------------------- 1 | package com.varabyte.kobweb.cli.create.freemarker 2 | 3 | import com.varabyte.kobweb.cli.common.kotter.askYesNo 4 | import com.varabyte.kobweb.cli.common.kotter.informInfo 5 | import com.varabyte.kobweb.cli.common.kotter.processing 6 | import com.varabyte.kobweb.cli.common.kotter.queryUser 7 | import com.varabyte.kobweb.cli.common.template.Instruction 8 | import com.varabyte.kobweb.cli.common.wildcardToRegex 9 | import com.varabyte.kobweb.cli.create.freemarker.methods.FileToPackageMethod 10 | import com.varabyte.kobweb.cli.create.freemarker.methods.FileToTitleMethod 11 | import com.varabyte.kobweb.cli.create.freemarker.methods.IsIntMethod 12 | import com.varabyte.kobweb.cli.create.freemarker.methods.IsNotEmptyMethod 13 | import com.varabyte.kobweb.cli.create.freemarker.methods.IsNumberMethod 14 | import com.varabyte.kobweb.cli.create.freemarker.methods.IsPackageMethod 15 | import com.varabyte.kobweb.cli.create.freemarker.methods.IsPositiveIntMethod 16 | import com.varabyte.kobweb.cli.create.freemarker.methods.IsPositiveNumberMethod 17 | import com.varabyte.kobweb.cli.create.freemarker.methods.IsYesNoMethod 18 | import com.varabyte.kobweb.cli.create.freemarker.methods.NotMethod 19 | import com.varabyte.kobweb.cli.create.freemarker.methods.PackageToPathMethod 20 | import com.varabyte.kobweb.cli.create.freemarker.methods.EscapeYamlStringMethod 21 | import com.varabyte.kobweb.cli.create.freemarker.methods.YesNoToBoolMethod 22 | import com.varabyte.kobweb.common.error.KobwebException 23 | import com.varabyte.kobweb.common.path.invariantSeparatorsPath 24 | import com.varabyte.kotter.runtime.Session 25 | import freemarker.cache.NullCacheStorage 26 | import freemarker.template.Configuration 27 | import freemarker.template.Template 28 | import freemarker.template.TemplateExceptionHandler 29 | import freemarker.template.TemplateMethodModelEx 30 | import java.io.File 31 | import java.io.FileWriter 32 | import java.io.StringReader 33 | import java.io.StringWriter 34 | import java.nio.file.Files 35 | import java.nio.file.Path 36 | import kotlin.io.path.createDirectories 37 | import kotlin.io.path.isRegularFile 38 | import kotlin.io.path.name 39 | import kotlin.io.path.notExists 40 | 41 | private fun String.process(cfg: Configuration, model: Map): String { 42 | val reader = StringReader(this) 43 | val writer = StringWriter() 44 | Template("unused", reader, cfg).process(model, writer) 45 | return writer.buffer.toString() 46 | } 47 | 48 | class FreemarkerState(private val src: Path, private val dest: Path) { 49 | private val model = mutableMapOf( 50 | "projectFolder" to dest.name, 51 | 52 | // region Validators 53 | "isInt" to IsIntMethod(), // Added in 0.9.13 54 | "isNotEmpty" to IsNotEmptyMethod(), 55 | "isNumber" to IsNumberMethod(), // Added in 0.9.13 56 | "isPackage" to IsPackageMethod(), 57 | "isPositiveInt" to IsPositiveIntMethod(), // Added in 0.9.13 58 | "isPositiveNumber" to IsPositiveNumberMethod(), // Added in 0.9.13 59 | "isYesNo" to IsYesNoMethod(), 60 | // endregion 61 | 62 | // region Converters 63 | "escapeYamlString" to EscapeYamlStringMethod(), // Added in 0.9.17 64 | "fileToTitle" to FileToTitleMethod(), 65 | "fileToPackage" to FileToPackageMethod(), 66 | "not" to NotMethod(), 67 | "packageToPath" to PackageToPathMethod(), 68 | "yesNoToBool" to YesNoToBoolMethod(), 69 | // endregion 70 | ) 71 | 72 | // See also: https://freemarker.apache.org/docs/pgui_quickstart_all.html 73 | private val cfg = Configuration(Configuration.VERSION_2_3_31).apply { 74 | setDirectoryForTemplateLoading(src.toFile()) 75 | // Kobweb doesn't serve templates - it just runs through files once. No need to cache. 76 | cacheStorage = NullCacheStorage() 77 | defaultEncoding = "UTF-8" 78 | templateExceptionHandler = TemplateExceptionHandler.RETHROW_HANDLER 79 | logTemplateExceptions = false 80 | wrapUncheckedExceptions = true 81 | fallbackOnNullLoopVariable = false 82 | } 83 | 84 | private fun Session.process(instructions: Iterable) { 85 | for (inst in instructions) { 86 | val useInstruction = inst.condition?.process(cfg, model)?.toBoolean() ?: true 87 | if (!useInstruction) continue 88 | 89 | when (inst) { 90 | is Instruction.Group -> { 91 | process(inst.instructions) 92 | } 93 | 94 | is Instruction.Inform -> { 95 | val message = inst.message.process(cfg, model) 96 | informInfo(message) 97 | } 98 | 99 | is Instruction.QueryVar -> { 100 | // Useful hack: In the original version of this code, yes/no questions were handled by asking users 101 | // to pass in the string "yes/no" which got converted to a boolean. We can check if the validation 102 | // function is the "IsYesNo" validation method and, if so, use our new, more user-friendly, 103 | // impossible-to-misuse yes/no widget instead. 104 | val templateMethodModel = model[inst.validation] as? TemplateMethodModelEx 105 | val finalAnswer = if (templateMethodModel is IsYesNoMethod) { 106 | val yesNoToBool = YesNoToBoolMethod() 107 | 108 | val answerBool = askYesNo( 109 | inst.prompt, 110 | inst.note, 111 | yesNoToBool.exec((inst.default ?: true).toString()).toBoolean() 112 | ) 113 | yesNoToBool.exec(answerBool.toString()) 114 | } else { 115 | val default = inst.default?.process(cfg, model) 116 | val answer = queryUser(inst.prompt, inst.note, default, validateAnswer = { value -> 117 | (model[inst.validation] as? TemplateMethodModelEx)?.exec(listOf(value))?.toString() 118 | }) 119 | 120 | inst.transform?.let { transform -> 121 | val modelWithValue = model.toMutableMap() 122 | modelWithValue["value"] = answer 123 | transform.process(cfg, modelWithValue) 124 | } ?: answer 125 | } 126 | model[inst.name] = finalAnswer 127 | } 128 | 129 | is Instruction.DefineVar -> { 130 | model[inst.name] = inst.value.process(cfg, model) 131 | } 132 | 133 | is Instruction.ProcessFreemarker -> { 134 | processing("Processing templates") { 135 | val srcFile = src.toFile() 136 | val filesToProcess = mutableListOf() 137 | srcFile.walkBottomUp().forEach { file -> 138 | if (file.extension == "ftl") { 139 | filesToProcess.add(file) 140 | } 141 | } 142 | filesToProcess.forEach { templateFile -> 143 | val template = cfg.getTemplate(templateFile.toRelativeString(srcFile)) 144 | FileWriter(templateFile.path.removeSuffix(".ftl")).use { writer -> 145 | template.process(model, writer) 146 | } 147 | templateFile.delete() 148 | } 149 | } 150 | } 151 | 152 | is Instruction.Move -> { 153 | val to = inst.to.process(cfg, model) 154 | processing(inst.description ?: "Moving \"${inst.from}\" to \"$to\"") { 155 | val matcher = inst.from.wildcardToRegex() 156 | val srcFile = src.toFile() 157 | val filesToMove = mutableListOf() 158 | srcFile.walkBottomUp().forEach { file -> 159 | // Matcher expects *nix paths; make sure this check works on Windows 160 | if (matcher.matches(file.toRelativeString(srcFile).invariantSeparatorsPath)) { 161 | filesToMove.add(file) 162 | } 163 | } 164 | val destPath = src.resolve(to) 165 | if (destPath.notExists()) { 166 | destPath.createDirectories() 167 | } else if (destPath.isRegularFile()) { 168 | throw KobwebException("Cannot move files into target that isn't a directory") 169 | } 170 | filesToMove.forEach { fileToMove -> 171 | Files.move(fileToMove.toPath(), destPath.resolve(fileToMove.name)) 172 | } 173 | } 174 | } 175 | 176 | is Instruction.Rename -> { 177 | val name = inst.name.process(cfg, model) 178 | processing(inst.description ?: "Renaming \"${inst.file}\" to \"$name\"") { 179 | val srcFile = src.toFile() 180 | val fileToRename = srcFile.resolve(inst.file) 181 | if (!fileToRename.exists()) { 182 | throw KobwebException("Cannot rename a file (${inst.file}) because it does not exist") 183 | } 184 | 185 | // If the rename isn't actually changing anything, technically we're done 186 | if (fileToRename.name != name) { 187 | val targetFile = fileToRename.resolveSibling(name) 188 | if (targetFile.exists()) { 189 | throw KobwebException("Cannot rename a file (${inst.file}) because the rename target ($targetFile) already exists") 190 | } 191 | 192 | fileToRename.renameTo(targetFile) 193 | } 194 | } 195 | } 196 | 197 | is Instruction.Delete -> { 198 | processing(inst.description ?: "Deleting \"${inst.files}\"") { 199 | val deleteMatcher = inst.files.wildcardToRegex() 200 | 201 | val srcFile = src.toFile() 202 | val filesToDelete = mutableListOf() 203 | srcFile.walkBottomUp().forEach { file -> 204 | // Matcher expects *nix paths; make sure this check works on Windows 205 | val relativePath = file.toRelativeString(srcFile).invariantSeparatorsPath 206 | if (deleteMatcher.matches(relativePath)) { 207 | filesToDelete.add(file) 208 | } 209 | } 210 | filesToDelete.forEach { fileToDelete -> fileToDelete.deleteRecursively() } 211 | } 212 | } 213 | } 214 | } 215 | 216 | } 217 | 218 | fun execute(app: Session, instructions: List) { 219 | app.apply { 220 | process(instructions) 221 | processing("Nearly finished. Populating final project") { 222 | val srcFile = src.toFile() 223 | val files = mutableListOf() 224 | srcFile.walkBottomUp().forEach { file -> 225 | if (file.isFile) { 226 | files.add(file) 227 | } 228 | } 229 | 230 | files.forEach { file -> 231 | val subPath = file.parentFile.toRelativeString(srcFile) 232 | val destPath = dest.resolve(subPath) 233 | if (destPath.notExists()) { 234 | destPath.createDirectories() 235 | } 236 | 237 | Files.copy(file.toPath(), destPath.resolve(file.name)) 238 | } 239 | } 240 | } 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/com/varabyte/kobweb/cli/common/KobwebUtils.kt: -------------------------------------------------------------------------------- 1 | package com.varabyte.kobweb.cli.common 2 | 3 | import com.varabyte.kobweb.cli.common.kotter.chooseFromList 4 | import com.varabyte.kobweb.cli.common.kotter.informError 5 | import com.varabyte.kobweb.cli.common.kotter.informInfo 6 | import com.varabyte.kobweb.cli.common.kotter.onYesNoChanged 7 | import com.varabyte.kobweb.cli.common.kotter.textInfoPrefix 8 | import com.varabyte.kobweb.cli.common.kotter.textQuestionPrefix 9 | import com.varabyte.kobweb.cli.common.kotter.yesNo 10 | import com.varabyte.kobweb.cli.stop.handleStop 11 | import com.varabyte.kobweb.common.error.KobwebException 12 | import com.varabyte.kobweb.project.KobwebApplication 13 | import com.varabyte.kobweb.project.KobwebFolder 14 | import com.varabyte.kobweb.project.conf.KobwebConf 15 | import com.varabyte.kobweb.project.conf.KobwebConfFile 16 | import com.varabyte.kobweb.server.api.ServerEnvironment 17 | import com.varabyte.kobweb.server.api.ServerState 18 | import com.varabyte.kobweb.server.api.ServerStateFile 19 | import com.varabyte.kotter.foundation.liveVarOf 20 | import com.varabyte.kotter.foundation.runUntilSignal 21 | import com.varabyte.kotter.foundation.session 22 | import com.varabyte.kotter.foundation.text.cyan 23 | import com.varabyte.kotter.foundation.text.text 24 | import com.varabyte.kotter.foundation.text.textLine 25 | import com.varabyte.kotter.foundation.text.yellow 26 | import com.varabyte.kotter.runtime.Session 27 | import java.io.Closeable 28 | import java.nio.file.Path 29 | import kotlin.io.path.absolutePathString 30 | import kotlin.io.path.exists 31 | 32 | /** 33 | * Classes needed for the CLI to be able to execute commands for the current Kobweb project. 34 | */ 35 | class KobwebExecutionEnvironment( 36 | val application: KobwebApplication, 37 | val gradle: KobwebGradle, 38 | ) : Closeable { 39 | override fun close() { 40 | gradle.close() 41 | } 42 | } 43 | 44 | private const val NOT_KOBWEB_APPLICATION_ERROR = "This command must be called in a Kobweb application module." 45 | 46 | fun assertKobwebApplication(path: Path): KobwebApplication { 47 | return try { 48 | KobwebApplication(path) 49 | } catch (ex: KobwebException) { 50 | throw KobwebException(NOT_KOBWEB_APPLICATION_ERROR) 51 | } 52 | } 53 | 54 | fun assertKobwebConfIn(kobwebFolder: KobwebFolder): KobwebConf { 55 | val confFile = KobwebConfFile(kobwebFolder) 56 | return confFile.content 57 | ?: run { 58 | // We want the relative path name INCLUDING the parent folder name 59 | // e.g. ".kobweb/conf.yaml" 60 | val relativePath = confFile.path.subpath(kobwebFolder.path.nameCount - 1, confFile.path.nameCount) 61 | if (!confFile.path.exists()) { 62 | throw KobwebException("The file `${relativePath}` seems to have been deleted at some point. This is not expected and Kobweb cannot run without it. Consider restoring your `conf.yaml` file from source control history if possible, or create a new, temporary Kobweb project from scratch and copy its `conf.yaml` file over, modifying it as necessary.") 63 | } else { 64 | throw KobwebException("The file `${relativePath}` cannot be loaded for some reason. Please open it up in an editor and check for syntax errors.") 65 | } 66 | } 67 | } 68 | 69 | fun assertKobwebExecutionEnvironment(env: ServerEnvironment, path: Path): KobwebExecutionEnvironment { 70 | return KobwebExecutionEnvironment( 71 | assertKobwebApplication(path), 72 | KobwebGradle(env, path), 73 | ) 74 | } 75 | 76 | private fun KobwebFolder.queryState(): ServerState? { 77 | return ServerStateFile(this).content 78 | } 79 | 80 | fun KobwebFolder.assertServerNotAlreadyRunning() { 81 | queryState()?.let { serverState -> 82 | if (serverState.isRunning()) { 83 | throw KobwebException("Cannot execute this command as a server is already running (PID=${serverState.pid}). Consider running `kobweb stop` if this is unexpected.") 84 | } 85 | } 86 | } 87 | 88 | fun KobwebApplication.assertServerNotAlreadyRunning() { 89 | this.kobwebFolder.assertServerNotAlreadyRunning() 90 | } 91 | 92 | fun KobwebApplication.isServerAlreadyRunning(): Boolean { 93 | return try { 94 | assertServerNotAlreadyRunning() 95 | false 96 | } catch (ex: KobwebException) { 97 | true 98 | } 99 | } 100 | 101 | fun Session.findKobwebApplication(basePath: Path): KobwebApplication? { 102 | // When we report the location of the target path to the user, we want to do it relative from where the user ran the 103 | // kobweb command from. But this might fail (e.g. on Windows running the command on one drive targeting another), 104 | // so in that case, just append the "this" path to the base path, because within the context of this function, 105 | // `this` will always be a child of `basePath`. 106 | fun Path.relativeToCurrentDirectoryOrBasePath(): Path { 107 | check(this.absolutePathString().startsWith(basePath.absolutePathString())) 108 | return this.relativeToCurrentDirectory() ?: basePath.resolve(this) 109 | } 110 | 111 | 112 | val foundPath: Path? = if (!KobwebFolder.isFoundIn(basePath)) { 113 | // Frustratingly, both walkTopDown and walkButtomUp seem to visit directories in the same order, whereas we want 114 | // folders closer to us (e.g. "site") to be recommended over folders further away (e.g. "subdir/site"). 115 | // So we'll sort by depth ourselves. 116 | fun Sequence.sortedByDepth(): Sequence { 117 | return sortedBy { p -> 118 | p.relativeToCurrentDirectoryOrBasePath().toString().count { it == '/' || it == '\\' } 119 | } 120 | } 121 | 122 | val candidates = try { 123 | basePath.toFile().walk().maxDepth(2) 124 | .filter { it.isDirectory } 125 | .map { it.toPath() } 126 | .filter { KobwebFolder.isFoundIn(it) } 127 | .sortedByDepth() 128 | .toList() 129 | } catch(ex: Exception) { 130 | // If this happens, we definitely don't have access to projects to recommend to users. 131 | // The user is probably running kobweb in a privileged location, perhaps. 132 | emptyList() 133 | } 134 | 135 | if (candidates.isNotEmpty()) { 136 | if (candidates.size == 1) { 137 | val candidate = candidates.single() 138 | 139 | var shouldUseNewLocation by liveVarOf(true) 140 | section { 141 | textLine() 142 | textInfoPrefix() 143 | text("A Kobweb application was not found here, but one was found in ") 144 | cyan { text(candidate.relativeToCurrentDirectoryOrBasePath().toString()) } 145 | textLine(".") 146 | 147 | textQuestionPrefix() 148 | text("Use ") 149 | cyan { text(candidate.relativeToCurrentDirectoryOrBasePath().toString()) } 150 | text(" instead? ") 151 | 152 | yesNo(shouldUseNewLocation) 153 | textLine() 154 | }.runUntilSignal { 155 | onYesNoChanged { 156 | shouldUseNewLocation = isYes 157 | if (shouldAccept) signal() 158 | } 159 | } 160 | 161 | candidate.takeIf { shouldUseNewLocation } 162 | } else { 163 | chooseFromList( 164 | "A Kobweb application was not found here, but multiple Kobweb applications were found in nested folders.", 165 | candidates, 166 | itemToString = { it.relativeToCurrentDirectoryOrBasePath().toString() }) 167 | } 168 | } else { 169 | null 170 | } 171 | } else { 172 | basePath 173 | } 174 | 175 | val foundApplication = try { 176 | foundPath?.let { KobwebApplication(it) }?.also { application -> 177 | if (application.path != basePath) { 178 | informInfo { 179 | val argsCopy = Globals.getValue(ProgramArgsKey).toMutableList() 180 | val pathIndex = argsCopy.indexOfFirst { it == "-p" || it == "--path" } 181 | val newPath = application.path.relativeToCurrentDirectoryOrBasePath().toString() 182 | when { 183 | // Replace over the old path 184 | pathIndex >= 0 -> argsCopy[pathIndex + 1] = newPath 185 | // Add "-p ". Always set it as the first argument after the subcommand, 186 | // e.g. `kobweb run --env prod` -> `kobweb run -p --env prod` 187 | // to make the change easier to see for the user but also to reduce the chance of this causing a 188 | // problem in the future (e.g. if we add an argument that consumes the rest of the line or 189 | // something) 190 | else -> argsCopy.addAll(1, listOf("-p", newPath)) 191 | } 192 | 193 | text("Running: ") 194 | cyan { text("kobweb ${argsCopy.joinToString(" ")}") } 195 | 196 | Globals[ProgramArgsKey] = argsCopy.toTypedArray() 197 | } 198 | } 199 | } 200 | } catch (ex: KobwebException) { 201 | null 202 | } 203 | 204 | if (foundApplication == null) { 205 | informError(NOT_KOBWEB_APPLICATION_ERROR) 206 | } 207 | return foundApplication 208 | } 209 | 210 | // If `useAnsi` is true, this must NOT be called within an existing Kotter session! Because it will try to create a 211 | // new one. 212 | fun findKobwebExecutionEnvironment(env: ServerEnvironment, root: Path, useAnsi: Boolean): KobwebExecutionEnvironment? { 213 | return if (useAnsi) { 214 | var application: KobwebApplication? = null 215 | session { 216 | application = findKobwebApplication(root) 217 | } 218 | application?.let { KobwebExecutionEnvironment(it, KobwebGradle(env, it.path)) } 219 | } else { 220 | assertKobwebExecutionEnvironment(env, root) 221 | } 222 | } 223 | 224 | fun Session.isServerAlreadyRunningFor(project: KobwebApplication, kobwebGradle: KobwebGradle): Boolean { 225 | project.kobwebFolder.queryState()?.let { serverState -> 226 | if (serverState.isRunning()) { 227 | informError("A server is already running (PID=${serverState.pid}).") 228 | var stopRequested by liveVarOf(false) 229 | // Only show the warning until the user has confirmed their choice. Otherwise, it looks weird to 230 | // leave that warning in the text history. 231 | var showWarning by liveVarOf(true) 232 | section { 233 | textLine() 234 | textQuestionPrefix() 235 | textLine("Would you like to stop that server and continue? ") 236 | yesNo(stopRequested, default = false) 237 | if (stopRequested && showWarning) { 238 | yellow { textLine("Consider checking other terminals first for the active Kobweb session you are about to interrupt.") } 239 | } 240 | textLine() 241 | }.onFinishing { 242 | showWarning = false 243 | }.runUntilSignal { 244 | onYesNoChanged { 245 | stopRequested = isYes 246 | if (shouldAccept) signal() 247 | } 248 | } 249 | 250 | return if (!stopRequested) { 251 | section { 252 | textLine("Exiting early, thereby leaving the current server running.") 253 | textLine() 254 | textInfoPrefix() 255 | text("You may consider running ") 256 | cyan { text("kobweb stop") } 257 | text(" before proceeding again.") 258 | }.run() 259 | 260 | true 261 | } else { 262 | handleStop(kobwebGradle) 263 | false 264 | } 265 | } 266 | } 267 | 268 | return false 269 | } 270 | 271 | fun Session.findKobwebConfIn(kobwebFolder: KobwebFolder): KobwebConf? { 272 | return try { 273 | assertKobwebConfIn(kobwebFolder) 274 | } catch (ex: KobwebException) { 275 | informError(ex.message!!) 276 | null 277 | } 278 | } 279 | 280 | fun Session.findKobwebConfFor(kobwebApplication: KobwebApplication) = findKobwebConfIn(kobwebApplication.kobwebFolder) 281 | 282 | fun Session.showStaticSiteLayoutWarning() { 283 | section { 284 | // TODO(#123): Link to URL doc link when available. 285 | yellow { textLine("Static site layout chosen. Some Kobweb features like server api routes / api streams are unavailable in this configuration.") } 286 | textLine() 287 | }.run() 288 | } 289 | -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/main.kt: -------------------------------------------------------------------------------- 1 | import com.github.ajalt.clikt.core.Context 2 | import com.github.ajalt.clikt.core.CoreCliktCommand 3 | import com.github.ajalt.clikt.core.ParameterHolder 4 | import com.github.ajalt.clikt.core.UsageError 5 | import com.github.ajalt.clikt.core.context 6 | import com.github.ajalt.clikt.core.main 7 | import com.github.ajalt.clikt.core.subcommands 8 | import com.github.ajalt.clikt.output.Localization 9 | import com.github.ajalt.clikt.output.ParameterFormatter 10 | import com.github.ajalt.clikt.parameters.arguments.argument 11 | import com.github.ajalt.clikt.parameters.arguments.optional 12 | import com.github.ajalt.clikt.parameters.groups.mutuallyExclusiveOptions 13 | import com.github.ajalt.clikt.parameters.groups.single 14 | import com.github.ajalt.clikt.parameters.options.convert 15 | import com.github.ajalt.clikt.parameters.options.default 16 | import com.github.ajalt.clikt.parameters.options.flag 17 | import com.github.ajalt.clikt.parameters.options.option 18 | import com.github.ajalt.clikt.parameters.types.enum 19 | import com.github.ajalt.clikt.parameters.types.file 20 | import com.varabyte.kobweb.cli.common.DEFAULT_REPO 21 | import com.varabyte.kobweb.cli.common.Globals 22 | import com.varabyte.kobweb.cli.common.ProgramArgsKey 23 | import com.varabyte.kobweb.cli.common.kotter.trySession 24 | import com.varabyte.kobweb.cli.common.version.SemVer 25 | import com.varabyte.kobweb.cli.common.version.isSnapshot 26 | import com.varabyte.kobweb.cli.common.version.kobwebCliVersion 27 | import com.varabyte.kobweb.cli.common.version.reportUpdateAvailable 28 | import com.varabyte.kobweb.cli.conf.handleConf 29 | import com.varabyte.kobweb.cli.create.handleCreate 30 | import com.varabyte.kobweb.cli.export.handleExport 31 | import com.varabyte.kobweb.cli.help.KotterHelpFormatter 32 | import com.varabyte.kobweb.cli.list.handleList 33 | import com.varabyte.kobweb.cli.run.handleRun 34 | import com.varabyte.kobweb.cli.stop.handleStop 35 | import com.varabyte.kobweb.cli.version.handleVersion 36 | import com.varabyte.kobweb.server.api.ServerEnvironment 37 | import com.varabyte.kobweb.server.api.SiteLayout 38 | import kotlinx.coroutines.CoroutineScope 39 | import kotlinx.coroutines.Dispatchers 40 | import kotlinx.coroutines.launch 41 | import okhttp3.OkHttpClient 42 | import okhttp3.Request 43 | import java.io.File 44 | 45 | private fun ParameterHolder.layout() = option( 46 | "-l", "--layout", 47 | help = "Specify the organizational layout of the site files." 48 | ) 49 | .enum() 50 | 51 | enum class TeleTypeMode { 52 | ENABLED, 53 | DISABLED, 54 | } 55 | 56 | fun TeleTypeMode?.shouldUseAnsi(): Boolean { 57 | return this == null || this == TeleTypeMode.ENABLED 58 | } 59 | 60 | // We use `absoluteFile` so that the parent directories are directly accessible. This is necessary for the gradle 61 | // tooling api to be able to get the root project configuration if the kobweb module is a subproject. 62 | private fun ParameterHolder.path() = option( 63 | "-p", "--path", 64 | help = "The path to the Kobweb application module.", 65 | ) 66 | .file(mustExist = true, canBeFile = false) 67 | .convert { it.absoluteFile as File } // cast platform type to explicitly not nullable 68 | .default(File(".").absoluteFile, defaultForHelp = "the current directory") 69 | 70 | private fun ParameterHolder.ttyMode() = mutuallyExclusiveOptions( 71 | option( 72 | "-t", "--tty", 73 | help = "Enable TTY support (default). Tries to run using ANSI support in an interactive mode if it can. Falls back to --notty otherwise." 74 | ).flag().convert { TeleTypeMode.ENABLED }, 75 | option( 76 | "--notty", 77 | help = "Explicitly disable TTY support. In this case, runs in plain mode, logging output sequentially without listening for user input, which is useful for CI environments or Docker containers.", 78 | ).flag().convert { TeleTypeMode.DISABLED }, 79 | ).single() 80 | 81 | private fun ParameterHolder.gradleArgs(suffix: String? = null) = option( 82 | "--gradle" + (suffix?.let { "-$it" } ?: ""), 83 | help = 84 | if (suffix == null) { 85 | "Arguments that will be passed into every Gradle call issued by this command (some Kobweb commands have multiple phases), useful for common configurations like --quiet. Surround with quotes for multiple arguments or if there are spaces." 86 | } else { 87 | "Arguments that will be passed to the Gradle call associated with the \"$suffix\" phase specifically." 88 | } 89 | ) 90 | .convert { args -> args.split(' ').filter { it.isNotBlank() } } 91 | .default(emptyList(), defaultForHelp = "none") 92 | 93 | open class NoOpCliktCommand : CoreCliktCommand() { 94 | override fun run() {} 95 | } 96 | 97 | fun main(args: Array) { 98 | Globals[ProgramArgsKey] = args 99 | 100 | /** 101 | * Common functionality for all Kobweb subcommands. 102 | */ 103 | abstract class KobwebSubcommand(private val help: String) : CoreCliktCommand() { 104 | private var newVersionAvailable: SemVer.Parsed? = null 105 | 106 | override fun help(context: Context): String = help 107 | 108 | /** 109 | * If true, do an upgrade check while this command is running. 110 | * 111 | * If one is available, show an upgrade message after the command finishes running. 112 | * 113 | * This value should generally be false unless the command is one that is long-running where a message showing 114 | * up after it is finished wouldn't be considered intrusive. 115 | */ 116 | protected open fun shouldCheckForUpgrade(): Boolean = false 117 | 118 | private fun checkForUpgradeAsync() { 119 | // No need to check for new versions if we're in development mode 120 | // (which is the only time we'd see a snapshot version here). 121 | if (kobwebCliVersion.isSnapshot) return 122 | 123 | CoroutineScope(Dispatchers.IO).launch { 124 | val client = OkHttpClient() 125 | val latestVersionRequest = 126 | Request.Builder() 127 | .url("https://raw.githubusercontent.com/varabyte/data/main/kobweb/cli-version.txt") 128 | .build() 129 | 130 | client.newCall(latestVersionRequest).execute().use { response -> 131 | if (response.isSuccessful) { 132 | response.body?.string()?.trim() 133 | ?.let { latestVersionStr -> SemVer.tryParse(latestVersionStr) } 134 | ?.let { latestVersion -> 135 | if (kobwebCliVersion < latestVersion) { 136 | newVersionAvailable = latestVersion 137 | } 138 | } 139 | } 140 | } 141 | } 142 | } 143 | 144 | override fun run() { 145 | if (shouldCheckForUpgrade()) checkForUpgradeAsync() 146 | 147 | doRun() 148 | 149 | // If we were able to connect in time AND see that a new version is available, then show an upgrade 150 | // message. Worst case if the command finished running before we were able to check? We don't show the 151 | // message, but that's not a big deal. 152 | newVersionAvailable?.let { newVersion -> 153 | // If we can create a session, that means we're in a TTY and a user is likely watching. 154 | // In that case, we can print a message to upgrade. Otherwise, e.g. if on a CI, this should 155 | // just fail silently. 156 | trySession { this.reportUpdateAvailable(kobwebCliVersion, newVersion) } 157 | } 158 | } 159 | 160 | protected abstract fun doRun() 161 | } 162 | 163 | // The Kobweb command itself doesn't do anything; it delegates everything to subcommands. 164 | class Kobweb : NoOpCliktCommand() { 165 | init { 166 | context { 167 | helpFormatter = { context -> 168 | KotterHelpFormatter( 169 | context = context, 170 | showDefaultValues = true, 171 | ) 172 | } 173 | helpOptionNames += "help" // Allows "kobweb help" to work 174 | } 175 | } 176 | 177 | } 178 | 179 | class Version : KobwebSubcommand(help = "Print the version of this binary") { 180 | override fun doRun() { 181 | handleVersion() 182 | } 183 | } 184 | 185 | class List : KobwebSubcommand(help = "List all project templates") { 186 | val repo by option(help = "The repository that hosts Kobweb templates.").default(DEFAULT_REPO) 187 | val branch by option(help = "The branch in the repository to use. If not specified, git will attempt to use the repo's default branch.") 188 | 189 | override fun shouldCheckForUpgrade() = true 190 | override fun doRun() { 191 | handleList(repo, branch) 192 | } 193 | } 194 | 195 | class Create : KobwebSubcommand(help = "Create a Kobweb app / site from a template") { 196 | val template by argument(help = "The name of the template to instantiate, e.g. 'app'. If not specified, choices will be presented.").optional() 197 | val repo by option(help = "The repository that hosts Kobweb templates.").default(DEFAULT_REPO) 198 | val branch by option(help = "The branch in the repository to use. If not specified, git will attempt to use the repo's default branch.") 199 | 200 | // Don't check for an upgrade on create, because the user probably just installed kobweb anyway, and the update 201 | // message kind of overwhelms the instructions to start running the app. 202 | override fun shouldCheckForUpgrade() = false 203 | 204 | override fun doRun() { 205 | handleCreate(repo, branch, template) 206 | } 207 | } 208 | 209 | class Export : KobwebSubcommand(help = "Generate a static version of a Kobweb app / site") { 210 | val ttyMode by ttyMode() 211 | val layout by layout() 212 | val path by path() 213 | val gradleArgsCommon by gradleArgs() 214 | val gradleArgsExport by gradleArgs("export") 215 | val gradleArgsStop by gradleArgs("stop") 216 | 217 | override fun shouldCheckForUpgrade() = ttyMode.shouldUseAnsi() 218 | override fun doRun() { 219 | handleExport( 220 | path, 221 | layout, 222 | ttyMode.shouldUseAnsi(), 223 | gradleArgsCommon, 224 | gradleArgsExport, 225 | gradleArgsStop 226 | ) 227 | } 228 | } 229 | 230 | class Run : KobwebSubcommand(help = "Run a Kobweb server") { 231 | val env by option(help = "Whether the server should run in development mode or production.").enum() 232 | .default(ServerEnvironment.DEV) 233 | val ttyMode by ttyMode() 234 | val foreground by option( 235 | "-f", 236 | "--foreground", 237 | help = "Keep kobweb running in the foreground. This value can only be specified in --notty mode." 238 | ).flag(default = false) 239 | val once by option( 240 | "-o", 241 | "--once", 242 | help = "Run without engaging live-reloading. This value can only be specified when --env is set to dev." 243 | ).flag(default = false) 244 | val layout by layout().default(SiteLayout.FULLSTACK) 245 | val path by path() 246 | val gradleArgsCommon by gradleArgs() 247 | val gradleArgsStart by gradleArgs("start") 248 | val gradleArgsStop by gradleArgs("stop") 249 | 250 | override fun shouldCheckForUpgrade() = ttyMode.shouldUseAnsi() 251 | override fun doRun() { 252 | if (foreground && ttyMode != TeleTypeMode.DISABLED) { 253 | throw object : UsageError(null) { 254 | override fun formatMessage(localization: Localization, formatter: ParameterFormatter): String { 255 | return "The ${formatter.formatOption("--foreground")} flag is only valid when running in ${formatter.formatOption("--notty")} mode." 256 | } 257 | } 258 | } 259 | if (once && env != ServerEnvironment.DEV) { 260 | throw object : UsageError(null) { 261 | override fun formatMessage(localization: Localization, formatter: ParameterFormatter): String { 262 | return "The ${formatter.formatOption("--once")} flag is only valid when ${formatter.formatOption("--env")} is set to dev." 263 | } 264 | } 265 | } 266 | handleRun( 267 | env, 268 | path, 269 | layout, 270 | ttyMode.shouldUseAnsi(), 271 | foreground, 272 | once, 273 | gradleArgsCommon, 274 | gradleArgsStart, 275 | gradleArgsStop 276 | ) 277 | } 278 | } 279 | 280 | class Stop : KobwebSubcommand(help = "Stop a Kobweb server if one is running") { 281 | val ttyMode by ttyMode() 282 | val path by path() 283 | val gradleArgsCommon by gradleArgs() 284 | val gradleArgsStop by gradleArgs("stop") 285 | 286 | // Don't check for an upgrade on create, because the user probably just installed kobweb anyway, and the update 287 | // message kind of overwhelms the instructions to start running the app. 288 | override fun shouldCheckForUpgrade() = false 289 | 290 | override fun doRun() { 291 | handleStop(path, ttyMode.shouldUseAnsi(), gradleArgsCommon, gradleArgsStop) 292 | } 293 | } 294 | 295 | class Conf : KobwebSubcommand(help = "Query a value from the .kobweb/conf.yaml file (e.g. \"server.port\")") { 296 | val query by argument(help = "The query to search the .kobweb/conf.yaml for (e.g. \"server.port\"). If not specified, this command will list all possible queries.").optional() 297 | val path by path() 298 | 299 | override fun doRun() { 300 | handleConf(query, path) 301 | } 302 | } 303 | 304 | // Special-case handling for `kobweb -v` and `kobweb --version`, which are special-cased since it's a format that 305 | // is expected for many tools. 306 | if (args.size == 1 && (args[0] == "-v" || args[0] == "--version")) { 307 | handleVersion() 308 | return 309 | } 310 | 311 | Kobweb() 312 | .subcommands(Version(), List(), Create(), Export(), Run(), Stop(), Conf()) 313 | .main(args) 314 | } 315 | -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/com/varabyte/kobweb/cli/common/GradleUtils.kt: -------------------------------------------------------------------------------- 1 | package com.varabyte.kobweb.cli.common 2 | 3 | import com.varabyte.kobweb.cli.common.kotter.handleConsoleOutput 4 | import com.varabyte.kobweb.server.api.ServerEnvironment 5 | import com.varabyte.kobweb.server.api.SiteLayout 6 | import com.varabyte.kotter.foundation.collections.liveListOf 7 | import com.varabyte.kotter.foundation.input.Key 8 | import com.varabyte.kotter.foundation.input.Keys 9 | import com.varabyte.kotter.foundation.liveVarOf 10 | import com.varabyte.kotter.foundation.text.red 11 | import com.varabyte.kotter.foundation.text.text 12 | import com.varabyte.kotter.foundation.text.textLine 13 | import com.varabyte.kotter.foundation.text.yellow 14 | import com.varabyte.kotter.runtime.RunScope 15 | import com.varabyte.kotter.runtime.Session 16 | import com.varabyte.kotter.runtime.concurrent.createKey 17 | import com.varabyte.kotter.runtime.render.RenderScope 18 | import org.gradle.tooling.CancellationTokenSource 19 | import org.gradle.tooling.GradleConnectionException 20 | import org.gradle.tooling.GradleConnector 21 | import org.gradle.tooling.ProjectConnection 22 | import org.gradle.tooling.ResultHandler 23 | import org.gradle.tooling.internal.consumer.DefaultGradleConnector 24 | import java.io.ByteArrayOutputStream 25 | import java.io.Closeable 26 | import java.io.File 27 | import java.io.OutputStream 28 | import java.nio.file.Path 29 | import java.util.concurrent.CountDownLatch 30 | import java.util.concurrent.TimeUnit 31 | 32 | class KobwebGradle(private val env: ServerEnvironment, projectDir: File) : Closeable { 33 | constructor(env: ServerEnvironment, projectDir: Path) : this(env, projectDir.toFile()) 34 | 35 | class OnStartingEvent(val task: String, val args: List) { 36 | /** 37 | * The full command that will be run, including the `gradlew` prefix. 38 | */ 39 | val fullCommand 40 | get() = buildList { 41 | add("$") 42 | add("gradlew") 43 | add(task) 44 | addAll(args) 45 | }.joinToString(" ") 46 | } 47 | 48 | var onStarting: (OnStartingEvent) -> Unit = { println(it.fullCommand) } 49 | 50 | private val gradleConnector = GradleConnector.newConnector().forProjectDirectory(projectDir).also { 51 | // The Gradle daemon spawned by the tooling API seems to stick around and not always get removed by 52 | // ./gradlew --stop for some reason? Adding a timeout seems to be the way that some projects deal with 53 | // leaked daemons go away when working with the Gradle Tooling API 54 | // Note we do a safe "as?" check here, for future safety, but it seems that the `DefaultGradleConnector` 55 | // interface has been used since at least 2014: 56 | // https://discuss.gradle.org/t/setting-org-gradle-daemon-idletimeout-through-tooling-api/5875 57 | (it as? DefaultGradleConnector)?.daemonMaxIdleTime(1, TimeUnit.MINUTES) 58 | } 59 | private val projectConnection: ProjectConnection = gradleConnector.connect() 60 | 61 | private val handles = mutableListOf() 62 | 63 | override fun close() { 64 | // Cancel any in-flight tasks (especially continuous ones). Otherwise, `projectConnection.close()` can hang. 65 | handles.toList().forEach { it.cancel() } 66 | handles.clear() 67 | 68 | projectConnection.close() 69 | gradleConnector.disconnect() 70 | } 71 | 72 | class Handle internal constructor(private val cancellationSource: CancellationTokenSource) { 73 | var lineHandler: (line: String, isError: Boolean) -> Unit = { line, isError -> 74 | if (isError) { 75 | System.err.println(line) 76 | } else { 77 | println(line) 78 | } 79 | } 80 | 81 | var onCompleted: (failure: Exception?) -> Unit = { } 82 | 83 | internal inner class HandleOutputStream(private val isError: Boolean) : OutputStream() { 84 | private val delegateStream = ByteArrayOutputStream() 85 | override fun write(b: Int) { 86 | if (b == 10) { 87 | lineHandler.invoke(delegateStream.toString(), isError) 88 | delegateStream.reset() 89 | } else if (b != 13) { // Skip newline bytes on Windows 90 | delegateStream.write(b) 91 | } 92 | } 93 | } 94 | 95 | // Latch will be counted down by Gradle when it finishes; see ResultHandler code elsewhere. 96 | internal val latch = CountDownLatch(1) 97 | 98 | fun cancel() { 99 | cancellationSource.cancel() 100 | } 101 | 102 | fun waitFor() { 103 | latch.await() 104 | } 105 | } 106 | 107 | fun gradlew(task: String, vararg args: String): Handle { 108 | val finalArgs = args.toList() + "--stacktrace" 109 | val cancelToken = GradleConnector.newCancellationTokenSource() 110 | val handle = Handle(cancelToken) 111 | 112 | onStarting(OnStartingEvent(task, finalArgs)) 113 | projectConnection.newBuild() 114 | .setStandardOutput(handle.HandleOutputStream(isError = false)) 115 | .setStandardError(handle.HandleOutputStream(isError = true)) 116 | .forTasks(task) 117 | .withArguments(finalArgs) 118 | .withCancellationToken(cancelToken.token()) 119 | .run(object : ResultHandler { 120 | private fun handleFinished() { 121 | handles.remove(handle) 122 | handle.latch.countDown() 123 | } 124 | 125 | override fun onComplete(result: Void?) { 126 | handle.onCompleted.invoke(null) 127 | handleFinished() 128 | } 129 | 130 | override fun onFailure(failure: GradleConnectionException) { 131 | handle.onCompleted.invoke(failure) 132 | handleFinished() 133 | } 134 | }) 135 | 136 | return handle.also { handles.add(it) } 137 | } 138 | 139 | fun startServer( 140 | enableLiveReloading: Boolean, 141 | siteLayout: SiteLayout, 142 | extraGradleArgs: List = emptyList(), 143 | ): Handle { 144 | val args = mutableListOf("-PkobwebEnv=$env", "-PkobwebRunLayout=$siteLayout") 145 | if (enableLiveReloading) { 146 | args.add("-t") 147 | } 148 | args.addAll(extraGradleArgs) 149 | return gradlew("kobwebStart", *args.toTypedArray()) 150 | } 151 | 152 | fun stopServer(extraGradleArgs: List = emptyList()): Handle { 153 | return gradlew("kobwebStop", *extraGradleArgs.toTypedArray()) 154 | } 155 | 156 | fun export(siteLayout: SiteLayout, extraGradleArgs: List = emptyList()): Handle { 157 | // Even if we are exporting a non-Kobweb layout, we still want to start up a dev server using a Kobweb layout so 158 | // it looks for the source files in the right place. 159 | return gradlew( 160 | "kobwebExport", 161 | "-PkobwebReuseServer=false", 162 | "-PkobwebEnv=DEV", 163 | "-PkobwebRunLayout=FULLSTACK", 164 | "-PkobwebBuildTarget=RELEASE", 165 | "-PkobwebExportLayout=$siteLayout", 166 | *extraGradleArgs.toTypedArray() 167 | ) 168 | } 169 | } 170 | 171 | fun KobwebGradle.Handle.waitForAndCheckForException(): Exception? { 172 | var failure: Exception? = null 173 | onCompleted = { failure = it } 174 | waitFor() 175 | return failure 176 | } 177 | 178 | private const val GRADLE_ERROR_PREFIX = "e: " 179 | private const val GRADLE_WARNING_PREFIX = "w: " 180 | private const val GRADLE_WHAT_WENT_WRONG = "* What went wrong:" 181 | private const val GRADLE_TRY_PREFIX = "* Try:" 182 | private const val GRADLE_TASK_PREFIX = "> Task :" 183 | 184 | sealed interface GradleAlert { 185 | class Warning(val line: String) : GradleAlert 186 | class Error(val line: String) : GradleAlert 187 | class Task(val task: String) : GradleAlert 188 | object BuildRestarted : GradleAlert 189 | } 190 | 191 | private val WhatWentWrongKey = RunScope.Lifecycle.createKey() 192 | 193 | fun RunScope.handleGradleOutput(line: String, isError: Boolean, onGradleEvent: (GradleAlert) -> Unit) { 194 | handleConsoleOutput(line, isError) 195 | 196 | if (line.startsWith(GRADLE_ERROR_PREFIX)) { 197 | onGradleEvent(GradleAlert.Error(line.removePrefix(GRADLE_ERROR_PREFIX))) 198 | } else if (line.startsWith(GRADLE_WARNING_PREFIX)) { 199 | onGradleEvent(GradleAlert.Warning(line.removePrefix(GRADLE_WARNING_PREFIX))) 200 | } else if (line.startsWith(GRADLE_TASK_PREFIX)) { 201 | onGradleEvent(GradleAlert.Task(line.removePrefix(GRADLE_TASK_PREFIX).substringBefore(' '))) 202 | } else if (line == "Change detected, executing build...") { 203 | onGradleEvent(GradleAlert.BuildRestarted) 204 | } 205 | // For the next two else statements, error messages appear sandwiched between "what went wrong:" and "try:" blocks. 206 | // We surface just the error message for now. We'll see in practice if that results in confusing output or not 207 | // based on user reports... 208 | else if (line == GRADLE_WHAT_WENT_WRONG) { 209 | data[WhatWentWrongKey] = StringBuilder() 210 | } else if (data.contains(WhatWentWrongKey)) { 211 | if (line.startsWith(GRADLE_TRY_PREFIX)) { 212 | data.remove(WhatWentWrongKey) { 213 | val sb = this 214 | // Remove a trailing newline, which separated previous error text from the 215 | // "* Try:" block below it. 216 | onGradleEvent(GradleAlert.Error(sb.toString().trimEnd())) 217 | } 218 | } else { 219 | data.getValue(WhatWentWrongKey).appendLine(line) 220 | } 221 | } 222 | } 223 | 224 | /** 225 | * Class which handles the collection and rendering of Gradle compile warnings and errors. 226 | */ 227 | // Why 5 errors only? As errors may be ~2-3 lines long with a space between them, 5 errors 228 | // would easily take up about 20-25 lines. If we end rerendering the whole screen, this 229 | // causes annoying flickering on Windows. So try to choose a small enough value, aiming to avoid 230 | // Windows flickering while still presenting enough information for users. If people complain, 231 | // 5 is too small, we can allow users to configure this somehow, e.g. via CLI params. 232 | class GradleAlertBundle(session: Session, private val pageSize: Int = 5) { 233 | private val warnings = session.liveListOf() 234 | private val errors = session.liveListOf() 235 | var hasFirstTaskRun by session.liveVarOf(false) 236 | private set 237 | private var startIndex by session.liveVarOf(0) 238 | private var stuckToEnd = false 239 | private val maxIndex get() = (warnings.size + errors.size - pageSize).coerceAtLeast(0) 240 | 241 | fun handleAlert(alert: GradleAlert) { 242 | when (alert) { 243 | is GradleAlert.BuildRestarted -> { 244 | startIndex = 0 245 | stuckToEnd = false 246 | warnings.clear() 247 | errors.clear() 248 | } 249 | 250 | is GradleAlert.Task -> { 251 | hasFirstTaskRun = true 252 | } 253 | 254 | is GradleAlert.Warning -> { 255 | warnings.add(alert) 256 | } 257 | 258 | is GradleAlert.Error -> { 259 | errors.add(alert) 260 | } 261 | } 262 | 263 | if (stuckToEnd) { 264 | startIndex = maxIndex 265 | } 266 | } 267 | 268 | fun handleKey(key: Key): Boolean { 269 | var handled = true 270 | when (key) { 271 | Keys.HOME -> { 272 | startIndex = 0 273 | stuckToEnd = false 274 | } 275 | 276 | Keys.END -> { 277 | startIndex = maxIndex 278 | stuckToEnd = true 279 | } 280 | 281 | Keys.UP -> { 282 | startIndex = (startIndex - 1).coerceAtLeast(0) 283 | stuckToEnd = false 284 | } 285 | 286 | Keys.PAGE_UP -> { 287 | startIndex = (startIndex - pageSize).coerceAtLeast(0) 288 | stuckToEnd = false 289 | } 290 | 291 | Keys.DOWN -> { 292 | startIndex = (startIndex + 1).coerceAtMost(maxIndex) 293 | stuckToEnd = (startIndex == maxIndex) 294 | } 295 | 296 | Keys.PAGE_DOWN -> { 297 | startIndex = (startIndex + pageSize).coerceAtMost(maxIndex) 298 | stuckToEnd = (startIndex == maxIndex) 299 | } 300 | 301 | else -> handled = false 302 | } 303 | return handled 304 | } 305 | 306 | fun renderInto(renderScope: RenderScope) { 307 | renderScope.apply { 308 | if (!hasFirstTaskRun) { 309 | yellow { textLine("Output may seem to pause for a while if Kobweb needs to download / resolve dependencies.") } 310 | textLine() 311 | } 312 | } 313 | 314 | val totalMessageCount = warnings.size + errors.size 315 | if (totalMessageCount == 0) return 316 | 317 | renderScope.apply { 318 | yellow { 319 | text("Found ${errors.size} error(s) and ${warnings.size} warning(s).") 320 | if (errors.isNotEmpty()) { 321 | text(" Please resolve errors to continue.") 322 | } 323 | textLine() 324 | } 325 | textLine() 326 | if (startIndex > 0) { 327 | textLine("... Press UP, PAGE UP, or HOME to see earlier errors.") 328 | } 329 | for (i in startIndex until (startIndex + pageSize)) { 330 | val alert = if (i < errors.size) { 331 | errors[i] 332 | } else if (i < totalMessageCount) { 333 | warnings[i - errors.size] 334 | } else { 335 | break 336 | } 337 | 338 | if (i > startIndex) textLine() 339 | text("${i + 1}: ") 340 | when (alert) { 341 | is GradleAlert.Error -> red { textLine(alert.line) } 342 | is GradleAlert.Warning -> yellow { textLine(alert.line) } 343 | else -> error("Unexpected alert type: $alert") 344 | } 345 | } 346 | if (startIndex < maxIndex) { 347 | textLine("... Press DOWN, PAGE DOWN, or END to see later errors.") 348 | } 349 | textLine() 350 | } 351 | } 352 | } 353 | -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/com/varabyte/kobweb/cli/help/KotterHelpFormatter.kt: -------------------------------------------------------------------------------- 1 | package com.varabyte.kobweb.cli.help 2 | 3 | import com.github.ajalt.clikt.core.Context 4 | import com.github.ajalt.clikt.core.UsageError 5 | import com.github.ajalt.clikt.output.AbstractHelpFormatter 6 | import com.github.ajalt.clikt.output.HelpFormatter 7 | import com.varabyte.kotter.foundation.text.blue 8 | import com.varabyte.kotter.foundation.text.bold 9 | import com.varabyte.kotter.foundation.text.red 10 | import com.varabyte.kotter.foundation.text.text 11 | import com.varabyte.kotter.foundation.text.white 12 | import com.varabyte.kotter.foundation.text.yellow 13 | import com.varabyte.kotter.runtime.render.RenderScope 14 | import com.varabyte.kotterx.util.buildAnsiString 15 | 16 | fun RenderScope.sectionTitleColor(block: RenderScope.() -> Unit) { 17 | yellow(isBright = true) { bold { block() } } 18 | } 19 | 20 | fun RenderScope.optionNameColor(block: RenderScope.() -> Unit) { 21 | blue { bold { block() } } 22 | } 23 | 24 | private fun pad(amount: Int) = " ".repeat(amount) 25 | 26 | private const val MAX_CONTENT_WIDTH = 80 27 | 28 | private const val START_PADDING = 2 29 | 30 | // If an option name + meta is too long, add a newline before showing its description, e.g. 31 | // 32 | // Options: 33 | // 34 | // --foo This is a description of foo 35 | // -l, --layout=(VERTICAL|HORIZONTAL|GRID) 36 | // This is a description of layout <- starts on newline 37 | private const val MAX_OPTION_DESC_INDENT = 26 38 | 39 | private const val TERM_DESC_MARGIN = 2 40 | 41 | // Kotter's buildAnsiString puts a trailing newline at the end of the output; we don't want that here 42 | private fun inlineAnsiString(block: RenderScope.() -> Unit): String { 43 | return buildAnsiString(block).removeSuffix("\n") 44 | } 45 | 46 | private fun CharSequence.stripAnsiEscapeCodes(): String { 47 | return this.replace(Regex("\u001B\\[[;\\d]*m"), "") 48 | } 49 | 50 | private val CharSequence.lengthWithoutAnsi: Int get() = this.stripAnsiEscapeCodes().length 51 | 52 | @Suppress("RedundantOverride") 53 | class KotterHelpFormatter( 54 | /** 55 | * The current command's context. 56 | */ 57 | context: Context, 58 | /** 59 | * The string to show before the names of required options, or null to not show a mark. 60 | */ 61 | requiredOptionMarker: String? = null, 62 | /** 63 | * If true, the default values will be shown in the help text for parameters that have them. 64 | */ 65 | showDefaultValues: Boolean = false, 66 | /** 67 | * If true, a tag indicating the parameter is required will be shown after the description of 68 | * required parameters. 69 | */ 70 | showRequiredTag: Boolean = false, 71 | ) : AbstractHelpFormatter( 72 | context, 73 | requiredOptionMarker, 74 | showDefaultValues, 75 | showRequiredTag 76 | ) { 77 | override fun formatHelp( 78 | error: UsageError?, 79 | prolog: String, 80 | epilog: String, 81 | parameters: List, 82 | programName: String, 83 | ): String { 84 | val parts = collectHelpParts(error, prolog, epilog, parameters, programName) 85 | return parts.joinToString("\n\n") { it.removeSuffix("\n") } 86 | } 87 | 88 | private fun wrap(text: String, pad: Int = 0, width: Int = MAX_CONTENT_WIDTH - pad): String { 89 | return text.split('\n').joinToString("\n") { line -> 90 | if (line.lengthWithoutAnsi > width) { 91 | buildString { 92 | val remaining = StringBuilder(line) 93 | val nextWord = StringBuilder() 94 | var lineLength = 0 95 | 96 | fun applyInitialPadding() { 97 | if (lineLength == 0 && pad > 0) { 98 | append(" ".repeat(pad)) 99 | lineLength += pad 100 | } 101 | } 102 | 103 | fun flushWord() { 104 | if (lineLength + nextWord.lengthWithoutAnsi > width) { 105 | if (lineLength > 0) { 106 | append('\n') 107 | lineLength = 0 108 | while (nextWord.first().isWhitespace()) nextWord.deleteAt(0) 109 | } 110 | } 111 | 112 | while (nextWord.lengthWithoutAnsi > width) { 113 | applyInitialPadding() 114 | appendLine(nextWord.substring(0, width)) 115 | nextWord.deleteRange(0, width) 116 | } 117 | 118 | applyInitialPadding() 119 | append(nextWord) 120 | lineLength += nextWord.lengthWithoutAnsi 121 | nextWord.clear() 122 | } 123 | 124 | while (remaining.isNotEmpty()) { 125 | val nextChar = remaining.first(); remaining.deleteAt(0) 126 | if (nextChar.isWhitespace() && nextWord.isNotEmpty()) { 127 | flushWord() 128 | } 129 | nextWord.append(nextChar) 130 | } 131 | flushWord() 132 | } 133 | } 134 | else { 135 | line 136 | } 137 | } 138 | } 139 | 140 | override fun styleRequiredMarker(name: String): String { 141 | return super.styleRequiredMarker(name) 142 | } 143 | 144 | // Used for misc tooltips 145 | // 146 | // --env=(DEV|PROD) The environment to run in. >>(default: DEV)<< 147 | override fun styleHelpTag(name: String): String { 148 | return super.styleHelpTag(name) 149 | } 150 | 151 | // Used for option names 152 | // 153 | // Options: 154 | // 155 | // >>--env<<=(DEV|PROD) 156 | // >>-h<< 157 | override fun styleOptionName(name: String): String { 158 | return inlineAnsiString { 159 | optionNameColor { text(name) } 160 | } 161 | } 162 | 163 | // Used for argument names 164 | // 165 | // Arguments: 166 | // >><< The file to perform an operation on 167 | override fun styleArgumentName(name: String): String { 168 | return styleOptionName(name) 169 | } 170 | 171 | // Used for subcommand names 172 | // 173 | // Commands: 174 | // >>version<< Print the version of this binary 175 | // >>run<< Run a server 176 | override fun styleSubcommandName(name: String): String { 177 | return styleOptionName(name) 178 | } 179 | 180 | // Used for section titles 181 | // 182 | // >>Options:<< 183 | // ... 184 | // 185 | // >>Commands:<< 186 | // ... 187 | override fun styleSectionTitle(title: String): String { 188 | return inlineAnsiString { 189 | sectionTitleColor { text(title) } 190 | } 191 | } 192 | 193 | // >>Usage:<< ... 194 | override fun styleUsageTitle(title: String): String { 195 | return styleSectionTitle(title) 196 | } 197 | 198 | // >>Error:<< ... 199 | override fun styleError(title: String): String { 200 | return inlineAnsiString { 201 | red { text(title) } 202 | } 203 | } 204 | 205 | // Example args in option list 206 | // 207 | // Options: 208 | // 209 | // --env=>>(DEV|PROD)<< 210 | // --path=>><< 211 | override fun styleMetavar(metavar: String): String { 212 | return inlineAnsiString { yellow { text(metavar.lowercase()) } } 213 | } 214 | 215 | 216 | // Usage: command >>[] []<< 217 | override fun styleOptionalUsageParameter(parameter: String): String { 218 | return inlineAnsiString { 219 | white(isBright = false) { text(parameter) } 220 | } 221 | } 222 | 223 | override fun styleRequiredUsageParameter(parameter: String): String { 224 | return super.styleRequiredUsageParameter(parameter) 225 | } 226 | 227 | // Error: no such subcommand foo 228 | override fun renderError( 229 | parameters: List, 230 | error: UsageError, 231 | ) = renderErrorString(parameters, error) 232 | 233 | // Usage: foo [] [] 234 | override fun renderUsage( 235 | parameters: List, 236 | programName: String, 237 | ) = buildString { 238 | val params = renderUsageParametersString(parameters) 239 | val title = localization.usageTitle() 240 | 241 | append(styleUsageTitle(title)) 242 | append(' ') 243 | append(programName) 244 | if (params.isNotEmpty()) { 245 | append(' ') 246 | append(params) 247 | } 248 | } 249 | 250 | // Usage: command 251 | // 252 | // >> Do a command << 253 | // 254 | // Options: 255 | // ... 256 | override fun renderProlog(prolog: String): String { 257 | return wrap(prolog, pad = 2) 258 | } 259 | 260 | // ??? 261 | override fun renderEpilog(epilog: String): String { 262 | return wrap(epilog) 263 | } 264 | 265 | // Options: 266 | // 267 | // -h, --help Show this message and exit 268 | // --env The environment to run in 269 | // 270 | // Commands: 271 | // 272 | // run Run a server 273 | // stop Stop the server 274 | // 275 | // Above, "Options:" and "Commands:" are titles and the rest is the content. You can have multiple sections, one 276 | // after the other, and they should be separated by a newline. 277 | override fun renderParameters(parameters: List): String { 278 | return buildString { 279 | collectParameterSections(parameters).forEach { (title, content) -> 280 | appendLine(title) 281 | appendLine() 282 | appendLine(content) 283 | appendLine() 284 | } 285 | } 286 | } 287 | 288 | // A list of options being rendered 289 | // 290 | // Options: 291 | // 292 | // --foo 293 | // --bar 294 | // --baz 295 | // 296 | // `parameters` is a list of those options. help may exist if rendering an option group 297 | override fun renderOptionGroup( 298 | help: String?, 299 | parameters: List, 300 | ): String = buildString { 301 | if (help != null) { 302 | appendLine() 303 | appendLine(wrap(help, pad = 2)) 304 | appendLine() 305 | } 306 | val options = parameters.map { renderOptionDefinition(it) } 307 | append(buildParameterList(options)) 308 | } 309 | 310 | // --env=(DEV|PROD) The environment to run in 311 | // ^^^^(term)^^^^^^ ^^^^^^(definition)^^^^^^^ 312 | // 313 | // Marker will be set for required options (which Kobweb does not have at the moment) 314 | override fun renderDefinitionTerm(row: DefinitionRow): String { 315 | val rowMarker = row.marker 316 | val termPrefix = when { 317 | rowMarker.isNullOrEmpty() -> pad(START_PADDING) 318 | else -> rowMarker + pad(START_PADDING).drop(rowMarker.length).ifEmpty { " " } 319 | } 320 | return termPrefix + row.term 321 | } 322 | 323 | override fun renderDefinitionDescription(row: DefinitionRow): String { 324 | val optionRegex = Regex("--\\w+") // e.g. "--option" 325 | val inlineCodeRegex = Regex("`(\\w+)`") // e.g. "`command`" 326 | val defaultValueRegex = Regex("\\(default: (.+)\\)") 327 | 328 | var result = row.description.replace(" (default: none)", "") 329 | result = optionRegex.replace(result) { matchResult -> 330 | styleOptionName(matchResult.value) 331 | } 332 | result = inlineCodeRegex.replace(result) { matchResult -> 333 | styleMetavar(matchResult.groupValues[1]) 334 | } 335 | result = defaultValueRegex.replace(result) { matchResult -> 336 | "(default: ${styleMetavar(matchResult.groupValues[1])})" 337 | } 338 | return result 339 | } 340 | 341 | // Handle rendering out the list of terms to descriptions. 342 | // We do some calculations so the options feel like they exist in a table, for example: 343 | // 344 | // ``` 345 | // Options: 346 | // 347 | // --env=(DEV|PROD) Whether the server should run in development mode or 348 | // production. (default: DEV) 349 | // -t, --tty Enable TTY support (default). Tries to run using ANSI 350 | // support in an interactive mode if it can. Falls back 351 | // to `--notty` otherwise. 352 | // --notty Explicitly disable TTY support. In this case, runs in 353 | // plain mode, logging output sequentially without 354 | // listening for user input, which is useful for CI 355 | // environments or Docker containers. 356 | // ... 357 | // ``` 358 | // 359 | // If the initial term is too long, we start the description on a new line. 360 | // 361 | // ``` 362 | // ... 363 | // -f, --foreground Keep kobweb running in the foreground. This value is 364 | // ignored unless in --notty mode. 365 | // -l, --layout=(FULLSTACK|STATIC|KOBWEB) 366 | // Specify the organizational layout of the site files. 367 | // NOTE: The option `kobweb` is deprecated and will be 368 | // removed in a future version. Please use `fullstack` 369 | // instead. (default: FULLSTACK) 370 | // -p, --path= The path to the Kobweb application module. (default: 371 | // the current directory) 372 | // ... 373 | // ``` 374 | override fun buildParameterList(rows: List): String { 375 | val rawTerms = rows.map { it.term.stripAnsiEscapeCodes() } 376 | val maxTermLength = (rawTerms.filter { it.length < MAX_OPTION_DESC_INDENT }.maxOfOrNull { it.length } ?: MAX_OPTION_DESC_INDENT) 377 | val indent = " ".repeat(maxTermLength + START_PADDING + 2) 378 | return rows.joinToString("\n") { row -> 379 | val term = renderDefinitionTerm(row) 380 | val rawTerm = row.term.stripAnsiEscapeCodes() 381 | val definition = wrap(renderDefinitionDescription(row), width = MAX_CONTENT_WIDTH - maxTermLength - START_PADDING - TERM_DESC_MARGIN) 382 | .replace("\n", "\n$indent") 383 | 384 | if (rawTerm.length < MAX_OPTION_DESC_INDENT) { 385 | term + pad(maxTermLength - rawTerm.length + TERM_DESC_MARGIN) + definition 386 | } else { 387 | term + "\n" + pad(START_PADDING + maxTermLength + TERM_DESC_MARGIN) + definition 388 | } 389 | } 390 | } 391 | } 392 | -------------------------------------------------------------------------------- /kobweb/src/main/kotlin/com/varabyte/kobweb/cli/run/Run.kt: -------------------------------------------------------------------------------- 1 | package com.varabyte.kobweb.cli.run 2 | 3 | import com.github.ajalt.clikt.core.CliktError 4 | import com.varabyte.kobweb.cli.common.Anims 5 | import com.varabyte.kobweb.cli.common.GradleAlertBundle 6 | import com.varabyte.kobweb.cli.common.KobwebExecutionEnvironment 7 | import com.varabyte.kobweb.cli.common.assertServerNotAlreadyRunning 8 | import com.varabyte.kobweb.cli.common.findKobwebConfIn 9 | import com.varabyte.kobweb.cli.common.findKobwebExecutionEnvironment 10 | import com.varabyte.kobweb.cli.common.handleGradleOutput 11 | import com.varabyte.kobweb.cli.common.isServerAlreadyRunningFor 12 | import com.varabyte.kobweb.cli.common.kotter.handleConsoleOutput 13 | import com.varabyte.kobweb.cli.common.kotter.informGradleStarting 14 | import com.varabyte.kobweb.cli.common.kotter.newline 15 | import com.varabyte.kobweb.cli.common.kotter.textInfo 16 | import com.varabyte.kobweb.cli.common.kotter.trySession 17 | import com.varabyte.kobweb.cli.common.kotter.warn 18 | import com.varabyte.kobweb.cli.common.kotter.warnFallingBackToPlainText 19 | import com.varabyte.kobweb.cli.common.showStaticSiteLayoutWarning 20 | import com.varabyte.kobweb.cli.common.version.KobwebServerFeatureVersions 21 | import com.varabyte.kobweb.cli.common.version.SemVer 22 | import com.varabyte.kobweb.cli.common.waitForAndCheckForException 23 | import com.varabyte.kobweb.cli.help.optionNameColor 24 | import com.varabyte.kobweb.cli.help.sectionTitleColor 25 | import com.varabyte.kobweb.cli.stop.handleStop 26 | import com.varabyte.kobweb.common.navigation.BasePath 27 | import com.varabyte.kobweb.project.conf.KobwebConfFile 28 | import com.varabyte.kobweb.server.api.ServerEnvironment 29 | import com.varabyte.kobweb.server.api.ServerRequest 30 | import com.varabyte.kobweb.server.api.ServerRequestsFile 31 | import com.varabyte.kobweb.server.api.ServerState 32 | import com.varabyte.kobweb.server.api.ServerStateFile 33 | import com.varabyte.kobweb.server.api.SiteLayout 34 | import com.varabyte.kotter.foundation.anim.Anim 35 | import com.varabyte.kotter.foundation.anim.textAnimOf 36 | import com.varabyte.kotter.foundation.anim.textLine 37 | import com.varabyte.kotter.foundation.input.Keys 38 | import com.varabyte.kotter.foundation.input.onKeyPressed 39 | import com.varabyte.kotter.foundation.liveVarOf 40 | import com.varabyte.kotter.foundation.runUntilSignal 41 | import com.varabyte.kotter.foundation.shutdown.addShutdownHook 42 | import com.varabyte.kotter.foundation.text.bold 43 | import com.varabyte.kotter.foundation.text.cyan 44 | import com.varabyte.kotter.foundation.text.green 45 | import com.varabyte.kotter.foundation.text.red 46 | import com.varabyte.kotter.foundation.text.text 47 | import com.varabyte.kotter.foundation.text.textLine 48 | import com.varabyte.kotter.foundation.text.yellow 49 | import com.varabyte.kotter.foundation.timer.addTimer 50 | import com.varabyte.kotter.runtime.render.RenderScope 51 | import kotlinx.coroutines.CoroutineScope 52 | import kotlinx.coroutines.Dispatchers 53 | import kotlinx.coroutines.coroutineScope 54 | import kotlinx.coroutines.delay 55 | import kotlinx.coroutines.launch 56 | import kotlinx.coroutines.runBlocking 57 | import okhttp3.OkHttpClient 58 | import okhttp3.Request 59 | import java.io.File 60 | import java.nio.file.Files 61 | import kotlin.time.Duration.Companion.milliseconds 62 | 63 | private enum class RunState { 64 | STARTING, 65 | RUNNING, 66 | STOPPING, 67 | STOPPED, 68 | CANCELLING, 69 | CANCELLED, 70 | INTERRUPTED, 71 | } 72 | 73 | private val ServerState.url: String get() = "http://localhost:$port" 74 | 75 | fun handleRun( 76 | env: ServerEnvironment, 77 | projectDir: File, 78 | siteLayout: SiteLayout, 79 | useAnsi: Boolean, 80 | runInForeground: Boolean, 81 | runOnce: Boolean, 82 | gradleArgsCommon: List, 83 | gradleArgsStart: List, 84 | gradleArgsStop: List, 85 | ) { 86 | val kobwebExecutionEnvironment = findKobwebExecutionEnvironment(env, projectDir.toPath(), useAnsi) 87 | ?: return // Error message already printed 88 | 89 | kobwebExecutionEnvironment.use { 90 | handleRun( 91 | env, 92 | siteLayout, 93 | useAnsi, 94 | runInForeground, 95 | runOnce, 96 | kobwebExecutionEnvironment, 97 | gradleArgsCommon, 98 | gradleArgsStart, 99 | gradleArgsStop, 100 | ) 101 | } 102 | } 103 | 104 | private fun handleRun( 105 | env: ServerEnvironment, 106 | siteLayout: SiteLayout, 107 | useAnsi: Boolean, 108 | runInForeground: Boolean, 109 | runOnce: Boolean, 110 | kobwebExecutionEnvironment: KobwebExecutionEnvironment, 111 | gradleArgsCommon: List, 112 | gradleArgsStart: List, 113 | gradleArgsStop: List, 114 | ) { 115 | val kobwebApplication = kobwebExecutionEnvironment.application 116 | val kobwebGradle = kobwebExecutionEnvironment.gradle 117 | var restartRequested = false 118 | 119 | var runInPlainMode = !useAnsi 120 | if (useAnsi && !trySession { 121 | if (runInForeground) { 122 | warn("User requested running in foreground mode, which will be ignored in interactive mode.") 123 | } 124 | 125 | if (isServerAlreadyRunningFor(kobwebApplication, kobwebGradle)) return@trySession 126 | 127 | val kobwebFolder = kobwebApplication.kobwebFolder 128 | val conf = findKobwebConfIn(kobwebFolder) ?: return@trySession 129 | 130 | newline() // Put space between user prompt and eventual first line of Gradle output 131 | 132 | if (siteLayout.isStatic) { 133 | showStaticSiteLayoutWarning() 134 | } 135 | 136 | val envName = when (env) { 137 | ServerEnvironment.DEV -> "development" 138 | ServerEnvironment.PROD -> "production" 139 | } 140 | val serverStateFile = ServerStateFile(kobwebFolder) 141 | 142 | val gradleAlertBundle = GradleAlertBundle(this) 143 | var userRequestedCancelWhileBuilding = false 144 | 145 | run { 146 | val ellipsisAnim = textAnimOf(Anims.ELLIPSIS) 147 | var runState by liveVarOf(RunState.STARTING) 148 | var showHelp by liveVarOf(false) 149 | var kobwebConfChanged by liveVarOf(false) 150 | var canToggleLiveReloading by liveVarOf(false) 151 | var liveReloadingPaused: Boolean by liveVarOf(false) 152 | var serverState: ServerState? = null // Set on and after RunState.RUNNING 153 | var cancelReason by liveVarOf("") 154 | var exception by liveVarOf(null) // Set if RunState.INTERRUPTED 155 | // If a base path is set, we'll add it to the server URL (at which point we'll need to add slash dividers) 156 | val basePath = BasePath(conf.site.basePath) 157 | section { 158 | textLine() // Add text line between this block and Gradle output above 159 | 160 | when (runState) { 161 | RunState.STARTING -> { 162 | textLine("Starting a Kobweb server ($envName)$ellipsisAnim") 163 | textLine() 164 | gradleAlertBundle.renderInto(this) 165 | textLine("Press Q anytime to cancel.") 166 | } 167 | 168 | RunState.RUNNING -> { 169 | serverState!!.let { serverState -> 170 | green { 171 | text("Kobweb server ($envName) is running at ") 172 | cyan { text("${serverState.url}$basePath") } 173 | } 174 | textLine(" (PID = ${serverState.pid})") 175 | if (liveReloadingPaused) { 176 | yellow { 177 | textLine("Live reloading is now PAUSED. Press P to unpause.") 178 | } 179 | } 180 | textLine() 181 | gradleAlertBundle.renderInto(this) 182 | 183 | if (!showHelp) { 184 | textLine("Press Q anytime to stop the server. Press H for help.") 185 | } else { 186 | sectionTitleColor { 187 | textLine("Commands:") 188 | } 189 | textLine() 190 | 191 | val commands = buildMap Unit> { 192 | put("h|help") { text("Toggle this help view") } 193 | put("q|quit") { text("Shutdown the server") } 194 | if (canToggleLiveReloading) { 195 | put("p|pause") { 196 | text("Toggle live reloading [") 197 | yellow { 198 | text(if (liveReloadingPaused) "OFF" else "ON") 199 | } 200 | text("]") 201 | } 202 | } 203 | } 204 | 205 | val longestCommandLength = commands.keys.maxOf { it.length } 206 | commands 207 | .forEach { (command, renderDescription) -> 208 | text(" ") 209 | val (key, name) = command.split("|", limit = 2) 210 | optionNameColor { text(key) } 211 | text(" ") 212 | bold { text(name) } 213 | text(" ".repeat(longestCommandLength - command.length) + " ") 214 | renderDescription() 215 | textLine() 216 | } 217 | } 218 | 219 | if (kobwebConfChanged) { 220 | textLine() 221 | yellow { 222 | textLine("Kobweb configuration has changed. Press R to restart the server.") 223 | } 224 | } 225 | } 226 | } 227 | 228 | RunState.STOPPING -> { 229 | text("Server is stopping") 230 | serverState?.let { serverState -> 231 | text(" (PID = ${serverState.pid})") 232 | } 233 | textLine(ellipsisAnim) 234 | } 235 | 236 | RunState.STOPPED -> { 237 | textLine("Server was stopped.") 238 | } 239 | 240 | RunState.CANCELLING -> { 241 | check(cancelReason.isNotBlank()) 242 | yellow { textLine("Cancelling: $cancelReason$ellipsisAnim") } 243 | } 244 | 245 | RunState.CANCELLED -> { 246 | yellow { textLine("Cancelled: $cancelReason") } 247 | } 248 | 249 | RunState.INTERRUPTED -> { 250 | red { textLine("Interrupted by exception:") } 251 | textLine() 252 | textLine(exception!!.stackTraceToString()) 253 | } 254 | } 255 | }.runUntilSignal { 256 | kobwebGradle.onStarting = ::informGradleStarting 257 | val startServerProcess = try { 258 | kobwebGradle.startServer( 259 | enableLiveReloading = (env == ServerEnvironment.DEV && !runOnce), 260 | siteLayout, 261 | gradleArgsCommon + gradleArgsStart, 262 | ) 263 | } catch (ex: Exception) { 264 | exception = ex 265 | runState = RunState.INTERRUPTED 266 | return@runUntilSignal 267 | } 268 | startServerProcess.lineHandler = { line, isError -> 269 | handleGradleOutput(line, isError) { alert -> gradleAlertBundle.handleAlert(alert) } 270 | } 271 | 272 | addShutdownHook { 273 | if (runState == RunState.RUNNING || runState == RunState.STOPPING) { 274 | cancelReason = 275 | "CTRL-C received. We kicked off a request to stop the server but we have to exit NOW before waiting for a confirmation." 276 | runState = RunState.CANCELLED 277 | 278 | ServerRequestsFile(kobwebFolder).enqueueRequest(ServerRequest.Stop()) 279 | } else { 280 | cancelReason = "CTRL-C received. Server startup cancelled." 281 | runState = RunState.CANCELLED 282 | } 283 | signal() 284 | } 285 | 286 | onKeyPressed { 287 | var keyHandled = false 288 | if ( 289 | (runState in listOf(RunState.STARTING, RunState.RUNNING) && key in listOf( 290 | Keys.EOF, Keys.Q, Keys.Q_UPPER)) 291 | || (runState == RunState.RUNNING && key == Keys.R) 292 | ) { 293 | if (runState == RunState.STARTING) { 294 | keyHandled = true 295 | runState = RunState.STOPPING 296 | CoroutineScope(Dispatchers.IO).launch { 297 | startServerProcess.cancel() 298 | startServerProcess.waitFor() 299 | cancelReason = "User quit before server could confirm it had started up." 300 | runState = RunState.CANCELLED 301 | userRequestedCancelWhileBuilding = true 302 | signal() 303 | } 304 | } else if (runState == RunState.RUNNING) { 305 | keyHandled = true 306 | runState = RunState.STOPPING 307 | if (key == Keys.R) { restartRequested = true } 308 | CoroutineScope(Dispatchers.IO).launch { 309 | startServerProcess.cancel() 310 | startServerProcess.waitFor() 311 | 312 | val stopServerProcess = kobwebGradle.stopServer(gradleArgsCommon + gradleArgsStop) 313 | stopServerProcess.lineHandler = ::handleConsoleOutput 314 | stopServerProcess.waitFor() 315 | 316 | runState = RunState.STOPPED 317 | signal() 318 | } 319 | } 320 | } 321 | 322 | if (!keyHandled && runState == RunState.RUNNING) { 323 | if (key == Keys.H || key == Keys.H_UPPER) { 324 | keyHandled = true 325 | showHelp = !showHelp 326 | } else if (canToggleLiveReloading && (key == Keys.P || key == Keys.P_UPPER)) { 327 | keyHandled = true 328 | liveReloadingPaused = !liveReloadingPaused 329 | ServerRequestsFile(kobwebApplication.kobwebFolder).enqueueRequest( 330 | if (liveReloadingPaused) ServerRequest.PauseClientEvents() else ServerRequest.ResumeClientEvents() 331 | ) 332 | } 333 | } 334 | 335 | if (!keyHandled) gradleAlertBundle.handleKey(key) 336 | } 337 | 338 | coroutineScope { 339 | while (runState == RunState.STARTING) { 340 | serverStateFile.content?.takeIf { it.isRunning() }?.let { 341 | serverState = it 342 | runState = RunState.RUNNING 343 | 344 | // Newer versions of Kobweb support toggling live reload mode dynamically. However, 345 | // we shouldn't expose this option to users who requested only running once, because in 346 | // that case, we'll never trigger a live reload from happening in the first place. 347 | if (!runOnce) { 348 | CoroutineScope(Dispatchers.IO).launch { 349 | val client = OkHttpClient() 350 | val serverVersionRequest = 351 | Request.Builder() 352 | .url("${serverState.url}/api/kobweb-version") 353 | .build() 354 | 355 | client.newCall(serverVersionRequest).execute().use { response -> 356 | if (response.isSuccessful) { 357 | response.body?.string()?.trim() 358 | ?.let { serverVersionStr -> 359 | SemVer.tryParse(serverVersionStr) 360 | }?.let { serverVersion -> 361 | if (serverVersion >= KobwebServerFeatureVersions.toggleLiveReloading) { 362 | canToggleLiveReloading = true 363 | } 364 | } 365 | } 366 | } 367 | } 368 | } 369 | } ?: run { delay(300) } 370 | } 371 | } 372 | 373 | CoroutineScope(Dispatchers.IO).launch { 374 | while (runState != RunState.RUNNING) { 375 | delay(1000) 376 | } 377 | 378 | val kobwebConf = KobwebConfFile(kobwebFolder) 379 | val timestamp = Files.getLastModifiedTime(kobwebConf.path) 380 | while (runState == RunState.RUNNING) { 381 | delay(1000) 382 | if (Files.getLastModifiedTime(kobwebConf.path) != timestamp) { 383 | kobwebConfChanged = true 384 | break 385 | } 386 | } 387 | } 388 | 389 | if (runState == RunState.RUNNING) { 390 | addTimer(500.milliseconds, repeat = true) { 391 | if (runState == RunState.RUNNING) { 392 | serverState!!.let { serverState -> 393 | if (!serverState.isRunning() || serverStateFile.content != serverState) { 394 | cancelReason = "It seems like the server was stopped by a separate process." 395 | runState = RunState.CANCELLED 396 | signal() 397 | } 398 | } 399 | } else { 400 | repeat = false 401 | } 402 | } 403 | } 404 | } 405 | } 406 | 407 | run { 408 | var runningServerDetected by liveVarOf(false) 409 | var checkAborted by liveVarOf(false) 410 | // Only wait for a running server if at least one task has run. If the user cancelled their run before 411 | // that early, the chance of a server starting is zero. 412 | if (userRequestedCancelWhileBuilding && gradleAlertBundle.hasFirstTaskRun) { 413 | var remainingTimeMs by liveVarOf(5000) // In practice, we usually detect a server within 2 seconds. 414 | 415 | fun Int.msToSecTimeString() = "${this / 1000}.${(this % 1000).toString().padEnd(3, '0')}s" 416 | 417 | section { 418 | textLine() 419 | if (!runningServerDetected) { 420 | textLine("Depending on timing, a server might still start up despite a cancellation request.") 421 | textLine() 422 | if (checkAborted) { 423 | text("Check cancelled. Consider running ") 424 | cyan { text("kobweb stop") } 425 | textLine(" later to verify that no server started up.") 426 | } else if (remainingTimeMs > 0) { 427 | textLine("Watching for a Kobweb server. (Remaining: ${remainingTimeMs.msToSecTimeString()})") 428 | textLine() 429 | textLine("Press any key to abort this check.") 430 | } else { 431 | textLine("Server startup was successfully cancelled.") 432 | } 433 | } else { 434 | textInfo("Running server detected after cancellation request. Shutting it down.") 435 | } 436 | textLine() 437 | }.runUntilSignal { 438 | addTimer(Anim.ONE_FRAME_60FPS, repeat = true) { 439 | remainingTimeMs -= elapsed.inWholeMilliseconds.toInt() 440 | if (remainingTimeMs < 0) remainingTimeMs = 0 441 | 442 | runningServerDetected = serverStateFile.content?.isRunning() == true 443 | 444 | if (remainingTimeMs == 0 || runningServerDetected) { 445 | repeat = false 446 | signal() 447 | } 448 | } 449 | 450 | onKeyPressed { 451 | checkAborted = true 452 | signal() 453 | } 454 | } 455 | } 456 | 457 | if (runningServerDetected) { 458 | handleStop(kobwebGradle) 459 | } 460 | } 461 | }) { 462 | warnFallingBackToPlainText() 463 | runInPlainMode = true 464 | } 465 | 466 | if (runInPlainMode) { 467 | kobwebApplication.assertServerNotAlreadyRunning() 468 | 469 | if (gradleArgsStop.isNotEmpty()) { 470 | println("Warning: --gradle-stop is ignored when running in non-interactive mode (which does not stop the server).") 471 | } 472 | 473 | // If we're non-interactive, it means we just want to start the Kobweb server and exit without waiting for 474 | // for any additional changes. (This is essentially used when run in a web server environment) 475 | val runFailed = kobwebGradle 476 | .startServer(enableLiveReloading = false, siteLayout, gradleArgsCommon + gradleArgsStart) 477 | .waitForAndCheckForException() != null 478 | if (runFailed) { 479 | throw CliktError("Failed to start a Kobweb server. Please check Gradle output and resolve any errors before retrying.") 480 | } 481 | 482 | val serverStateFile = ServerStateFile(kobwebApplication.kobwebFolder) 483 | runBlocking { 484 | while (serverStateFile.content?.isRunning() == false) { 485 | delay(20) // Low delay because startup should happen fairly quickly 486 | } 487 | } 488 | 489 | if (runInForeground) { 490 | println() 491 | println("Press CTRL-C to exit this application and shutdown the server.") 492 | Runtime.getRuntime().addShutdownHook(Thread { 493 | if (serverStateFile.content?.isRunning() == false) return@Thread 494 | ServerRequestsFile(kobwebApplication.kobwebFolder).enqueueRequest(ServerRequest.Stop()) 495 | println() 496 | println("CTRL-C received. Sent a message to stop the server.") 497 | println("You may still have to run 'kobweb stop' if it didn't work.") 498 | System.out.flush() 499 | }) 500 | 501 | runBlocking { 502 | while (serverStateFile.content?.isRunning() == true) { 503 | delay(300) 504 | } 505 | } 506 | } 507 | } 508 | 509 | if (restartRequested) { 510 | handleRun( 511 | env, 512 | siteLayout, 513 | useAnsi, 514 | runInForeground, 515 | runOnce, 516 | kobwebExecutionEnvironment, 517 | gradleArgsCommon, 518 | gradleArgsStart, 519 | gradleArgsStop, 520 | ) 521 | } 522 | } 523 | --------------------------------------------------------------------------------