commit 50cdd8e1261b56c5a469b3dc9bf790f97d5ca635 Author: slhafzjw Date: Tue Feb 24 17:57:03 2026 +0800 chore: initialize slhaf hub project diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3040c7d --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +build/ +out/ +.idea/ +.kotlin/ +*.db +scripts/.host-api-token diff --git a/README.md b/README.md new file mode 100644 index 0000000..40801bb --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# Kotlin Script Host (Gradle Project) + +This project provides two runtime entrypoints while keeping dynamic script loading from `scripts/`. + +## Run CLI host +```bash +cd /tmp/kotlin-scripts +./gradlew runCli --args='scripts/hello.hub.kts' +./gradlew runCli --args='scripts/hello.hub.kts --arg=name=Codex --arg=upper=true' +``` + +Watch mode: +```bash +./gradlew runCli --args='scripts/hello.hub.kts --watch --debounce-ms=200' +``` + +## Run Web host (Ktor) +```bash +./gradlew runWeb --args='--port=8080 --scripts-dir=./scripts' +``` + +Auth: +- Use `Authorization: Bearer ` for all APIs except `/health`. +- Token source: + - Preferred: set env `HOST_API_TOKEN`. + - Otherwise host auto-generates a token and stores it at `scripts/.host-api-token`. + +Routes: +- `GET /health` +- `GET /scripts` +- `GET /scripts/{script}` (raw script content) +- `POST /scripts/{script}` +- `PUT /scripts/{script}` +- `DELETE /scripts/{script}` +- `GET /meta/{script}` +- `GET /run/{script}?k=v` +- `POST /run/{script}?k=v` + +Examples: +```bash +curl 'http://127.0.0.1:8080/health' +TOKEN="$(cat scripts/.host-api-token)" +curl -H "Authorization: Bearer $TOKEN" 'http://127.0.0.1:8080/scripts' +curl -H "Authorization: Bearer $TOKEN" 'http://127.0.0.1:8080/scripts/hello' +curl -H "Authorization: Bearer $TOKEN" -X POST 'http://127.0.0.1:8080/scripts/new-api' --data-binary $'// @desc: new api\nval args: Array = emptyArray()\nprintln("ok")' +curl -H "Authorization: Bearer $TOKEN" -X PUT 'http://127.0.0.1:8080/scripts/new-api' --data-binary $'// @desc: new api v2\nval args: Array = emptyArray()\nprintln("ok-v2")' +curl -H "Authorization: Bearer $TOKEN" -X DELETE 'http://127.0.0.1:8080/scripts/new-api' +curl -H "Authorization: Bearer $TOKEN" 'http://127.0.0.1:8080/meta/hello' +curl -H "Authorization: Bearer $TOKEN" 'http://127.0.0.1:8080/run/hello?name=Alice&upper=true' +curl -H "Authorization: Bearer $TOKEN" -X POST 'http://127.0.0.1:8080/run/hello?name=Alice' -d 'from-body' +``` + +## Script Metadata & Args (`*.hub.kts`) +Scripts declare metadata in comments and receive request arguments through explicit `args` declaration: + +```kotlin +// @desc: Demo greeting API +// @param: name | default=world | desc=Name to greet +// @param: token | required=true | desc=Required token + +val args: Array = emptyArray() +val kv = args.mapNotNull { + val i = it.indexOf('=') + if (i <= 0) null else it.substring(0, i) to it.substring(i + 1) +}.toMap() + +val name = kv["name"] ?: "world" +val token = kv["token"] ?: error("token required") +println("hello $name, token=$token") +``` + +## Dynamic scripts +You can add/remove `*.hub.kts` files in `scripts/` at any time. The web host resolves scripts by route name (`/run/{script}` -> `scripts/{script}.hub.kts`) on each request, so newly added scripts are available immediately. + +## Notes +- This keeps runtime behavior dynamic; Gradle is used for dependency resolution and launching, not for precompiling scripts. +- IDE completion for regular Kotlin sources (`src/main/kotlin`) is fully modelled by Gradle. +- You do not need a package/build artifact step before each run. `runCli` and `runWeb` launch directly from source; scripts are compiled on-demand per execution/request. +- For script files with custom extension (`*.hub.kts`), IDEA code insight is usually weaker than standard `*.main.kts` or module Kotlin sources. This is an IDE limitation for custom script definitions. + +## Command CLI +A standalone CLI script is available at `tools/api-cli.main.kts` (independent from host internals, only HTTP calls). + +Examples: +```bash +kotlin tools/api-cli.main.kts --base-url=http://127.0.0.1:8080 --token-file=./scripts/.host-api-token list +kotlin tools/api-cli.main.kts --token-file=./scripts/.host-api-token show hello +kotlin tools/api-cli.main.kts --token-file=./scripts/.host-api-token run hello --arg=name=Alice --arg=upper=true +kotlin tools/api-cli.main.kts --token-file=./scripts/.host-api-token create demo --text='// @desc: demo\nval args: Array = emptyArray()\nprintln("ok")' +``` + +Note: +- In this environment, `elide run -- ` currently does not expose Kotlin script args reliably; use `kotlin` to run the CLI script. + +## Simple TUI +A minimal keyboard-driven TUI is available at `tools/api-tui.main.kts`. + +Run: +```bash +kotlin tools/api-tui.main.kts --base-url=http://127.0.0.1:8080 --token-file=./scripts/.host-api-token +``` + +Keys: +- `Up/Down` or `j/k`: switch script +- `Left/Right` or `h/l`: switch action (`Refresh/Show/Run/Meta/Create/Edit/Delete/Quit`) +- `Enter`: execute selected action +- `q`: quit + +Create/Edit/Delete behavior: +- `Create`: prompt script name, then choose source mode: + - `e` (default): create temp file, open terminal editor, then upload via API + - `f`: read a specified local file and upload via API + - In editor mode, if content is unchanged from initial template, creation is cancelled +- `Edit`: fetch current script content (`GET /scripts/{script}`), write to temp file, open editor, save+exit, then upload via `PUT` +- `Delete`: asks confirmation before calling `DELETE` +- `Run`: prompts for optional query args (`k=v`, separated by `&` or space), and optional POST mode/body + - Now uses a keyboard-driven sub-menu (`Method/Query/Body/Execute/Cancel`) and remembers last run config per script during the session + +Editor selection: +- First uses `$EDITOR` +- Fallback to first available of `nvim`, `vim`, `nano` diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..2447c4a --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,49 @@ +plugins { + kotlin("jvm") version "2.2.20" + application +} + +val kotlinVersion = "2.2.20" + +repositories { + mavenCentral() +} + +dependencies { + implementation(kotlin("stdlib")) + + implementation("org.jetbrains.kotlin:kotlin-scripting-jvm-host:$kotlinVersion") + implementation("org.jetbrains.kotlin:kotlin-scripting-jvm:$kotlinVersion") + implementation("org.jetbrains.kotlin:kotlin-scripting-dependencies:$kotlinVersion") + implementation("org.jetbrains.kotlin:kotlin-scripting-dependencies-maven:$kotlinVersion") + + implementation("com.google.guava:guava:28.2-jre") + + implementation("io.ktor:ktor-server-core-jvm:2.3.13") + implementation("io.ktor:ktor-server-netty-jvm:2.3.13") + runtimeOnly("ch.qos.logback:logback-classic:1.5.18") +} + +kotlin { + jvmToolchain(17) +} + +application { + mainClass.set("work.slhaf.hub.CliHostKt") +} + +val runCli by tasks.registering(JavaExec::class) { + group = "application" + description = "Run the script CLI host" + classpath = sourceSets["main"].runtimeClasspath + mainClass.set("work.slhaf.hub.CliHostKt") + workingDir = projectDir +} + +val runWeb by tasks.registering(JavaExec::class) { + group = "application" + description = "Run the script web host" + classpath = sourceSets["main"].runtimeClasspath + mainClass.set("work.slhaf.hub.WebHostKt") + workingDir = projectDir +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..c1962a7 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..35a1b01 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +networkTimeout=10000 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..aeb74cb --- /dev/null +++ b/gradlew @@ -0,0 +1,245 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..f85dfd7 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "kotlin-scripts-host" diff --git a/src/main/kotlin/work/slhaf/hub/CliHost.kt b/src/main/kotlin/work/slhaf/hub/CliHost.kt new file mode 100644 index 0000000..8758baa --- /dev/null +++ b/src/main/kotlin/work/slhaf/hub/CliHost.kt @@ -0,0 +1,134 @@ +package work.slhaf.hub + +import java.io.File +import java.nio.file.FileSystems +import java.nio.file.Path +import java.nio.file.StandardWatchEventKinds + +private fun usage() { + println( + """ +Usage: + ./gradlew runCli --args=' [--arg=key=value ...] [--body=text] [--watch] [--debounce-ms=300]' + +Examples: + ./gradlew runCli --args='scripts/hello.hub.kts' + ./gradlew runCli --args='scripts/hello.hub.kts --arg=name=Codex' + ./gradlew runCli --args='scripts/hello.hub.kts --watch --debounce-ms=200' + """.trimIndent() + ) +} + +private fun parseDebounce(cliArgs: List): Long { + val token = cliArgs.firstOrNull { it.startsWith("--debounce-ms=") } ?: return 300L + return token.substringAfter("=").toLongOrNull()?.coerceAtLeast(50L) ?: 300L +} + +private fun parseScriptArgs(cliArgs: List): List = + cliArgs.asSequence() + .filter { it.startsWith("--arg=") } + .map { + val token = it.substringAfter("--arg=") + token + } + .toList() + +private fun parseBody(cliArgs: List): String? = + cliArgs.firstOrNull { it.startsWith("--body=") }?.substringAfter("=") + +private fun runOnce(scriptFile: File, requestContext: ScriptRequestContext) { + println("\\n=== Evaluating ${scriptFile.absolutePath} @ ${java.time.LocalTime.now()} ===") + val result = evalAndCapture(scriptFile, requestContext) + + if (result.output.isNotBlank()) { + println(result.output) + } + if (result.metadata.description != null) { + println("[META] description: ${result.metadata.description}") + } + if (result.metadata.params.isNotEmpty()) { + println( + "[META] params: " + result.metadata.params.joinToString(", ") { p -> + "${p.name}(required=${p.required}, default=${p.defaultValue ?: "null"})" + } + ) + } + + if (result.ok) { + println("[OK] Script evaluation finished") + } else { + println("[FAIL] Script evaluation failed") + } +} + +fun main(args: Array) { + val rawArgs = args.toList() + if (rawArgs.isEmpty() || rawArgs.contains("--help") || rawArgs.contains("-h")) { + usage() + kotlin.system.exitProcess(if (rawArgs.isEmpty()) 1 else 0) + } + + val scriptPath = rawArgs.firstOrNull { !it.startsWith("--") } + if (scriptPath == null) { + usage() + kotlin.system.exitProcess(1) + } + + val scriptFile = File(scriptPath).absoluteFile + if (!scriptFile.exists()) { + println("Script file not found: ${scriptFile.absolutePath}") + kotlin.system.exitProcess(2) + } + + val watch = rawArgs.contains("--watch") + val debounceMs = parseDebounce(rawArgs) + val requestContext = ScriptRequestContext( + args = parseScriptArgs(rawArgs), + body = parseBody(rawArgs) + ) + + runOnce(scriptFile, requestContext) + if (!watch) return + + val watcher = FileSystems.getDefault().newWatchService() + val dir: Path = scriptFile.parentFile.toPath() + dir.register( + watcher, + StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_MODIFY, + StandardWatchEventKinds.ENTRY_DELETE + ) + + println("[WATCH] Watching ${scriptFile.absolutePath}, debounce=${debounceMs}ms") + println("[WATCH] Press Ctrl+C to stop") + + var lastStamp = scriptFile.takeIf { it.exists() }?.let { "${it.length()}-${it.lastModified()}" } ?: "MISSING" + + while (true) { + val key = watcher.take() + var shouldReload = false + + for (event in key.pollEvents()) { + val changed = event.context()?.toString() ?: continue + if (changed == scriptFile.name) { + shouldReload = true + } + } + + key.reset() + + if (!shouldReload) continue + + Thread.sleep(debounceMs) + val currentStamp = scriptFile.takeIf { it.exists() }?.let { "${it.length()}-${it.lastModified()}" } ?: "MISSING" + if (currentStamp == lastStamp) continue + + lastStamp = currentStamp + if (!scriptFile.exists()) { + println("[WATCH] Script deleted: ${scriptFile.absolutePath}") + continue + } + + runOnce(scriptFile, requestContext) + } +} diff --git a/src/main/kotlin/work/slhaf/hub/ScriptEngine.kt b/src/main/kotlin/work/slhaf/hub/ScriptEngine.kt new file mode 100644 index 0000000..94cd067 --- /dev/null +++ b/src/main/kotlin/work/slhaf/hub/ScriptEngine.kt @@ -0,0 +1,244 @@ +package work.slhaf.hub + +import kotlinx.coroutines.runBlocking +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.PrintStream +import java.util.concurrent.ConcurrentHashMap +import kotlin.script.experimental.api.* +import kotlin.script.experimental.dependencies.* +import kotlin.script.experimental.dependencies.maven.MavenDependenciesResolver +import kotlin.script.experimental.host.toScriptSource +import kotlin.script.experimental.jvm.JvmDependency +import kotlin.script.experimental.jvm.jvm +import kotlin.script.experimental.jvm.updateClasspath +import kotlin.script.experimental.jvmhost.BasicJvmScriptingHost + +private val scriptingHost = BasicJvmScriptingHost() +private val evalLock = Any() +private val metadataCache = ConcurrentHashMap>() // key -> stamp, metadata + +private val resolver = CompoundDependenciesResolver(FileSystemDependenciesResolver(), MavenDependenciesResolver()) +private val argsDeclarationRegex = Regex("""^\s*val\s+args\s*:\s*Array\s*=\s*emptyArray\(\)\s*$""") + +fun explicitClasspathFromEnv(): List? { + val value = System.getenv("HOST_SCRIPT_CLASSPATH") ?: return null + if (value.isBlank()) return null + return value.split(File.pathSeparator).filter { it.isNotBlank() }.map(::File) +} + +private fun runtimeClasspathFromJavaProperty(): List { + val raw = System.getProperty("java.class.path").orEmpty() + if (raw.isBlank()) return emptyList() + return raw + .split(File.pathSeparator) + .asSequence() + .filter { it.isNotBlank() } + .map(::File) + .filter { it.exists() } + .distinctBy { it.absolutePath } + .toList() +} + +private fun configureMavenDepsOnAnnotations( + context: ScriptConfigurationRefinementContext +): ResultWithDiagnostics { + val annotations = context.collectedData + ?.get(ScriptCollectedData.collectedAnnotations) + ?.takeIf { it.isNotEmpty() } + ?: return context.compilationConfiguration.asSuccess() + + return runBlocking { + resolver.resolveFromScriptSourceAnnotations(annotations) + }.onSuccess { files -> + context.compilationConfiguration.with { + dependencies.append(JvmDependency(files)) + }.asSuccess() + } +} + +private fun compilationConfiguration(explicitCp: List?): ScriptCompilationConfiguration { + return ScriptCompilationConfiguration { + baseClass(SimpleScript::class) + defaultImports(DependsOn::class, Repository::class) + + jvm { + val runtimeCp = runtimeClasspathFromJavaProperty() + updateClasspath((explicitCp ?: emptyList()) + runtimeCp) + } + + refineConfiguration { + onAnnotations(DependsOn::class, Repository::class, handler = ::configureMavenDepsOnAnnotations) + } + } +} + +private fun escapeKotlinString(value: String): String = buildString(value.length) { + value.forEach { ch -> + when (ch) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> append(ch) + } + } +} + +private fun argsInitializer(args: List): String = + "val args: Array = arrayOf(${args.joinToString(",") { "\"${escapeKotlinString(it)}\"" }})" + +private fun scriptStamp(file: File): String = "${file.length()}-${file.lastModified()}" + +private fun injectArgsDeclaration(scriptContent: String, args: List): String { + val lines = scriptContent.lines() + val injected = argsInitializer(args) + var replaced = false + val result = lines.map { line -> + if (!replaced && argsDeclarationRegex.matches(line)) { + replaced = true + injected + } else { + line + } + } + return result.joinToString("\n") +} + +private fun parseMetadataFromComments(scriptContent: String): ScriptMetadata { + var description: String? = null + val params = mutableListOf() + + scriptContent.lines().forEach { raw -> + val line = raw.trim() + if (!line.startsWith("//")) return@forEach + + val comment = line.removePrefix("//").trim() + if (comment.startsWith("@desc:", ignoreCase = true)) { + description = comment.substringAfter(":").trim().takeIf { it.isNotBlank() } + return@forEach + } + if (comment.startsWith("@param:", ignoreCase = true)) { + val payload = comment.substringAfter(":").trim() + if (payload.isBlank()) return@forEach + + val parts = payload.split("|").map { it.trim() }.filter { it.isNotBlank() } + if (parts.isEmpty()) return@forEach + + val name = parts.first() + var required = false + var defaultValue: String? = null + var desc: String? = null + + parts.drop(1).forEach { part -> + when { + part.equals("required", ignoreCase = true) -> required = true + part.startsWith("required=", ignoreCase = true) -> + required = part.substringAfter("=").trim().equals("true", ignoreCase = true) + part.startsWith("default=", ignoreCase = true) -> + defaultValue = part.substringAfter("=").trim().ifBlank { null } + part.startsWith("desc=", ignoreCase = true) -> + desc = part.substringAfter("=").trim().ifBlank { null } + } + } + + params += ScriptParamDefinition( + name = name, + required = required, + defaultValue = defaultValue, + description = desc + ) + } + } + + return ScriptMetadata(description = description, params = params) +} + +private fun metadataForFile(scriptFile: File, scriptContent: String): ScriptMetadata { + val key = scriptFile.canonicalPath + val stamp = scriptStamp(scriptFile) + val cached = metadataCache[key] + if (cached != null && cached.first == stamp) return cached.second + + val parsed = parseMetadataFromComments(scriptContent) + metadataCache[key] = stamp to parsed + return parsed +} + +private fun evalSource(source: SourceCode): ResultWithDiagnostics { + val explicitCp = explicitClasspathFromEnv() + return scriptingHost.eval(source, compilationConfiguration(explicitCp), null) +} + +data class ScriptExecutionResult( + val ok: Boolean, + val output: String, + val metadata: ScriptMetadata, + val missingRequiredParams: List +) + +fun cachedMetadata(scriptFile: File): ScriptMetadata? { + val key = scriptFile.canonicalPath + val cached = metadataCache[key] ?: return null + val currentStamp = scriptStamp(scriptFile) + return if (cached.first == currentStamp) cached.second else null +} + +fun removeCachedMetadata(scriptFile: File) { + metadataCache.remove(scriptFile.canonicalPath) +} + +fun evalAndCapture(scriptFile: File, requestContext: ScriptRequestContext = ScriptRequestContext()): ScriptExecutionResult { + synchronized(evalLock) { + val oldOut = System.out + val oldErr = System.err + val buffer = ByteArrayOutputStream() + val ps = PrintStream(buffer, true, Charsets.UTF_8.name()) + + return try { + System.setOut(ps) + System.setErr(ps) + + val original = scriptFile.readText() + val metadata = metadataForFile(scriptFile, original) + val missingRequired = metadata.params + .filter { p -> + p.required && requestContext.args.none { token -> + token.substringBefore("=", missingDelimiterValue = "") == p.name + } && p.defaultValue == null + } + .map { it.name } + + val injected = injectArgsDeclaration(original, requestContext.args) + val result = evalSource(injected.toScriptSource(scriptFile.name)) + val diagnostics = result.reports + .filter { it.severity > ScriptDiagnostic.Severity.DEBUG } + .joinToString("\n") { + val ex = it.exception?.let { e -> ": ${e::class.simpleName}: ${e.message}" } ?: "" + "[${it.severity}] ${it.message}$ex" + } + val missingMessage = if (missingRequired.isEmpty()) "" else + "[ERROR] Missing required parameters: ${missingRequired.joinToString(", ")}" + + val output = buffer.toString(Charsets.UTF_8.name()).trim() + val finalText = buildString { + if (output.isNotEmpty()) appendLine(output) + if (diagnostics.isNotEmpty()) appendLine(diagnostics) + if (missingMessage.isNotEmpty()) appendLine(missingMessage) + }.trim() + + ScriptExecutionResult( + ok = result is ResultWithDiagnostics.Success && missingRequired.isEmpty(), + output = finalText, + metadata = metadata, + missingRequiredParams = missingRequired + ) + } finally { + ps.flush() + ps.close() + System.setOut(oldOut) + System.setErr(oldErr) + } + } +} diff --git a/src/main/kotlin/work/slhaf/hub/ScriptRuntime.kt b/src/main/kotlin/work/slhaf/hub/ScriptRuntime.kt new file mode 100644 index 0000000..d6c3fbe --- /dev/null +++ b/src/main/kotlin/work/slhaf/hub/ScriptRuntime.kt @@ -0,0 +1,18 @@ +package work.slhaf.hub + +data class ScriptParamDefinition( + val name: String, + val required: Boolean = false, + val defaultValue: String? = null, + val description: String? = null +) + +data class ScriptMetadata( + val description: String? = null, + val params: List = emptyList() +) + +data class ScriptRequestContext( + val args: List = emptyList(), + val body: String? = null +) diff --git a/src/main/kotlin/work/slhaf/hub/SimpleScriptDef.kt b/src/main/kotlin/work/slhaf/hub/SimpleScriptDef.kt new file mode 100644 index 0000000..4b141fc --- /dev/null +++ b/src/main/kotlin/work/slhaf/hub/SimpleScriptDef.kt @@ -0,0 +1,6 @@ +package work.slhaf.hub + +import kotlin.script.experimental.annotations.KotlinScript + +@KotlinScript(fileExtension = "hub.kts") +abstract class SimpleScript diff --git a/src/main/kotlin/work/slhaf/hub/WebHost.kt b/src/main/kotlin/work/slhaf/hub/WebHost.kt new file mode 100644 index 0000000..2e201bc --- /dev/null +++ b/src/main/kotlin/work/slhaf/hub/WebHost.kt @@ -0,0 +1,125 @@ +package work.slhaf.hub + +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.Application +import io.ktor.server.application.call +import io.ktor.server.engine.embeddedServer +import io.ktor.server.netty.Netty +import io.ktor.server.response.respondText +import io.ktor.server.routing.delete +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import io.ktor.server.routing.put +import io.ktor.server.routing.routing +import java.io.File + +private const val DEFAULT_PORT = 8080 +private const val DEFAULT_SCRIPTS_DIR = "scripts" +private const val HOST = "127.0.0.1" + +private fun Application.module(scriptsDir: File, apiToken: String) { + routing { + get("/health") { + call.respondText("OK") + } + get("/scripts") { + if (!requireAuth(call, apiToken)) return@get + call.respondText(renderScriptList(scriptsDir), ContentType.Text.Plain) + } + get("/scripts/{script}") { + if (!requireAuth(call, apiToken)) return@get + handleGetScriptContent(call, scriptsDir) + } + post("/scripts/{script}") { + if (!requireAuth(call, apiToken)) return@post + handleCreateScript(call, scriptsDir) + } + put("/scripts/{script}") { + if (!requireAuth(call, apiToken)) return@put + handleUpdateScript(call, scriptsDir) + } + delete("/scripts/{script}") { + if (!requireAuth(call, apiToken)) return@delete + handleDeleteScript(call, scriptsDir) + } + get("/meta/{script}") { + if (!requireAuth(call, apiToken)) return@get + val name = call.parameters["script"] + ?: return@get call.respondText("missing route name", status = HttpStatusCode.BadRequest) + val script = resolveScriptFile(scriptsDir, name) + ?: return@get call.respondText("invalid script name", status = HttpStatusCode.BadRequest) + if (!script.exists()) { + return@get call.respondText("script not found: ${script.name}", status = HttpStatusCode.NotFound) + } + + val (metadata, source) = loadMetadata(script) + call.respondText( + metadataJson(name, metadata, source), + contentType = ContentType.Application.Json + ) + } + get("/run/{script}") { + if (!requireAuth(call, apiToken)) return@get + handleRunRequest(call, scriptsDir, consumeBody = false) + } + post("/run/{script}") { + if (!requireAuth(call, apiToken)) return@post + handleRunRequest(call, scriptsDir, consumeBody = true) + } + } +} + +private fun usage() { + println( + """ +Usage: + ./gradlew runWeb --args='[--port=8080] [--scripts-dir=./scripts]' +Routes: + GET /health + Authorization: + Authorization: Bearer + or X-Host-Token: + GET /scripts + GET /scripts/{script} + POST /scripts/{script} + PUT /scripts/{script} + DELETE /scripts/{script} + GET /meta/{script} + GET /run/{script}?k=v + POST /run/{script}?k=v + """.trimIndent() + ) +} + +private fun List.optionValue(prefix: String): String? = + firstOrNull { it.startsWith(prefix) }?.substringAfter("=") + +fun main(args: Array) { + val cli = args.toList() + + if ("--help" in cli || "-h" in cli) { + usage() + return + } + + val port = cli.optionValue("--port=")?.toIntOrNull() ?: DEFAULT_PORT + val scriptsDir = File(cli.optionValue("--scripts-dir=") ?: DEFAULT_SCRIPTS_DIR).absoluteFile + + if (!scriptsDir.exists()) scriptsDir.mkdirs() + val auth = loadOrCreateApiToken(scriptsDir) + + println("Starting script web host on http://$HOST:$port") + println("Scripts directory: ${scriptsDir.absolutePath}") + println("Auth token source: ${auth.source}") + when { + auth.source.startsWith("env:") -> println("Auth token loaded from environment variable.") + auth.source.startsWith("generated:") -> + println("Auth token generated and saved to: ${auth.tokenFile?.absolutePath}") + else -> println("Auth token loaded from file: ${auth.tokenFile?.absolutePath}") + } + + embeddedServer(Netty, port = port, host = HOST) { + module(scriptsDir, auth.token) + }.start(wait = true) +} diff --git a/src/main/kotlin/work/slhaf/hub/WebScriptService.kt b/src/main/kotlin/work/slhaf/hub/WebScriptService.kt new file mode 100644 index 0000000..abfa679 --- /dev/null +++ b/src/main/kotlin/work/slhaf/hub/WebScriptService.kt @@ -0,0 +1,206 @@ +package work.slhaf.hub + +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall +import io.ktor.server.request.receiveText +import io.ktor.server.response.respondText +import java.io.File + +private const val SCRIPT_EXTENSION = ".hub.kts" +private val ROUTE_NAME_REGEX = Regex("[A-Za-z0-9._-]+") + +fun resolveScriptFile(baseDir: File, routeName: String): File? { + if (!routeName.matches(ROUTE_NAME_REGEX)) return null + + val canonicalBase = baseDir.canonicalFile + val candidate = File(baseDir, "$routeName$SCRIPT_EXTENSION").canonicalFile + val insideBase = candidate.path.startsWith(canonicalBase.path + File.separator) + + return if (insideBase || candidate == canonicalBase) candidate else null +} + +private fun listScriptNames(scriptsDir: File): List = + scriptsDir.listFiles() + ?.asSequence() + ?.filter { it.isFile && it.name.endsWith(SCRIPT_EXTENSION) } + ?.map { it.name.removeSuffix(SCRIPT_EXTENSION) } + ?.sorted() + ?.toList() + ?: emptyList() + +fun renderScriptList(scriptsDir: File): String = + listScriptNames(scriptsDir).joinToString("\n") { name -> + val file = resolveScriptFile(scriptsDir, name) + val description = file?.let(::cachedMetadata)?.description + if (description.isNullOrBlank()) name else "$name\t$description" + } + +private fun String.jsonEscaped(): String = buildString(length) { + for (ch in this@jsonEscaped) { + when (ch) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> append(ch) + } + } +} + +fun metadataJson(scriptName: String, metadata: ScriptMetadata, source: String): String { + val description = metadata.description?.let { "\"${it.jsonEscaped()}\"" } ?: "null" + val params = metadata.params.joinToString(",") { param -> + val defaultValue = param.defaultValue?.let { "\"${it.jsonEscaped()}\"" } ?: "null" + val desc = param.description?.let { "\"${it.jsonEscaped()}\"" } ?: "null" + """{"name":"${param.name.jsonEscaped()}","required":${param.required},"defaultValue":$defaultValue,"description":$desc}""" + } + return """{"script":"${scriptName.jsonEscaped()}","source":"${source.jsonEscaped()}","description":$description,"params":[$params]}""" +} + +fun loadMetadata(script: File): Pair { + val cached = cachedMetadata(script) + if (cached != null) return cached to "cache" + + val executed = evalAndCapture(script, ScriptRequestContext(args = emptyList())).metadata + return executed to "parsed" +} + +suspend fun handleCreateScript(call: ApplicationCall, scriptsDir: File) { + val name = call.parameters["script"] + ?: return call.respondText("missing route name", status = HttpStatusCode.BadRequest) + val script = resolveScriptFile(scriptsDir, name) + ?: return call.respondText("invalid script name", status = HttpStatusCode.BadRequest) + if (script.exists()) { + return call.respondText("script already exists: ${script.name}", status = HttpStatusCode.Conflict) + } + + val content = call.receiveText() + if (content.isBlank()) { + return call.respondText("script content is empty", status = HttpStatusCode.BadRequest) + } + + script.parentFile?.mkdirs() + script.writeText(content) + removeCachedMetadata(script) + + val result = evalAndCapture(script, ScriptRequestContext()) + if (!result.ok) { + script.delete() + removeCachedMetadata(script) + return call.respondText( + "script validation failed:\n${result.output.ifBlank { "unknown error" }}", + status = HttpStatusCode.BadRequest, + contentType = ContentType.Text.Plain + ) + } + + call.respondText( + "created ${script.name}\n${result.output}".trim(), + status = HttpStatusCode.Created, + contentType = ContentType.Text.Plain + ) +} + +suspend fun handleDeleteScript(call: ApplicationCall, scriptsDir: File) { + val name = call.parameters["script"] + ?: return call.respondText("missing route name", status = HttpStatusCode.BadRequest) + val script = resolveScriptFile(scriptsDir, name) + ?: return call.respondText("invalid script name", status = HttpStatusCode.BadRequest) + if (!script.exists()) { + return call.respondText("script not found: ${script.name}", status = HttpStatusCode.NotFound) + } + + val deleted = script.delete() + removeCachedMetadata(script) + if (!deleted) { + return call.respondText( + "failed to delete script: ${script.name}", + status = HttpStatusCode.InternalServerError + ) + } + call.respondText("deleted ${script.name}", status = HttpStatusCode.OK) +} + +suspend fun handleUpdateScript(call: ApplicationCall, scriptsDir: File) { + val name = call.parameters["script"] + ?: return call.respondText("missing route name", status = HttpStatusCode.BadRequest) + val script = resolveScriptFile(scriptsDir, name) + ?: return call.respondText("invalid script name", status = HttpStatusCode.BadRequest) + if (!script.exists()) { + return call.respondText("script not found: ${script.name}", status = HttpStatusCode.NotFound) + } + + val newContent = call.receiveText() + if (newContent.isBlank()) { + return call.respondText("script content is empty", status = HttpStatusCode.BadRequest) + } + + val previousContent = script.readText() + script.writeText(newContent) + removeCachedMetadata(script) + + val result = evalAndCapture(script, ScriptRequestContext()) + if (!result.ok) { + script.writeText(previousContent) + removeCachedMetadata(script) + return call.respondText( + "script validation failed, rolled back:\n${result.output.ifBlank { "unknown error" }}", + status = HttpStatusCode.BadRequest, + contentType = ContentType.Text.Plain + ) + } + + call.respondText( + "updated ${script.name}\n${result.output}".trim(), + status = HttpStatusCode.OK, + contentType = ContentType.Text.Plain + ) +} + +suspend fun handleRunRequest(call: ApplicationCall, scriptsDir: File, consumeBody: Boolean) { + val name = call.parameters["script"] + ?: return call.respondText("missing route name", status = HttpStatusCode.BadRequest) + + val script = resolveScriptFile(scriptsDir, name) + ?: return call.respondText("invalid script name", status = HttpStatusCode.BadRequest) + + if (!script.exists()) { + return call.respondText("script not found: ${script.name}", status = HttpStatusCode.NotFound) + } + + val requestArgs = call.request.queryParameters.entries() + .mapNotNull { entry -> + val value = entry.value.lastOrNull() ?: return@mapNotNull null + "${entry.key}=$value" + } + .toList() + + val requestBody = if (consumeBody) call.receiveText() else null + val result = evalAndCapture(script, ScriptRequestContext(args = requestArgs, body = requestBody)) + + val status = when { + result.ok -> HttpStatusCode.OK + result.missingRequiredParams.isNotEmpty() -> HttpStatusCode.BadRequest + else -> HttpStatusCode.InternalServerError + } + + call.respondText( + result.output.ifBlank { if (result.ok) "OK" else "FAILED" }, + status = status, + contentType = ContentType.Text.Plain + ) +} + +suspend fun handleGetScriptContent(call: ApplicationCall, scriptsDir: File) { + val name = call.parameters["script"] + ?: return call.respondText("missing route name", status = HttpStatusCode.BadRequest) + val script = resolveScriptFile(scriptsDir, name) + ?: return call.respondText("invalid script name", status = HttpStatusCode.BadRequest) + if (!script.exists()) { + return call.respondText("script not found: ${script.name}", status = HttpStatusCode.NotFound) + } + + call.respondText(script.readText(), contentType = ContentType.Text.Plain) +} diff --git a/src/main/kotlin/work/slhaf/hub/WebSecurity.kt b/src/main/kotlin/work/slhaf/hub/WebSecurity.kt new file mode 100644 index 0000000..50615cf --- /dev/null +++ b/src/main/kotlin/work/slhaf/hub/WebSecurity.kt @@ -0,0 +1,63 @@ +package work.slhaf.hub + +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall +import io.ktor.server.response.respondText +import java.io.File +import java.security.SecureRandom + +private const val ENV_API_TOKEN = "HOST_API_TOKEN" +private const val TOKEN_FILE_NAME = ".host-api-token" +private const val ALT_TOKEN_HEADER = "X-Host-Token" + +data class ApiTokenConfig( + val token: String, + val source: String, + val tokenFile: File? +) + +private fun randomTokenHex(bytes: Int = 32): String { + val random = ByteArray(bytes) + SecureRandom().nextBytes(random) + return random.joinToString("") { "%02x".format(it) } +} + +fun loadOrCreateApiToken(scriptsDir: File): ApiTokenConfig { + val envToken = System.getenv(ENV_API_TOKEN)?.trim() + if (!envToken.isNullOrBlank()) { + return ApiTokenConfig(envToken, "env:$ENV_API_TOKEN", null) + } + + val tokenFile = File(scriptsDir, TOKEN_FILE_NAME) + if (tokenFile.exists()) { + val saved = tokenFile.readText().trim() + if (saved.isNotBlank()) return ApiTokenConfig(saved, "file:${tokenFile.absolutePath}", tokenFile) + } + + val token = randomTokenHex() + tokenFile.writeText(token) + tokenFile.setReadable(false, false) + tokenFile.setReadable(true, true) + tokenFile.setWritable(false, false) + tokenFile.setWritable(true, true) + return ApiTokenConfig(token, "generated:file:${tokenFile.absolutePath}", tokenFile) +} + +private fun extractProvidedToken(call: ApplicationCall): String? { + val auth = call.request.headers[HttpHeaders.Authorization] + if (!auth.isNullOrBlank() && auth.startsWith("Bearer ", ignoreCase = true)) { + return auth.substringAfter("Bearer ").trim() + } + return call.request.headers[ALT_TOKEN_HEADER]?.trim() +} + +suspend fun requireAuth(call: ApplicationCall, expectedToken: String): Boolean { + val provided = extractProvidedToken(call) + if (provided == expectedToken) return true + + call.response.headers.append(HttpHeaders.WWWAuthenticate, "Bearer realm=\"script-host\"") + call.respondText("unauthorized", status = HttpStatusCode.Unauthorized, contentType = ContentType.Text.Plain) + return false +} diff --git a/tools/api-cli.kts b/tools/api-cli.kts new file mode 100755 index 0000000..5f6d9bc --- /dev/null +++ b/tools/api-cli.kts @@ -0,0 +1,258 @@ +#!/usr/bin/env kotlin + +import java.io.File +import java.net.URI +import java.net.URLEncoder +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.charset.StandardCharsets +import kotlin.system.exitProcess + +data class GlobalOptions( + val baseUrl: String, + val token: String?, + val tokenFile: String?, +) + +data class ParsedInput( + val global: GlobalOptions, + val command: String, + val commandArgs: List, +) + +fun usage(): String = + """ +Usage: + elide run api-cli.main.kts [global options] [command options] + +Global options: + --base-url= Default: http://127.0.0.1:8080 + --token= Authorization token + --token-file= Load token from file (fallback: HOST_API_TOKEN env) + +Commands: + health + list + show