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