chore: initialize slhaf hub project
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
.gradle/
|
||||
build/
|
||||
out/
|
||||
.idea/
|
||||
.kotlin/
|
||||
*.db
|
||||
scripts/.host-api-token
|
||||
121
README.md
Normal file
121
README.md
Normal file
@@ -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 <token>` 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<String> = 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<String> = 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<String> = 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<String> = emptyArray()\nprintln("ok")'
|
||||
```
|
||||
|
||||
Note:
|
||||
- In this environment, `elide run <kts> -- <args...>` 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`
|
||||
49
build.gradle.kts
Normal file
49
build.gradle.kts
Normal file
@@ -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
|
||||
}
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
6
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
6
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -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
|
||||
245
gradlew
vendored
Executable file
245
gradlew
vendored
Executable file
@@ -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" "$@"
|
||||
1
settings.gradle.kts
Normal file
1
settings.gradle.kts
Normal file
@@ -0,0 +1 @@
|
||||
rootProject.name = "kotlin-scripts-host"
|
||||
134
src/main/kotlin/work/slhaf/hub/CliHost.kt
Normal file
134
src/main/kotlin/work/slhaf/hub/CliHost.kt
Normal file
@@ -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='<script.hub.kts> [--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<String>): Long {
|
||||
val token = cliArgs.firstOrNull { it.startsWith("--debounce-ms=") } ?: return 300L
|
||||
return token.substringAfter("=").toLongOrNull()?.coerceAtLeast(50L) ?: 300L
|
||||
}
|
||||
|
||||
private fun parseScriptArgs(cliArgs: List<String>): List<String> =
|
||||
cliArgs.asSequence()
|
||||
.filter { it.startsWith("--arg=") }
|
||||
.map {
|
||||
val token = it.substringAfter("--arg=")
|
||||
token
|
||||
}
|
||||
.toList()
|
||||
|
||||
private fun parseBody(cliArgs: List<String>): 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<String>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
244
src/main/kotlin/work/slhaf/hub/ScriptEngine.kt
Normal file
244
src/main/kotlin/work/slhaf/hub/ScriptEngine.kt
Normal file
@@ -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<String, Pair<String, ScriptMetadata>>() // key -> stamp, metadata
|
||||
|
||||
private val resolver = CompoundDependenciesResolver(FileSystemDependenciesResolver(), MavenDependenciesResolver())
|
||||
private val argsDeclarationRegex = Regex("""^\s*val\s+args\s*:\s*Array<String>\s*=\s*emptyArray\(\)\s*$""")
|
||||
|
||||
fun explicitClasspathFromEnv(): List<File>? {
|
||||
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<File> {
|
||||
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<ScriptCompilationConfiguration> {
|
||||
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<File>?): 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>): String =
|
||||
"val args: Array<String> = arrayOf(${args.joinToString(",") { "\"${escapeKotlinString(it)}\"" }})"
|
||||
|
||||
private fun scriptStamp(file: File): String = "${file.length()}-${file.lastModified()}"
|
||||
|
||||
private fun injectArgsDeclaration(scriptContent: String, args: List<String>): 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<ScriptParamDefinition>()
|
||||
|
||||
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<EvaluationResult> {
|
||||
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<String>
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
18
src/main/kotlin/work/slhaf/hub/ScriptRuntime.kt
Normal file
18
src/main/kotlin/work/slhaf/hub/ScriptRuntime.kt
Normal file
@@ -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<ScriptParamDefinition> = emptyList()
|
||||
)
|
||||
|
||||
data class ScriptRequestContext(
|
||||
val args: List<String> = emptyList(),
|
||||
val body: String? = null
|
||||
)
|
||||
6
src/main/kotlin/work/slhaf/hub/SimpleScriptDef.kt
Normal file
6
src/main/kotlin/work/slhaf/hub/SimpleScriptDef.kt
Normal file
@@ -0,0 +1,6 @@
|
||||
package work.slhaf.hub
|
||||
|
||||
import kotlin.script.experimental.annotations.KotlinScript
|
||||
|
||||
@KotlinScript(fileExtension = "hub.kts")
|
||||
abstract class SimpleScript
|
||||
125
src/main/kotlin/work/slhaf/hub/WebHost.kt
Normal file
125
src/main/kotlin/work/slhaf/hub/WebHost.kt
Normal file
@@ -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 <token>
|
||||
or X-Host-Token: <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<String>.optionValue(prefix: String): String? =
|
||||
firstOrNull { it.startsWith(prefix) }?.substringAfter("=")
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
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)
|
||||
}
|
||||
206
src/main/kotlin/work/slhaf/hub/WebScriptService.kt
Normal file
206
src/main/kotlin/work/slhaf/hub/WebScriptService.kt
Normal file
@@ -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<String> =
|
||||
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<ScriptMetadata, String> {
|
||||
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)
|
||||
}
|
||||
63
src/main/kotlin/work/slhaf/hub/WebSecurity.kt
Normal file
63
src/main/kotlin/work/slhaf/hub/WebSecurity.kt
Normal file
@@ -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
|
||||
}
|
||||
258
tools/api-cli.kts
Executable file
258
tools/api-cli.kts
Executable file
@@ -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<String>,
|
||||
)
|
||||
|
||||
fun usage(): String =
|
||||
"""
|
||||
Usage:
|
||||
elide run api-cli.main.kts [global options] <command> [command options]
|
||||
|
||||
Global options:
|
||||
--base-url=<url> Default: http://127.0.0.1:8080
|
||||
--token=<token> Authorization token
|
||||
--token-file=<path> Load token from file (fallback: HOST_API_TOKEN env)
|
||||
|
||||
Commands:
|
||||
health
|
||||
list
|
||||
show <script>
|
||||
meta <script>
|
||||
run <script> [--arg=k=v ...] [--body=text] [--post]
|
||||
create <script> (--file=<path> | --text=<content>)
|
||||
update <script> (--file=<path> | --text=<content>)
|
||||
delete <script>
|
||||
|
||||
Examples:
|
||||
elide run api-cli.main.kts --base-url=http://127.0.0.1:8080 --token-file=./scripts/.host-api-token list
|
||||
elide run api-cli.main.kts --token-file=./scripts/.host-api-token show hello
|
||||
elide run api-cli.main.kts --token-file=./scripts/.host-api-token run hello --arg=name=Alice --arg=upper=true
|
||||
elide run api-cli.main.kts --token-file=./scripts/.host-api-token create demo --file=./demo.hub.kts
|
||||
""".trimIndent()
|
||||
|
||||
fun parseInput(args: List<String>): ParsedInput {
|
||||
if (args.isEmpty() || args.contains("--help") || args.contains("-h")) {
|
||||
println(usage())
|
||||
exitProcess(0)
|
||||
}
|
||||
|
||||
var baseUrl = "http://127.0.0.1:8080"
|
||||
var token: String? = null
|
||||
var tokenFile: String? = null
|
||||
var i = 0
|
||||
while (i < args.size && args[i].startsWith("--")) {
|
||||
val arg = args[i]
|
||||
when {
|
||||
arg.startsWith("--base-url=") -> baseUrl = arg.substringAfter("=")
|
||||
arg.startsWith("--token=") -> token = arg.substringAfter("=")
|
||||
arg.startsWith("--token-file=") -> tokenFile = arg.substringAfter("=")
|
||||
else -> break
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
if (i >= args.size) error("Missing command.\n${usage()}")
|
||||
val command = args[i]
|
||||
val commandArgs = args.drop(i + 1)
|
||||
return ParsedInput(GlobalOptions(baseUrl.trimEnd('/'), token, tokenFile), command, commandArgs)
|
||||
}
|
||||
|
||||
fun readToken(options: GlobalOptions): String? {
|
||||
if (!options.token.isNullOrBlank()) return options.token
|
||||
if (!options.tokenFile.isNullOrBlank()) {
|
||||
val file = File(options.tokenFile)
|
||||
if (!file.exists()) error("Token file not found: ${file.absolutePath}")
|
||||
return file.readText().trim().ifBlank { null }
|
||||
}
|
||||
return System.getenv("HOST_API_TOKEN")?.trim()?.ifBlank { null }
|
||||
}
|
||||
|
||||
fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8)
|
||||
|
||||
fun requireScriptName(args: List<String>): String {
|
||||
if (args.isEmpty()) error("Missing <script> argument.")
|
||||
return args.first()
|
||||
}
|
||||
|
||||
fun parseBodyAndSource(args: List<String>): Pair<String?, String?> {
|
||||
val fileArg = args.firstOrNull { it.startsWith("--file=") }?.substringAfter("=")
|
||||
val textArg = args.firstOrNull { it.startsWith("--text=") }?.substringAfter("=")
|
||||
if (fileArg != null && textArg != null) error("Use either --file or --text, not both.")
|
||||
if (fileArg == null && textArg == null) error("Missing --file or --text.")
|
||||
return fileArg to textArg
|
||||
}
|
||||
|
||||
fun parseRunArgs(args: List<String>): Triple<String, List<Pair<String, String>>, Pair<Boolean, String?>> {
|
||||
val script = requireScriptName(args)
|
||||
val rest = args.drop(1)
|
||||
val query = mutableListOf<Pair<String, String>>()
|
||||
var body: String? = null
|
||||
var post = false
|
||||
for (arg in rest) {
|
||||
when {
|
||||
arg == "--post" -> {
|
||||
post = true
|
||||
}
|
||||
|
||||
arg.startsWith("--body=") -> {
|
||||
body = arg.substringAfter("=")
|
||||
}
|
||||
|
||||
arg.startsWith("--arg=") -> {
|
||||
val token = arg.substringAfter("--arg=")
|
||||
val idx = token.indexOf('=')
|
||||
if (idx <= 0) error("Invalid --arg format: $arg, expected --arg=key=value")
|
||||
query += token.substring(0, idx) to token.substring(idx + 1)
|
||||
}
|
||||
|
||||
else -> {
|
||||
error("Unknown run option: $arg")
|
||||
}
|
||||
}
|
||||
}
|
||||
return Triple(script, query, post to body)
|
||||
}
|
||||
|
||||
fun request(
|
||||
client: HttpClient,
|
||||
baseUrl: String,
|
||||
token: String?,
|
||||
method: String,
|
||||
path: String,
|
||||
body: String? = null,
|
||||
): Pair<Int, String> {
|
||||
val reqBuilder =
|
||||
HttpRequest
|
||||
.newBuilder(URI.create("$baseUrl$path"))
|
||||
.header("Accept", "text/plain,application/json")
|
||||
if (!token.isNullOrBlank()) reqBuilder.header("Authorization", "Bearer $token")
|
||||
|
||||
val request =
|
||||
when (method) {
|
||||
"GET" -> {
|
||||
reqBuilder.GET().build()
|
||||
}
|
||||
|
||||
"DELETE" -> {
|
||||
reqBuilder.DELETE().build()
|
||||
}
|
||||
|
||||
"POST" -> {
|
||||
reqBuilder
|
||||
.header("Content-Type", "text/plain; charset=utf-8")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body ?: ""))
|
||||
.build()
|
||||
}
|
||||
|
||||
"PUT" -> {
|
||||
reqBuilder
|
||||
.header("Content-Type", "text/plain; charset=utf-8")
|
||||
.PUT(HttpRequest.BodyPublishers.ofString(body ?: ""))
|
||||
.build()
|
||||
}
|
||||
|
||||
else -> {
|
||||
error("Unsupported method: $method")
|
||||
}
|
||||
}
|
||||
|
||||
val response = client.send(request, HttpResponse.BodyHandlers.ofString())
|
||||
return response.statusCode() to response.body()
|
||||
}
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
try {
|
||||
val input = parseInput(args.toList())
|
||||
val token = readToken(input.global)
|
||||
val client = HttpClient.newHttpClient()
|
||||
val base = input.global.baseUrl
|
||||
|
||||
val (status, body) =
|
||||
when (input.command) {
|
||||
"health" -> {
|
||||
request(client, base, null, "GET", "/health")
|
||||
}
|
||||
|
||||
"list" -> {
|
||||
request(client, base, token, "GET", "/scripts")
|
||||
}
|
||||
|
||||
"show" -> {
|
||||
val script = requireScriptName(input.commandArgs)
|
||||
request(client, base, token, "GET", "/scripts/${encode(script)}")
|
||||
}
|
||||
|
||||
"meta" -> {
|
||||
val script = requireScriptName(input.commandArgs)
|
||||
request(client, base, token, "GET", "/meta/${encode(script)}")
|
||||
}
|
||||
|
||||
"run" -> {
|
||||
val (script, queryPairs, postAndBody) = parseRunArgs(input.commandArgs)
|
||||
val query =
|
||||
if (queryPairs.isEmpty()) {
|
||||
""
|
||||
} else {
|
||||
queryPairs.joinToString("&", prefix = "?") { (k, v) ->
|
||||
"${encode(k)}=${encode(v)}"
|
||||
}
|
||||
}
|
||||
val (post, postBody) = postAndBody
|
||||
val method = if (post) "POST" else "GET"
|
||||
request(client, base, token, method, "/run/${encode(script)}$query", postBody)
|
||||
}
|
||||
|
||||
"create" -> {
|
||||
val script = requireScriptName(input.commandArgs)
|
||||
val (fileArg, textArg) = parseBodyAndSource(input.commandArgs.drop(1))
|
||||
val bodyContent = if (fileArg != null) File(fileArg).readText() else textArg ?: ""
|
||||
request(client, base, token, "POST", "/scripts/${encode(script)}", bodyContent)
|
||||
}
|
||||
|
||||
"update" -> {
|
||||
val script = requireScriptName(input.commandArgs)
|
||||
val (fileArg, textArg) = parseBodyAndSource(input.commandArgs.drop(1))
|
||||
val bodyContent = if (fileArg != null) File(fileArg).readText() else textArg ?: ""
|
||||
request(client, base, token, "PUT", "/scripts/${encode(script)}", bodyContent)
|
||||
}
|
||||
|
||||
"delete" -> {
|
||||
val script = requireScriptName(input.commandArgs)
|
||||
request(client, base, token, "DELETE", "/scripts/${encode(script)}")
|
||||
}
|
||||
|
||||
else -> {
|
||||
error("Unknown command: ${input.command}\n${usage()}")
|
||||
}
|
||||
}
|
||||
|
||||
println(body)
|
||||
if (status >= 400) {
|
||||
System.err.println("[HTTP $status]")
|
||||
exitProcess(1)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
System.err.println("Error: ${e.message}")
|
||||
exitProcess(1)
|
||||
}
|
||||
}
|
||||
|
||||
main(args)
|
||||
207
tools/api-cli.main.kts
Executable file
207
tools/api-cli.main.kts
Executable file
@@ -0,0 +1,207 @@
|
||||
#!/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<String>
|
||||
)
|
||||
|
||||
fun usage(): String = """
|
||||
Usage:
|
||||
elide run api-cli.main.kts [global options] <command> [command options]
|
||||
|
||||
Global options:
|
||||
--base-url=<url> Default: http://127.0.0.1:8080
|
||||
--token=<token> Authorization token
|
||||
--token-file=<path> Load token from file (fallback: HOST_API_TOKEN env)
|
||||
|
||||
Commands:
|
||||
health
|
||||
list
|
||||
show <script>
|
||||
meta <script>
|
||||
run <script> [--arg=k=v ...] [--body=text] [--post]
|
||||
create <script> (--file=<path> | --text=<content>)
|
||||
update <script> (--file=<path> | --text=<content>)
|
||||
delete <script>
|
||||
|
||||
Examples:
|
||||
elide run api-cli.main.kts --base-url=http://127.0.0.1:8080 --token-file=./scripts/.host-api-token list
|
||||
elide run api-cli.main.kts --token-file=./scripts/.host-api-token show hello
|
||||
elide run api-cli.main.kts --token-file=./scripts/.host-api-token run hello --arg=name=Alice --arg=upper=true
|
||||
elide run api-cli.main.kts --token-file=./scripts/.host-api-token create demo --file=./demo.hub.kts
|
||||
""".trimIndent()
|
||||
|
||||
fun parseInput(args: List<String>): ParsedInput {
|
||||
if (args.isEmpty() || args.contains("--help") || args.contains("-h")) {
|
||||
println(usage())
|
||||
exitProcess(0)
|
||||
}
|
||||
|
||||
var baseUrl = "http://127.0.0.1:8080"
|
||||
var token: String? = null
|
||||
var tokenFile: String? = null
|
||||
var i = 0
|
||||
while (i < args.size && args[i].startsWith("--")) {
|
||||
val arg = args[i]
|
||||
when {
|
||||
arg.startsWith("--base-url=") -> baseUrl = arg.substringAfter("=")
|
||||
arg.startsWith("--token=") -> token = arg.substringAfter("=")
|
||||
arg.startsWith("--token-file=") -> tokenFile = arg.substringAfter("=")
|
||||
else -> break
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
if (i >= args.size) error("Missing command.\n${usage()}")
|
||||
val command = args[i]
|
||||
val commandArgs = args.drop(i + 1)
|
||||
return ParsedInput(GlobalOptions(baseUrl.trimEnd('/'), token, tokenFile), command, commandArgs)
|
||||
}
|
||||
|
||||
fun readToken(options: GlobalOptions): String? {
|
||||
if (!options.token.isNullOrBlank()) return options.token
|
||||
if (!options.tokenFile.isNullOrBlank()) {
|
||||
val file = File(options.tokenFile)
|
||||
if (!file.exists()) error("Token file not found: ${file.absolutePath}")
|
||||
return file.readText().trim().ifBlank { null }
|
||||
}
|
||||
return System.getenv("HOST_API_TOKEN")?.trim()?.ifBlank { null }
|
||||
}
|
||||
|
||||
fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8)
|
||||
|
||||
fun requireScriptName(args: List<String>): String {
|
||||
if (args.isEmpty()) error("Missing <script> argument.")
|
||||
return args.first()
|
||||
}
|
||||
|
||||
fun parseBodyAndSource(args: List<String>): Pair<String?, String?> {
|
||||
val fileArg = args.firstOrNull { it.startsWith("--file=") }?.substringAfter("=")
|
||||
val textArg = args.firstOrNull { it.startsWith("--text=") }?.substringAfter("=")
|
||||
if (fileArg != null && textArg != null) error("Use either --file or --text, not both.")
|
||||
if (fileArg == null && textArg == null) error("Missing --file or --text.")
|
||||
return fileArg to textArg
|
||||
}
|
||||
|
||||
fun parseRunArgs(args: List<String>): Triple<String, List<Pair<String, String>>, Pair<Boolean, String?>> {
|
||||
val script = requireScriptName(args)
|
||||
val rest = args.drop(1)
|
||||
val query = mutableListOf<Pair<String, String>>()
|
||||
var body: String? = null
|
||||
var post = false
|
||||
for (arg in rest) {
|
||||
when {
|
||||
arg == "--post" -> post = true
|
||||
arg.startsWith("--body=") -> body = arg.substringAfter("=")
|
||||
arg.startsWith("--arg=") -> {
|
||||
val token = arg.substringAfter("--arg=")
|
||||
val idx = token.indexOf('=')
|
||||
if (idx <= 0) error("Invalid --arg format: $arg, expected --arg=key=value")
|
||||
query += token.substring(0, idx) to token.substring(idx + 1)
|
||||
}
|
||||
else -> error("Unknown run option: $arg")
|
||||
}
|
||||
}
|
||||
return Triple(script, query, post to body)
|
||||
}
|
||||
|
||||
fun request(
|
||||
client: HttpClient,
|
||||
baseUrl: String,
|
||||
token: String?,
|
||||
method: String,
|
||||
path: String,
|
||||
body: String? = null
|
||||
): Pair<Int, String> {
|
||||
val reqBuilder = HttpRequest.newBuilder(URI.create("$baseUrl$path"))
|
||||
.header("Accept", "text/plain,application/json")
|
||||
if (!token.isNullOrBlank()) reqBuilder.header("Authorization", "Bearer $token")
|
||||
|
||||
val request = when (method) {
|
||||
"GET" -> reqBuilder.GET().build()
|
||||
"DELETE" -> reqBuilder.DELETE().build()
|
||||
"POST" -> reqBuilder.header("Content-Type", "text/plain; charset=utf-8")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body ?: "")).build()
|
||||
"PUT" -> reqBuilder.header("Content-Type", "text/plain; charset=utf-8")
|
||||
.PUT(HttpRequest.BodyPublishers.ofString(body ?: "")).build()
|
||||
else -> error("Unsupported method: $method")
|
||||
}
|
||||
|
||||
val response = client.send(request, HttpResponse.BodyHandlers.ofString())
|
||||
return response.statusCode() to response.body()
|
||||
}
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
try {
|
||||
val input = parseInput(args.toList())
|
||||
val token = readToken(input.global)
|
||||
val client = HttpClient.newHttpClient()
|
||||
val base = input.global.baseUrl
|
||||
|
||||
val (status, body) = when (input.command) {
|
||||
"health" -> request(client, base, null, "GET", "/health")
|
||||
"list" -> request(client, base, token, "GET", "/scripts")
|
||||
"show" -> {
|
||||
val script = requireScriptName(input.commandArgs)
|
||||
request(client, base, token, "GET", "/scripts/${encode(script)}")
|
||||
}
|
||||
"meta" -> {
|
||||
val script = requireScriptName(input.commandArgs)
|
||||
request(client, base, token, "GET", "/meta/${encode(script)}")
|
||||
}
|
||||
"run" -> {
|
||||
val (script, queryPairs, postAndBody) = parseRunArgs(input.commandArgs)
|
||||
val query = if (queryPairs.isEmpty()) "" else queryPairs.joinToString("&", prefix = "?") { (k, v) ->
|
||||
"${encode(k)}=${encode(v)}"
|
||||
}
|
||||
val (post, postBody) = postAndBody
|
||||
val method = if (post) "POST" else "GET"
|
||||
request(client, base, token, method, "/run/${encode(script)}$query", postBody)
|
||||
}
|
||||
"create" -> {
|
||||
val script = requireScriptName(input.commandArgs)
|
||||
val (fileArg, textArg) = parseBodyAndSource(input.commandArgs.drop(1))
|
||||
val bodyContent = if (fileArg != null) File(fileArg).readText() else textArg ?: ""
|
||||
request(client, base, token, "POST", "/scripts/${encode(script)}", bodyContent)
|
||||
}
|
||||
"update" -> {
|
||||
val script = requireScriptName(input.commandArgs)
|
||||
val (fileArg, textArg) = parseBodyAndSource(input.commandArgs.drop(1))
|
||||
val bodyContent = if (fileArg != null) File(fileArg).readText() else textArg ?: ""
|
||||
request(client, base, token, "PUT", "/scripts/${encode(script)}", bodyContent)
|
||||
}
|
||||
"delete" -> {
|
||||
val script = requireScriptName(input.commandArgs)
|
||||
request(client, base, token, "DELETE", "/scripts/${encode(script)}")
|
||||
}
|
||||
else -> error("Unknown command: ${input.command}\n${usage()}")
|
||||
}
|
||||
|
||||
println(body)
|
||||
if (status >= 400) {
|
||||
System.err.println("[HTTP $status]")
|
||||
exitProcess(1)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
System.err.println("Error: ${e.message}")
|
||||
exitProcess(1)
|
||||
}
|
||||
}
|
||||
|
||||
main(args)
|
||||
536
tools/api-tui.main.kts
Normal file
536
tools/api-tui.main.kts
Normal file
@@ -0,0 +1,536 @@
|
||||
#!/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.math.max
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
data class Options(
|
||||
val baseUrl: String,
|
||||
val token: String?,
|
||||
val tokenFile: String?
|
||||
)
|
||||
|
||||
private val RESET = "\u001b[0m"
|
||||
private val BOLD = "\u001b[1m"
|
||||
private val DIM = "\u001b[2m"
|
||||
private val CYAN = "\u001b[36m"
|
||||
private val GREEN = "\u001b[32m"
|
||||
private val YELLOW = "\u001b[33m"
|
||||
private val RED = "\u001b[31m"
|
||||
private val BG_BLUE = "\u001b[44m"
|
||||
private val FG_BLACK = "\u001b[30m"
|
||||
|
||||
fun ok(text: String) = "$GREEN$text$RESET"
|
||||
fun warn(text: String) = "$YELLOW$text$RESET"
|
||||
fun err(text: String) = "$RED$text$RESET"
|
||||
fun accent(text: String) = "$CYAN$text$RESET"
|
||||
fun selected(text: String) = "$BG_BLUE$FG_BLACK$BOLD$text$RESET"
|
||||
|
||||
fun usage(): String = """
|
||||
Usage:
|
||||
kotlin tools/api-tui.main.kts [--base-url=http://127.0.0.1:8080] [--token=<token> | --token-file=./scripts/.host-api-token]
|
||||
|
||||
Keys:
|
||||
Up/Down or j/k Select script
|
||||
Left/Right or h/l Select action
|
||||
Enter Execute action
|
||||
q Quit
|
||||
""".trimIndent()
|
||||
|
||||
fun parseOptions(args: List<String>): Options {
|
||||
if (args.contains("--help") || args.contains("-h")) {
|
||||
println(usage())
|
||||
exitProcess(0)
|
||||
}
|
||||
var baseUrl = "http://127.0.0.1:8080"
|
||||
var token: String? = null
|
||||
var tokenFile: String? = null
|
||||
args.forEach { arg ->
|
||||
when {
|
||||
arg.startsWith("--base-url=") -> baseUrl = arg.substringAfter("=")
|
||||
arg.startsWith("--token=") -> token = arg.substringAfter("=")
|
||||
arg.startsWith("--token-file=") -> tokenFile = arg.substringAfter("=")
|
||||
else -> error("Unknown option: $arg\n${usage()}")
|
||||
}
|
||||
}
|
||||
return Options(baseUrl.trimEnd('/'), token, tokenFile)
|
||||
}
|
||||
|
||||
fun readToken(options: Options): String {
|
||||
if (!options.token.isNullOrBlank()) return options.token
|
||||
if (!options.tokenFile.isNullOrBlank()) {
|
||||
val file = File(options.tokenFile)
|
||||
if (!file.exists()) error("Token file not found: ${file.absolutePath}")
|
||||
return file.readText().trim()
|
||||
}
|
||||
return System.getenv("HOST_API_TOKEN")?.trim()
|
||||
?: error("Missing token. Use --token or --token-file or HOST_API_TOKEN")
|
||||
}
|
||||
|
||||
fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8)
|
||||
|
||||
fun request(
|
||||
client: HttpClient,
|
||||
baseUrl: String,
|
||||
token: String,
|
||||
method: String,
|
||||
path: String,
|
||||
body: String? = null
|
||||
): Pair<Int, String> {
|
||||
val reqBuilder = HttpRequest.newBuilder(URI.create("$baseUrl$path"))
|
||||
.header("Accept", "text/plain,application/json")
|
||||
.header("Authorization", "Bearer $token")
|
||||
val request = when (method) {
|
||||
"GET" -> reqBuilder.GET().build()
|
||||
"POST" -> reqBuilder.header("Content-Type", "text/plain; charset=utf-8")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body ?: "")).build()
|
||||
"PUT" -> reqBuilder.header("Content-Type", "text/plain; charset=utf-8")
|
||||
.PUT(HttpRequest.BodyPublishers.ofString(body ?: "")).build()
|
||||
"DELETE" -> reqBuilder.DELETE().build()
|
||||
else -> error("Unsupported method: $method")
|
||||
}
|
||||
val response = client.send(request, HttpResponse.BodyHandlers.ofString())
|
||||
return response.statusCode() to response.body()
|
||||
}
|
||||
|
||||
fun shell(cmd: String): String {
|
||||
val p = ProcessBuilder("bash", "-lc", cmd).redirectErrorStream(true).start()
|
||||
val out = p.inputStream.bufferedReader().readText()
|
||||
p.waitFor()
|
||||
return out.trim()
|
||||
}
|
||||
|
||||
fun commandExists(cmd: String): Boolean =
|
||||
ProcessBuilder("bash", "-lc", "command -v $cmd >/dev/null 2>&1").start().waitFor() == 0
|
||||
|
||||
enum class Key {
|
||||
UP, DOWN, LEFT, RIGHT, ENTER, Q, OTHER
|
||||
}
|
||||
|
||||
data class RunProfile(
|
||||
val method: String = "GET",
|
||||
val queryRaw: String = "",
|
||||
val body: String = ""
|
||||
)
|
||||
|
||||
fun readKey(): Key {
|
||||
val first = System.`in`.read()
|
||||
if (first == -1) return Key.OTHER
|
||||
return when (first) {
|
||||
10, 13 -> Key.ENTER
|
||||
'q'.code, 'Q'.code -> Key.Q
|
||||
'k'.code, 'K'.code -> Key.UP
|
||||
'j'.code, 'J'.code -> Key.DOWN
|
||||
'h'.code, 'H'.code -> Key.LEFT
|
||||
'l'.code, 'L'.code -> Key.RIGHT
|
||||
27 -> {
|
||||
val second = System.`in`.read()
|
||||
val third = System.`in`.read()
|
||||
if (second == '['.code) {
|
||||
when (third) {
|
||||
'A'.code -> Key.UP
|
||||
'B'.code -> Key.DOWN
|
||||
'C'.code -> Key.RIGHT
|
||||
'D'.code -> Key.LEFT
|
||||
else -> Key.OTHER
|
||||
}
|
||||
} else Key.OTHER
|
||||
}
|
||||
else -> Key.OTHER
|
||||
}
|
||||
}
|
||||
|
||||
fun clearScreen() {
|
||||
print("\u001b[2J\u001b[H")
|
||||
}
|
||||
|
||||
fun drawRunConfig(scriptName: String, profile: RunProfile, selected: Int, hint: String) {
|
||||
clearScreen()
|
||||
println("${accent("Run Config")} ${DIM}script=$scriptName$RESET")
|
||||
println("${DIM}Up/Down select | Left/Right toggle | Enter edit/execute | q cancel$RESET")
|
||||
println()
|
||||
|
||||
val rows = listOf(
|
||||
"Method: ${profile.method}",
|
||||
"Query: ${profile.queryRaw.ifBlank { "(empty)" }}",
|
||||
"Body: ${if (profile.method == "POST") profile.body.ifBlank { "(empty)" } else "(ignored for GET)"}",
|
||||
"Execute",
|
||||
"Cancel"
|
||||
)
|
||||
rows.forEachIndexed { idx, row ->
|
||||
if (idx == selected) println(" ${selected("> $row")}") else println(" $row")
|
||||
}
|
||||
println()
|
||||
println("${BOLD}Hint:$RESET ${colorizeStatusLine(hint)}")
|
||||
}
|
||||
|
||||
fun colorizeStatusLine(line: String): String {
|
||||
return when {
|
||||
line.startsWith("[ERROR]") || line.startsWith("[HTTP 4") || line.startsWith("[HTTP 5") -> err(line)
|
||||
line.startsWith("Loaded") || line.contains("HTTP 200") || line.startsWith("[RUN") || line.startsWith("[SHOW") || line.startsWith("[META") || line.startsWith("[CREATE") || line.startsWith("[EDIT") || line.startsWith("[DELETE") -> ok(line)
|
||||
line.startsWith("No scripts.") || line.startsWith("[HTTP 3") || line.startsWith("[CANCEL]") -> warn(line)
|
||||
else -> line
|
||||
}
|
||||
}
|
||||
|
||||
fun draw(
|
||||
baseUrl: String,
|
||||
scripts: List<String>,
|
||||
selectedScript: Int,
|
||||
actions: List<String>,
|
||||
selectedAction: Int,
|
||||
output: String
|
||||
) {
|
||||
clearScreen()
|
||||
println("${accent("API TUI")} ${DIM}base=$baseUrl$RESET")
|
||||
println("${DIM}Keys: Up/Down/j/k script | Left/Right/h/l action | Enter execute | q quit$RESET")
|
||||
println()
|
||||
|
||||
print("${BOLD}Actions:$RESET ")
|
||||
actions.forEachIndexed { idx, name ->
|
||||
if (idx == selectedAction) print(selected(" $name ")) else print("[${accent(name)}] ")
|
||||
}
|
||||
println()
|
||||
println()
|
||||
println("${BOLD}Scripts:$RESET")
|
||||
if (scripts.isEmpty()) {
|
||||
println(" ${DIM}(no scripts)$RESET")
|
||||
} else {
|
||||
scripts.forEachIndexed { idx, name ->
|
||||
if (idx == selectedScript) {
|
||||
println(" ${selected("> $name")}")
|
||||
} else {
|
||||
println(" $name")
|
||||
}
|
||||
}
|
||||
}
|
||||
println()
|
||||
println("${BOLD}Output:$RESET")
|
||||
val lines = output.lines()
|
||||
lines.takeLast(max(1, 16)).forEach { println(colorizeStatusLine(it)) }
|
||||
}
|
||||
|
||||
fun fetchScripts(client: HttpClient, baseUrl: String, token: String): Pair<List<String>, String> {
|
||||
return try {
|
||||
val (status, body) = request(client, baseUrl, token, "GET", "/scripts")
|
||||
if (status >= 400) return emptyList<String>() to "[HTTP $status]\n$body"
|
||||
val scripts = body.lines().map { it.trim() }.filter { it.isNotBlank() }.map { it.substringBefore('\t') }
|
||||
scripts to "Loaded ${scripts.size} script(s)."
|
||||
} catch (t: Throwable) {
|
||||
emptyList<String>() to "[ERROR] ${t::class.simpleName}: ${t.message}"
|
||||
}
|
||||
}
|
||||
|
||||
fun chooseEditor(): String? {
|
||||
val env = System.getenv("EDITOR")?.trim()
|
||||
if (!env.isNullOrBlank()) return env
|
||||
val fallback = listOf("nvim", "vim", "nano").firstOrNull { commandExists(it) }
|
||||
return fallback
|
||||
}
|
||||
|
||||
fun promptLine(oldStty: String, prompt: String): String {
|
||||
shell("stty $oldStty < /dev/tty")
|
||||
print("\u001b[?25h")
|
||||
print(prompt)
|
||||
val value = readLine().orEmpty()
|
||||
shell("stty -echo -icanon min 1 time 0 < /dev/tty")
|
||||
print("\u001b[?25l")
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
fun promptRawLine(oldStty: String, prompt: String): String {
|
||||
shell("stty $oldStty < /dev/tty")
|
||||
print("\u001b[?25h")
|
||||
print(prompt)
|
||||
val value = readLine().orEmpty()
|
||||
shell("stty -echo -icanon min 1 time 0 < /dev/tty")
|
||||
print("\u001b[?25l")
|
||||
return value
|
||||
}
|
||||
|
||||
fun openEditor(oldStty: String, file: File): Pair<Boolean, String> {
|
||||
val editor = chooseEditor() ?: return false to "[ERROR] No editor found (set EDITOR or install nvim/vim/nano)."
|
||||
shell("stty $oldStty < /dev/tty")
|
||||
print("\u001b[?25h")
|
||||
println("Opening editor: $editor ${file.absolutePath}")
|
||||
val cmd = """$editor "${file.absolutePath.replace("\"", "\\\"")}""""
|
||||
val process = ProcessBuilder("bash", "-lc", cmd).inheritIO().start()
|
||||
val code = process.waitFor()
|
||||
shell("stty -echo -icanon min 1 time 0 < /dev/tty")
|
||||
print("\u001b[?25l")
|
||||
return if (code == 0) true to "[OK] Editor closed." else false to "[ERROR] Editor exited with code $code"
|
||||
}
|
||||
|
||||
fun initialScriptTemplate(name: String): String = """
|
||||
// @desc: $name
|
||||
// @param: sample | default=value | desc=example parameter
|
||||
|
||||
val args: Array<String> = emptyArray()
|
||||
val kv = args.mapNotNull {
|
||||
val i = it.indexOf('=')
|
||||
if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)
|
||||
}.toMap()
|
||||
|
||||
println("script=$name")
|
||||
println("sample=" + (kv["sample"] ?: "value"))
|
||||
""".trimIndent()
|
||||
|
||||
fun buildQueryString(raw: String): String {
|
||||
val items = raw.split(Regex("[&\\s]+")).map { it.trim() }.filter { it.isNotBlank() }
|
||||
if (items.isEmpty()) return ""
|
||||
val pairs = items.mapNotNull {
|
||||
val idx = it.indexOf('=')
|
||||
if (idx <= 0) null else it.substring(0, idx) to it.substring(idx + 1)
|
||||
}
|
||||
if (pairs.isEmpty()) return ""
|
||||
return pairs.joinToString("&", prefix = "?") { (k, v) -> "${encode(k)}=${encode(v)}" }
|
||||
}
|
||||
|
||||
fun runCreateFlow(
|
||||
client: HttpClient,
|
||||
baseUrl: String,
|
||||
token: String,
|
||||
oldStty: String
|
||||
): String {
|
||||
val scriptName = promptLine(oldStty, "Create script name: ")
|
||||
if (scriptName.isBlank()) return "[CANCEL] Empty script name."
|
||||
val sourceMode = promptLine(oldStty, "Source mode [e=editor,f=file] (default e): ").lowercase().ifBlank { "e" }
|
||||
|
||||
val content = when (sourceMode) {
|
||||
"f" -> {
|
||||
val path = promptLine(oldStty, "Source file path: ")
|
||||
if (path.isBlank()) return "[CANCEL] Empty file path."
|
||||
val f = File(path)
|
||||
if (!f.exists()) return "[ERROR] File not found: ${f.absolutePath}"
|
||||
f.readText()
|
||||
}
|
||||
else -> {
|
||||
val temp = File.createTempFile("apis-create-$scriptName-", ".hub.kts")
|
||||
val template = initialScriptTemplate(scriptName)
|
||||
temp.writeText(template)
|
||||
val (ok, msg) = openEditor(oldStty, temp)
|
||||
if (!ok) {
|
||||
temp.delete()
|
||||
return msg
|
||||
}
|
||||
val text = temp.readText()
|
||||
temp.delete()
|
||||
if (text == template) return "[CANCEL] No changes in template, skipped create."
|
||||
text
|
||||
}
|
||||
}
|
||||
if (content.isBlank()) return "[CANCEL] Empty script content."
|
||||
|
||||
val (status, body) = request(client, baseUrl, token, "POST", "/scripts/${encode(scriptName)}", content)
|
||||
return "[CREATE $scriptName] HTTP $status\n$body"
|
||||
}
|
||||
|
||||
fun runEditFlow(
|
||||
client: HttpClient,
|
||||
baseUrl: String,
|
||||
token: String,
|
||||
scriptName: String,
|
||||
oldStty: String
|
||||
): String {
|
||||
val (statusGet, bodyGet) = request(client, baseUrl, token, "GET", "/scripts/${encode(scriptName)}")
|
||||
if (statusGet >= 400) return "[EDIT $scriptName] HTTP $statusGet\n$bodyGet"
|
||||
|
||||
val temp = File.createTempFile("apis-edit-$scriptName-", ".hub.kts")
|
||||
temp.writeText(bodyGet)
|
||||
val (ok, msg) = openEditor(oldStty, temp)
|
||||
if (!ok) {
|
||||
temp.delete()
|
||||
return msg
|
||||
}
|
||||
val edited = temp.readText()
|
||||
temp.delete()
|
||||
if (edited == bodyGet) return "[CANCEL] No changes for $scriptName."
|
||||
|
||||
val (statusPut, bodyPut) = request(client, baseUrl, token, "PUT", "/scripts/${encode(scriptName)}", edited)
|
||||
return "[EDIT $scriptName] HTTP $statusPut\n$bodyPut"
|
||||
}
|
||||
|
||||
fun runDeleteFlow(
|
||||
client: HttpClient,
|
||||
baseUrl: String,
|
||||
token: String,
|
||||
scriptName: String,
|
||||
oldStty: String
|
||||
): String {
|
||||
val confirm = promptLine(oldStty, "Delete '$scriptName'? [y/N]: ").lowercase()
|
||||
if (confirm != "y" && confirm != "yes") return "[CANCEL] Delete aborted."
|
||||
val (status, body) = request(client, baseUrl, token, "DELETE", "/scripts/${encode(scriptName)}")
|
||||
return "[DELETE $scriptName] HTTP $status\n$body"
|
||||
}
|
||||
|
||||
fun runScriptFlow(
|
||||
client: HttpClient,
|
||||
baseUrl: String,
|
||||
token: String,
|
||||
scriptName: String,
|
||||
oldStty: String,
|
||||
initialProfile: RunProfile
|
||||
): Pair<String, RunProfile?> {
|
||||
var profile = initialProfile
|
||||
var selected = 0
|
||||
var hint = "Configure request and choose Execute."
|
||||
|
||||
while (true) {
|
||||
drawRunConfig(scriptName, profile, selected, hint)
|
||||
when (readKey()) {
|
||||
Key.UP -> selected = if (selected == 0) 4 else selected - 1
|
||||
Key.DOWN -> selected = if (selected == 4) 0 else selected + 1
|
||||
Key.LEFT, Key.RIGHT -> {
|
||||
if (selected == 0) {
|
||||
profile = profile.copy(method = if (profile.method == "GET") "POST" else "GET")
|
||||
hint = "Method set to ${profile.method}"
|
||||
}
|
||||
}
|
||||
Key.Q -> return "[CANCEL] Run aborted." to null
|
||||
Key.ENTER -> {
|
||||
when (selected) {
|
||||
0 -> {
|
||||
profile = profile.copy(method = if (profile.method == "GET") "POST" else "GET")
|
||||
hint = "Method set to ${profile.method}"
|
||||
}
|
||||
1 -> {
|
||||
val input = promptRawLine(oldStty, "Query args (k=v separated by '&' or space): ")
|
||||
profile = profile.copy(queryRaw = input.trim())
|
||||
hint = "Query updated."
|
||||
}
|
||||
2 -> {
|
||||
if (profile.method == "POST") {
|
||||
val input = promptRawLine(oldStty, "POST body (single line, blank allowed): ")
|
||||
profile = profile.copy(body = input)
|
||||
hint = "Body updated."
|
||||
} else {
|
||||
hint = "Body is ignored for GET."
|
||||
}
|
||||
}
|
||||
3 -> {
|
||||
val query = buildQueryString(profile.queryRaw)
|
||||
val body = if (profile.method == "POST") profile.body else null
|
||||
val (status, response) = request(client, baseUrl, token, profile.method, "/run/${encode(scriptName)}$query", body)
|
||||
return "[RUN $scriptName] HTTP $status\n$response" to profile
|
||||
}
|
||||
4 -> return "[CANCEL] Run aborted." to null
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
val options = parseOptions(args.toList())
|
||||
val token = readToken(options)
|
||||
val client = HttpClient.newHttpClient()
|
||||
val actions = listOf("Refresh", "Show", "Run", "Meta", "Create", "Edit", "Delete", "Quit")
|
||||
val runProfiles = mutableMapOf<String, RunProfile>()
|
||||
var selectedAction = 0
|
||||
var selectedScript = 0
|
||||
var output = ""
|
||||
|
||||
val oldStty = shell("stty -g < /dev/tty")
|
||||
shell("stty -echo -icanon min 1 time 0 < /dev/tty")
|
||||
print("\u001b[?25l")
|
||||
|
||||
try {
|
||||
var (scripts, initMessage) = fetchScripts(client, options.baseUrl, token)
|
||||
output = initMessage
|
||||
if (selectedScript >= scripts.size) selectedScript = max(0, scripts.size - 1)
|
||||
|
||||
while (true) {
|
||||
draw(options.baseUrl, scripts, selectedScript, actions, selectedAction, output)
|
||||
when (readKey()) {
|
||||
Key.UP -> if (scripts.isNotEmpty()) selectedScript = max(0, selectedScript - 1)
|
||||
Key.DOWN -> if (scripts.isNotEmpty()) selectedScript = minOf(scripts.lastIndex, selectedScript + 1)
|
||||
Key.LEFT -> selectedAction = if (selectedAction == 0) actions.lastIndex else selectedAction - 1
|
||||
Key.RIGHT -> selectedAction = if (selectedAction == actions.lastIndex) 0 else selectedAction + 1
|
||||
Key.Q -> break
|
||||
Key.ENTER -> {
|
||||
when (actions[selectedAction]) {
|
||||
"Refresh" -> {
|
||||
val result = fetchScripts(client, options.baseUrl, token)
|
||||
scripts = result.first
|
||||
if (selectedScript >= scripts.size) selectedScript = max(0, scripts.size - 1)
|
||||
output = result.second
|
||||
}
|
||||
"Show" -> {
|
||||
output = if (scripts.isEmpty()) {
|
||||
"No scripts."
|
||||
} else runCatching {
|
||||
val script = scripts[selectedScript]
|
||||
val (status, body) = request(client, options.baseUrl, token, "GET", "/scripts/${encode(script)}")
|
||||
"[SHOW $script] HTTP $status\n$body"
|
||||
}.getOrElse { "[ERROR] ${it::class.simpleName}: ${it.message}" }
|
||||
}
|
||||
"Run" -> {
|
||||
output = if (scripts.isEmpty()) {
|
||||
"No scripts."
|
||||
} else runCatching {
|
||||
val script = scripts[selectedScript]
|
||||
val initial = runProfiles[script] ?: RunProfile()
|
||||
val (text, updated) = runScriptFlow(client, options.baseUrl, token, script, oldStty, initial)
|
||||
if (updated != null) runProfiles[script] = updated
|
||||
text
|
||||
}.getOrElse { "[ERROR] ${it::class.simpleName}: ${it.message}" }
|
||||
}
|
||||
"Meta" -> {
|
||||
output = if (scripts.isEmpty()) {
|
||||
"No scripts."
|
||||
} else runCatching {
|
||||
val script = scripts[selectedScript]
|
||||
val (status, body) = request(client, options.baseUrl, token, "GET", "/meta/${encode(script)}")
|
||||
"[META $script] HTTP $status\n$body"
|
||||
}.getOrElse { "[ERROR] ${it::class.simpleName}: ${it.message}" }
|
||||
}
|
||||
"Create" -> {
|
||||
output = runCatching { runCreateFlow(client, options.baseUrl, token, oldStty) }
|
||||
.getOrElse { "[ERROR] ${it::class.simpleName}: ${it.message}" }
|
||||
val refreshed = fetchScripts(client, options.baseUrl, token)
|
||||
scripts = refreshed.first
|
||||
if (selectedScript >= scripts.size) selectedScript = max(0, scripts.size - 1)
|
||||
}
|
||||
"Edit" -> {
|
||||
output = if (scripts.isEmpty()) {
|
||||
"No scripts."
|
||||
} else runCatching {
|
||||
val script = scripts[selectedScript]
|
||||
runEditFlow(client, options.baseUrl, token, script, oldStty)
|
||||
}.getOrElse { "[ERROR] ${it::class.simpleName}: ${it.message}" }
|
||||
val refreshed = fetchScripts(client, options.baseUrl, token)
|
||||
scripts = refreshed.first
|
||||
if (selectedScript >= scripts.size) selectedScript = max(0, scripts.size - 1)
|
||||
}
|
||||
"Delete" -> {
|
||||
output = if (scripts.isEmpty()) {
|
||||
"No scripts."
|
||||
} else runCatching {
|
||||
val script = scripts[selectedScript]
|
||||
runDeleteFlow(client, options.baseUrl, token, script, oldStty)
|
||||
}.getOrElse { "[ERROR] ${it::class.simpleName}: ${it.message}" }
|
||||
val refreshed = fetchScripts(client, options.baseUrl, token)
|
||||
scripts = refreshed.first
|
||||
if (selectedScript >= scripts.size) selectedScript = max(0, scripts.size - 1)
|
||||
}
|
||||
"Quit" -> break
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
shell("stty $oldStty < /dev/tty")
|
||||
print("\u001b[?25h")
|
||||
clearScreen()
|
||||
}
|
||||
}
|
||||
|
||||
main(args)
|
||||
Reference in New Issue
Block a user