feat: add token-aware API TUI with subtoken actions and row-based focus navigation
This commit is contained in:
22
README.md
22
README.md
@@ -24,9 +24,13 @@ Auth:
|
|||||||
- Token source:
|
- Token source:
|
||||||
- Preferred: set env `HOST_API_TOKEN`.
|
- Preferred: set env `HOST_API_TOKEN`.
|
||||||
- Otherwise host auto-generates a token and stores it at `scripts/.host-api-token`.
|
- Otherwise host auto-generates a token and stores it at `scripts/.host-api-token`.
|
||||||
|
- Token types:
|
||||||
|
- `root`: full access to all endpoints.
|
||||||
|
- `sub`: only `health`, filtered `scripts`, and allowed-script `meta`/`run`.
|
||||||
|
|
||||||
Routes:
|
Routes:
|
||||||
- `GET /health`
|
- `GET /health`
|
||||||
|
- `GET /type`
|
||||||
- `GET /scripts`
|
- `GET /scripts`
|
||||||
- `GET /scripts/{script}` (raw script content)
|
- `GET /scripts/{script}` (raw script content)
|
||||||
- `POST /scripts/{script}`
|
- `POST /scripts/{script}`
|
||||||
@@ -35,11 +39,17 @@ Routes:
|
|||||||
- `GET /meta/{script}`
|
- `GET /meta/{script}`
|
||||||
- `GET /run/{script}?k=v`
|
- `GET /run/{script}?k=v`
|
||||||
- `POST /run/{script}?k=v`
|
- `POST /run/{script}?k=v`
|
||||||
|
- `GET /subtokens` (root only)
|
||||||
|
- `GET /subtokens/{name}` (root only)
|
||||||
|
- `POST /subtokens/{name}` (root only, body = script names list)
|
||||||
|
- `PUT /subtokens/{name}` (root only, body = script names list)
|
||||||
|
- `DELETE /subtokens/{name}` (root only)
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
```bash
|
```bash
|
||||||
curl 'http://127.0.0.1:8080/health'
|
curl 'http://127.0.0.1:8080/health'
|
||||||
TOKEN="$(cat scripts/.host-api-token)"
|
TOKEN="$(cat scripts/.host-api-token)"
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" 'http://127.0.0.1:8080/type'
|
||||||
curl -H "Authorization: Bearer $TOKEN" 'http://127.0.0.1:8080/scripts'
|
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" '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 POST 'http://127.0.0.1:8080/scripts/new-api' --data-binary $'// @desc: new api\nval args: Array<String> = emptyArray()\nprintln("ok")'
|
||||||
@@ -48,6 +58,8 @@ curl -H "Authorization: Bearer $TOKEN" -X DELETE 'http://127.0.0.1:8080/scripts/
|
|||||||
curl -H "Authorization: Bearer $TOKEN" 'http://127.0.0.1:8080/meta/hello'
|
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" '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'
|
curl -H "Authorization: Bearer $TOKEN" -X POST 'http://127.0.0.1:8080/run/hello?name=Alice' -d 'from-body'
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" -X POST 'http://127.0.0.1:8080/subtokens/demo-sub' --data-binary $'hello\ntime'
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" 'http://127.0.0.1:8080/subtokens'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Script Metadata & Args (`*.hub.kts`)
|
## Script Metadata & Args (`*.hub.kts`)
|
||||||
@@ -84,9 +96,12 @@ A standalone CLI script is available at `tools/api-cli.main.kts` (independent fr
|
|||||||
Examples:
|
Examples:
|
||||||
```bash
|
```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 --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 type
|
||||||
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 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 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")'
|
kotlin tools/api-cli.main.kts --token-file=./scripts/.host-api-token create demo --text='// @desc: demo\nval args: Array<String> = emptyArray()\nprintln("ok")'
|
||||||
|
kotlin tools/api-cli.main.kts --token-file=./scripts/.host-api-token sub-create demo-sub --scripts=hello,time
|
||||||
|
kotlin tools/api-cli.main.kts --token-file=./scripts/.host-api-token sub-list
|
||||||
```
|
```
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
@@ -102,10 +117,15 @@ kotlin tools/api-tui.main.kts --base-url=http://127.0.0.1:8080 --token-file=./sc
|
|||||||
|
|
||||||
Keys:
|
Keys:
|
||||||
- `Up/Down` or `j/k`: switch script
|
- `Up/Down` or `j/k`: switch script
|
||||||
- `Left/Right` or `h/l`: switch action (`Refresh/Show/Run/Meta/Create/Edit/Delete/Quit`)
|
- `Left/Right` or `h/l`: switch action (grouped by `Script` / `SubToken` / `System`)
|
||||||
- `Enter`: execute selected action
|
- `Enter`: execute selected action
|
||||||
- `q`: quit
|
- `q`: quit
|
||||||
|
|
||||||
|
Action model:
|
||||||
|
- `root` token: full grouped actions including `Subtokens`
|
||||||
|
- `sub` token: reduced action set (`Refresh/Run/Meta/Type/Quit`)
|
||||||
|
- `Subtokens` opens a keyboard sub-menu (`List/Show/Create/Update/Delete/Back`)
|
||||||
|
|
||||||
Create/Edit/Delete behavior:
|
Create/Edit/Delete behavior:
|
||||||
- `Create`: prompt script name, then choose source mode:
|
- `Create`: prompt script name, then choose source mode:
|
||||||
- `e` (default): create temp file, open terminal editor, then upload via API
|
- `e` (default): create temp file, open terminal editor, then upload via API
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import io.ktor.server.application.Application
|
|||||||
import io.ktor.server.application.call
|
import io.ktor.server.application.call
|
||||||
import io.ktor.server.engine.embeddedServer
|
import io.ktor.server.engine.embeddedServer
|
||||||
import io.ktor.server.netty.Netty
|
import io.ktor.server.netty.Netty
|
||||||
|
import io.ktor.server.request.receiveText
|
||||||
import io.ktor.server.response.respondText
|
import io.ktor.server.response.respondText
|
||||||
import io.ktor.server.routing.delete
|
import io.ktor.server.routing.delete
|
||||||
import io.ktor.server.routing.get
|
import io.ktor.server.routing.get
|
||||||
@@ -18,35 +19,122 @@ private const val DEFAULT_PORT = 8080
|
|||||||
private const val DEFAULT_SCRIPTS_DIR = "scripts"
|
private const val DEFAULT_SCRIPTS_DIR = "scripts"
|
||||||
private const val DEFAULT_HOST = "0.0.0.0"
|
private const val DEFAULT_HOST = "0.0.0.0"
|
||||||
|
|
||||||
private fun Application.module(scriptsDir: File, apiToken: String) {
|
private suspend fun handleSubTokenCreate(call: io.ktor.server.application.ApplicationCall, security: HostSecurity) {
|
||||||
|
val name = call.parameters["name"]
|
||||||
|
?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest)
|
||||||
|
|
||||||
|
if (!name.matches(Regex("[A-Za-z0-9._-]+"))) {
|
||||||
|
return call.respondText("invalid subtoken name", status = HttpStatusCode.BadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
val scriptsRaw = call.receiveText()
|
||||||
|
val scripts =
|
||||||
|
try {
|
||||||
|
parseScriptNameSet(scriptsRaw)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
return call.respondText(t.message ?: "invalid script names", status = HttpStatusCode.BadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
val created =
|
||||||
|
try {
|
||||||
|
security.subTokens.create(name, scripts)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
return call.respondText(t.message ?: "failed to create subtoken", status = HttpStatusCode.Conflict)
|
||||||
|
}
|
||||||
|
|
||||||
|
call.respondText(subTokenItemJson(created, includeToken = true), contentType = ContentType.Application.Json, status = HttpStatusCode.Created)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleSubTokenUpdate(call: io.ktor.server.application.ApplicationCall, security: HostSecurity) {
|
||||||
|
val name = call.parameters["name"]
|
||||||
|
?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest)
|
||||||
|
|
||||||
|
val scriptsRaw = call.receiveText()
|
||||||
|
val scripts =
|
||||||
|
try {
|
||||||
|
parseScriptNameSet(scriptsRaw)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
return call.respondText(t.message ?: "invalid script names", status = HttpStatusCode.BadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
val updated =
|
||||||
|
try {
|
||||||
|
security.subTokens.update(name, scripts)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
return call.respondText(t.message ?: "failed to update subtoken", status = HttpStatusCode.NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
call.respondText(subTokenItemJson(updated, includeToken = true), contentType = ContentType.Application.Json)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleSubTokenGet(call: io.ktor.server.application.ApplicationCall, security: HostSecurity) {
|
||||||
|
val name = call.parameters["name"]
|
||||||
|
?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest)
|
||||||
|
|
||||||
|
val item = security.subTokens.get(name)
|
||||||
|
?: return call.respondText("subtoken not found: $name", status = HttpStatusCode.NotFound)
|
||||||
|
|
||||||
|
call.respondText(subTokenItemJson(item, includeToken = true), contentType = ContentType.Application.Json)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleSubTokenDelete(call: io.ktor.server.application.ApplicationCall, security: HostSecurity) {
|
||||||
|
val name = call.parameters["name"]
|
||||||
|
?: return call.respondText("missing subtoken name", status = HttpStatusCode.BadRequest)
|
||||||
|
|
||||||
|
val deleted = security.subTokens.delete(name)
|
||||||
|
if (!deleted) return call.respondText("subtoken not found: $name", status = HttpStatusCode.NotFound)
|
||||||
|
|
||||||
|
call.respondText("deleted subtoken: $name")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Application.module(scriptsDir: File, security: HostSecurity) {
|
||||||
routing {
|
routing {
|
||||||
get("/health") {
|
get("/health") {
|
||||||
call.respondText("OK")
|
call.respondText("OK")
|
||||||
}
|
}
|
||||||
get("/scripts") {
|
|
||||||
if (!requireAuth(call, apiToken)) return@get
|
get("/type") {
|
||||||
call.respondText(renderScriptList(scriptsDir), ContentType.Text.Plain)
|
val auth = requireAuth(call, security) ?: return@get
|
||||||
|
call.respondText(tokenTypeJson(auth), contentType = ContentType.Application.Json)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get("/scripts") {
|
||||||
|
val auth = requireAuth(call, security) ?: return@get
|
||||||
|
val allow = visibleScriptsFor(auth)
|
||||||
|
call.respondText(renderScriptList(scriptsDir, allow), ContentType.Text.Plain)
|
||||||
|
}
|
||||||
|
|
||||||
get("/scripts/{script}") {
|
get("/scripts/{script}") {
|
||||||
if (!requireAuth(call, apiToken)) return@get
|
val auth = requireAuth(call, security) ?: return@get
|
||||||
|
if (!requireRoot(call, auth)) return@get
|
||||||
handleGetScriptContent(call, scriptsDir)
|
handleGetScriptContent(call, scriptsDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
post("/scripts/{script}") {
|
post("/scripts/{script}") {
|
||||||
if (!requireAuth(call, apiToken)) return@post
|
val auth = requireAuth(call, security) ?: return@post
|
||||||
|
if (!requireRoot(call, auth)) return@post
|
||||||
handleCreateScript(call, scriptsDir)
|
handleCreateScript(call, scriptsDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
put("/scripts/{script}") {
|
put("/scripts/{script}") {
|
||||||
if (!requireAuth(call, apiToken)) return@put
|
val auth = requireAuth(call, security) ?: return@put
|
||||||
|
if (!requireRoot(call, auth)) return@put
|
||||||
handleUpdateScript(call, scriptsDir)
|
handleUpdateScript(call, scriptsDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
delete("/scripts/{script}") {
|
delete("/scripts/{script}") {
|
||||||
if (!requireAuth(call, apiToken)) return@delete
|
val auth = requireAuth(call, security) ?: return@delete
|
||||||
|
if (!requireRoot(call, auth)) return@delete
|
||||||
handleDeleteScript(call, scriptsDir)
|
handleDeleteScript(call, scriptsDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
get("/meta/{script}") {
|
get("/meta/{script}") {
|
||||||
if (!requireAuth(call, apiToken)) return@get
|
val auth = requireAuth(call, security) ?: return@get
|
||||||
val name = call.parameters["script"]
|
val name = call.parameters["script"]
|
||||||
?: return@get call.respondText("missing route name", status = HttpStatusCode.BadRequest)
|
?: return@get call.respondText("missing route name", status = HttpStatusCode.BadRequest)
|
||||||
|
|
||||||
|
if (!requireScriptAccess(call, auth, name)) return@get
|
||||||
|
|
||||||
val script = resolveScriptFile(scriptsDir, name)
|
val script = resolveScriptFile(scriptsDir, name)
|
||||||
?: return@get call.respondText("invalid script name", status = HttpStatusCode.BadRequest)
|
?: return@get call.respondText("invalid script name", status = HttpStatusCode.BadRequest)
|
||||||
if (!script.exists()) {
|
if (!script.exists()) {
|
||||||
@@ -56,17 +144,55 @@ private fun Application.module(scriptsDir: File, apiToken: String) {
|
|||||||
val (metadata, source) = loadMetadata(script)
|
val (metadata, source) = loadMetadata(script)
|
||||||
call.respondText(
|
call.respondText(
|
||||||
metadataJson(name, metadata, source),
|
metadataJson(name, metadata, source),
|
||||||
contentType = ContentType.Application.Json
|
contentType = ContentType.Application.Json,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
get("/run/{script}") {
|
get("/run/{script}") {
|
||||||
if (!requireAuth(call, apiToken)) return@get
|
val auth = requireAuth(call, security) ?: return@get
|
||||||
|
val name = call.parameters["script"]
|
||||||
|
?: return@get call.respondText("missing route name", status = HttpStatusCode.BadRequest)
|
||||||
|
if (!requireScriptAccess(call, auth, name)) return@get
|
||||||
handleRunRequest(call, scriptsDir, consumeBody = false)
|
handleRunRequest(call, scriptsDir, consumeBody = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
post("/run/{script}") {
|
post("/run/{script}") {
|
||||||
if (!requireAuth(call, apiToken)) return@post
|
val auth = requireAuth(call, security) ?: return@post
|
||||||
|
val name = call.parameters["script"]
|
||||||
|
?: return@post call.respondText("missing route name", status = HttpStatusCode.BadRequest)
|
||||||
|
if (!requireScriptAccess(call, auth, name)) return@post
|
||||||
handleRunRequest(call, scriptsDir, consumeBody = true)
|
handleRunRequest(call, scriptsDir, consumeBody = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get("/subtokens") {
|
||||||
|
val auth = requireAuth(call, security) ?: return@get
|
||||||
|
if (!requireRoot(call, auth)) return@get
|
||||||
|
call.respondText(subTokenListJson(security.subTokens.list()), contentType = ContentType.Application.Json)
|
||||||
|
}
|
||||||
|
|
||||||
|
get("/subtokens/{name}") {
|
||||||
|
val auth = requireAuth(call, security) ?: return@get
|
||||||
|
if (!requireRoot(call, auth)) return@get
|
||||||
|
handleSubTokenGet(call, security)
|
||||||
|
}
|
||||||
|
|
||||||
|
post("/subtokens/{name}") {
|
||||||
|
val auth = requireAuth(call, security) ?: return@post
|
||||||
|
if (!requireRoot(call, auth)) return@post
|
||||||
|
handleSubTokenCreate(call, security)
|
||||||
|
}
|
||||||
|
|
||||||
|
put("/subtokens/{name}") {
|
||||||
|
val auth = requireAuth(call, security) ?: return@put
|
||||||
|
if (!requireRoot(call, auth)) return@put
|
||||||
|
handleSubTokenUpdate(call, security)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete("/subtokens/{name}") {
|
||||||
|
val auth = requireAuth(call, security) ?: return@delete
|
||||||
|
if (!requireRoot(call, auth)) return@delete
|
||||||
|
handleSubTokenDelete(call, security)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,18 +203,24 @@ Usage:
|
|||||||
./gradlew runWeb --args='[--host=0.0.0.0] [--port=8080] [--scripts-dir=./scripts]'
|
./gradlew runWeb --args='[--host=0.0.0.0] [--port=8080] [--scripts-dir=./scripts]'
|
||||||
Routes:
|
Routes:
|
||||||
GET /health
|
GET /health
|
||||||
|
GET /type
|
||||||
Authorization:
|
Authorization:
|
||||||
Authorization: Bearer <token>
|
Authorization: Bearer <token>
|
||||||
or X-Host-Token: <token>
|
or X-Host-Token: <token>
|
||||||
GET /scripts
|
GET /scripts
|
||||||
GET /scripts/{script}
|
GET /scripts/{script} (root only)
|
||||||
POST /scripts/{script}
|
POST /scripts/{script} (root only)
|
||||||
PUT /scripts/{script}
|
PUT /scripts/{script} (root only)
|
||||||
DELETE /scripts/{script}
|
DELETE /scripts/{script} (root only)
|
||||||
GET /meta/{script}
|
GET /meta/{script} (root or allowed subtoken)
|
||||||
GET /run/{script}?k=v
|
GET /run/{script}?k=v (root or allowed subtoken)
|
||||||
POST /run/{script}?k=v
|
POST /run/{script}?k=v (root or allowed subtoken)
|
||||||
""".trimIndent()
|
GET /subtokens (root only)
|
||||||
|
GET /subtokens/{name} (root only)
|
||||||
|
POST /subtokens/{name} (root only, body: script names list)
|
||||||
|
PUT /subtokens/{name} (root only, body: script names list)
|
||||||
|
DELETE /subtokens/{name} (root only)
|
||||||
|
""".trimIndent(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +241,7 @@ fun main(args: Array<String>) {
|
|||||||
|
|
||||||
if (!scriptsDir.exists()) scriptsDir.mkdirs()
|
if (!scriptsDir.exists()) scriptsDir.mkdirs()
|
||||||
val auth = loadOrCreateApiToken(scriptsDir)
|
val auth = loadOrCreateApiToken(scriptsDir)
|
||||||
|
val security = createHostSecurity(scriptsDir, auth.token)
|
||||||
|
|
||||||
println("Starting script web host on http://$host:$port")
|
println("Starting script web host on http://$host:$port")
|
||||||
println("Scripts directory: ${scriptsDir.absolutePath}")
|
println("Scripts directory: ${scriptsDir.absolutePath}")
|
||||||
@@ -117,10 +250,11 @@ fun main(args: Array<String>) {
|
|||||||
auth.source.startsWith("env:") -> println("Auth token loaded from environment variable.")
|
auth.source.startsWith("env:") -> println("Auth token loaded from environment variable.")
|
||||||
auth.source.startsWith("generated:") ->
|
auth.source.startsWith("generated:") ->
|
||||||
println("Auth token generated and saved to: ${auth.tokenFile?.absolutePath}")
|
println("Auth token generated and saved to: ${auth.tokenFile?.absolutePath}")
|
||||||
|
|
||||||
else -> println("Auth token loaded from file: ${auth.tokenFile?.absolutePath}")
|
else -> println("Auth token loaded from file: ${auth.tokenFile?.absolutePath}")
|
||||||
}
|
}
|
||||||
|
|
||||||
embeddedServer(Netty, port = port, host = host) {
|
embeddedServer(Netty, port = port, host = host) {
|
||||||
module(scriptsDir, auth.token)
|
module(scriptsDir, security)
|
||||||
}.start(wait = true)
|
}.start(wait = true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,11 @@ private fun listScriptNames(scriptsDir: File): List<String> =
|
|||||||
?.toList()
|
?.toList()
|
||||||
?: emptyList()
|
?: emptyList()
|
||||||
|
|
||||||
fun renderScriptList(scriptsDir: File): String =
|
fun renderScriptList(scriptsDir: File, allowNames: Set<String>? = null): String =
|
||||||
listScriptNames(scriptsDir).joinToString("\n") { name ->
|
listScriptNames(scriptsDir)
|
||||||
|
.asSequence()
|
||||||
|
.filter { allowNames == null || allowNames.contains(it) }
|
||||||
|
.joinToString("\n") { name ->
|
||||||
val file = resolveScriptFile(scriptsDir, name)
|
val file = resolveScriptFile(scriptsDir, name)
|
||||||
val description = file?.let(::cachedMetadata)?.description
|
val description = file?.let(::cachedMetadata)?.description
|
||||||
if (description.isNullOrBlank()) name else "$name\t$description"
|
if (description.isNullOrBlank()) name else "$name\t$description"
|
||||||
|
|||||||
@@ -10,15 +10,151 @@ import java.security.SecureRandom
|
|||||||
|
|
||||||
private const val ENV_API_TOKEN = "HOST_API_TOKEN"
|
private const val ENV_API_TOKEN = "HOST_API_TOKEN"
|
||||||
private const val TOKEN_FILE_NAME = ".host-api-token"
|
private const val TOKEN_FILE_NAME = ".host-api-token"
|
||||||
|
private const val SUBTOKEN_FILE_NAME = ".host-subtokens.db"
|
||||||
private const val ALT_TOKEN_HEADER = "X-Host-Token"
|
private const val ALT_TOKEN_HEADER = "X-Host-Token"
|
||||||
|
private val SCRIPT_NAME_REGEX = Regex("[A-Za-z0-9._-]+")
|
||||||
|
|
||||||
data class ApiTokenConfig(
|
data class ApiTokenConfig(
|
||||||
val token: String,
|
val token: String,
|
||||||
val source: String,
|
val source: String,
|
||||||
val tokenFile: File?
|
val tokenFile: File?,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun randomTokenHex(bytes: Int = 32): String {
|
enum class TokenType {
|
||||||
|
ROOT,
|
||||||
|
SUB,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SubTokenRecord(
|
||||||
|
val name: String,
|
||||||
|
val token: String,
|
||||||
|
val scripts: Set<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AuthContext(
|
||||||
|
val type: TokenType,
|
||||||
|
val subTokenName: String? = null,
|
||||||
|
val allowedScripts: Set<String> = emptySet(),
|
||||||
|
)
|
||||||
|
|
||||||
|
class SubTokenStore(
|
||||||
|
private val storageFile: File,
|
||||||
|
) {
|
||||||
|
private val lock = Any()
|
||||||
|
private var loaded = false
|
||||||
|
private val byName = linkedMapOf<String, SubTokenRecord>()
|
||||||
|
|
||||||
|
fun list(): List<SubTokenRecord> =
|
||||||
|
synchronized(lock) {
|
||||||
|
ensureLoaded()
|
||||||
|
byName.values.sortedBy { it.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun get(name: String): SubTokenRecord? =
|
||||||
|
synchronized(lock) {
|
||||||
|
ensureLoaded()
|
||||||
|
byName[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun findByToken(token: String): SubTokenRecord? =
|
||||||
|
synchronized(lock) {
|
||||||
|
ensureLoaded()
|
||||||
|
byName.values.firstOrNull { it.token == token }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun create(name: String, scripts: Set<String>): SubTokenRecord {
|
||||||
|
synchronized(lock) {
|
||||||
|
ensureLoaded()
|
||||||
|
require(name.matches(SCRIPT_NAME_REGEX)) { "invalid subtoken name" }
|
||||||
|
if (byName.containsKey(name)) error("subtoken already exists: $name")
|
||||||
|
|
||||||
|
val record = SubTokenRecord(name = name, token = randomTokenHex(), scripts = scripts.sorted().toSet())
|
||||||
|
byName[name] = record
|
||||||
|
persist()
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(name: String, scripts: Set<String>): SubTokenRecord {
|
||||||
|
synchronized(lock) {
|
||||||
|
ensureLoaded()
|
||||||
|
val existing = byName[name] ?: error("subtoken not found: $name")
|
||||||
|
val updated = existing.copy(scripts = scripts.sorted().toSet())
|
||||||
|
byName[name] = updated
|
||||||
|
persist()
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun delete(name: String): Boolean =
|
||||||
|
synchronized(lock) {
|
||||||
|
ensureLoaded()
|
||||||
|
val removed = byName.remove(name) ?: return false
|
||||||
|
persist()
|
||||||
|
removed.name.isNotBlank()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureLoaded() {
|
||||||
|
if (loaded) return
|
||||||
|
loaded = true
|
||||||
|
|
||||||
|
if (!storageFile.exists()) return
|
||||||
|
storageFile.readLines().forEach { line ->
|
||||||
|
val trimmed = line.trim()
|
||||||
|
if (trimmed.isBlank() || trimmed.startsWith("#")) return@forEach
|
||||||
|
|
||||||
|
val parts = trimmed.split('\t')
|
||||||
|
if (parts.size < 2) return@forEach
|
||||||
|
|
||||||
|
val name = parts[0].trim()
|
||||||
|
val token = parts[1].trim()
|
||||||
|
if (!name.matches(SCRIPT_NAME_REGEX) || token.isBlank()) return@forEach
|
||||||
|
|
||||||
|
val scripts =
|
||||||
|
parts.getOrNull(2)
|
||||||
|
?.split(',')
|
||||||
|
?.map { it.trim() }
|
||||||
|
?.filter { it.isNotBlank() && it.matches(SCRIPT_NAME_REGEX) }
|
||||||
|
?.toSet()
|
||||||
|
?: emptySet()
|
||||||
|
|
||||||
|
byName[name] = SubTokenRecord(name, token, scripts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun persist() {
|
||||||
|
storageFile.parentFile?.mkdirs()
|
||||||
|
val text =
|
||||||
|
buildString {
|
||||||
|
appendLine("# name\\ttoken\\tscript1,script2,...")
|
||||||
|
byName.values.sortedBy { it.name }.forEach { record ->
|
||||||
|
val scripts = record.scripts.sorted().joinToString(",")
|
||||||
|
append(record.name)
|
||||||
|
append('\t')
|
||||||
|
append(record.token)
|
||||||
|
append('\t')
|
||||||
|
append(scripts)
|
||||||
|
appendLine()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
storageFile.writeText(text)
|
||||||
|
storageFile.setReadable(false, false)
|
||||||
|
storageFile.setReadable(true, true)
|
||||||
|
storageFile.setWritable(false, false)
|
||||||
|
storageFile.setWritable(true, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class HostSecurity(
|
||||||
|
val rootToken: String,
|
||||||
|
val subTokens: SubTokenStore,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun createHostSecurity(scriptsDir: File, rootToken: String): HostSecurity =
|
||||||
|
HostSecurity(rootToken = rootToken, subTokens = SubTokenStore(File(scriptsDir, SUBTOKEN_FILE_NAME)))
|
||||||
|
|
||||||
|
fun randomTokenHex(bytes: Int = 32): String {
|
||||||
val random = ByteArray(bytes)
|
val random = ByteArray(bytes)
|
||||||
SecureRandom().nextBytes(random)
|
SecureRandom().nextBytes(random)
|
||||||
return random.joinToString("") { "%02x".format(it) }
|
return random.joinToString("") { "%02x".format(it) }
|
||||||
@@ -53,11 +189,86 @@ private fun extractProvidedToken(call: ApplicationCall): String? {
|
|||||||
return call.request.headers[ALT_TOKEN_HEADER]?.trim()
|
return call.request.headers[ALT_TOKEN_HEADER]?.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun requireAuth(call: ApplicationCall, expectedToken: String): Boolean {
|
private fun authenticateToken(call: ApplicationCall, security: HostSecurity): AuthContext? {
|
||||||
val provided = extractProvidedToken(call)
|
val provided = extractProvidedToken(call)
|
||||||
if (provided == expectedToken) return true
|
if (provided.isNullOrBlank()) return null
|
||||||
|
if (provided == security.rootToken) return AuthContext(type = TokenType.ROOT)
|
||||||
|
|
||||||
|
val sub = security.subTokens.findByToken(provided) ?: return null
|
||||||
|
return AuthContext(type = TokenType.SUB, subTokenName = sub.name, allowedScripts = sub.scripts)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun requireAuth(call: ApplicationCall, security: HostSecurity): AuthContext? {
|
||||||
|
val context = authenticateToken(call, security)
|
||||||
|
if (context != null) return context
|
||||||
|
|
||||||
call.response.headers.append(HttpHeaders.WWWAuthenticate, "Bearer realm=\"script-host\"")
|
call.response.headers.append(HttpHeaders.WWWAuthenticate, "Bearer realm=\"script-host\"")
|
||||||
call.respondText("unauthorized", status = HttpStatusCode.Unauthorized, contentType = ContentType.Text.Plain)
|
call.respondText("unauthorized", status = HttpStatusCode.Unauthorized, contentType = ContentType.Text.Plain)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun requireRoot(call: ApplicationCall, context: AuthContext): Boolean {
|
||||||
|
if (context.type == TokenType.ROOT) return true
|
||||||
|
call.respondText("forbidden: root token required", status = HttpStatusCode.Forbidden, contentType = ContentType.Text.Plain)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun requireScriptAccess(call: ApplicationCall, context: AuthContext, scriptName: String): Boolean {
|
||||||
|
if (context.type == TokenType.ROOT) return true
|
||||||
|
if (context.allowedScripts.contains(scriptName)) return true
|
||||||
|
call.respondText("forbidden: subtoken has no access to script '$scriptName'", status = HttpStatusCode.Forbidden, contentType = ContentType.Text.Plain)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun visibleScriptsFor(context: AuthContext): Set<String>? =
|
||||||
|
when (context.type) {
|
||||||
|
TokenType.ROOT -> null
|
||||||
|
TokenType.SUB -> context.allowedScripts
|
||||||
|
}
|
||||||
|
|
||||||
|
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 tokenTypeJson(context: AuthContext): String {
|
||||||
|
val scripts = context.allowedScripts.sorted().joinToString(",") { "\"${it.jsonEscaped()}\"" }
|
||||||
|
val name = context.subTokenName?.let { "\"${it.jsonEscaped()}\"" } ?: "null"
|
||||||
|
return """{"tokenType":"${context.type.name.lowercase()}","subTokenName":$name,"scripts":[${scripts}]}"""
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseScriptNameSet(raw: String): Set<String> {
|
||||||
|
val values =
|
||||||
|
raw.split(Regex("[,\\n\\r\\t\\s]+"))
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.toSet()
|
||||||
|
|
||||||
|
require(values.all { it.matches(SCRIPT_NAME_REGEX) }) {
|
||||||
|
"invalid script names, only [A-Za-z0-9._-] is allowed"
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
fun subTokenListJson(records: List<SubTokenRecord>): String {
|
||||||
|
val items =
|
||||||
|
records.joinToString(",") { record ->
|
||||||
|
val scripts = record.scripts.sorted().joinToString(",") { "\"${it.jsonEscaped()}\"" }
|
||||||
|
"""{"name":"${record.name.jsonEscaped()}","token":"${record.token.jsonEscaped()}","scripts":[${scripts}]}"""
|
||||||
|
}
|
||||||
|
return """{"items":[${items}]}"""
|
||||||
|
}
|
||||||
|
|
||||||
|
fun subTokenItemJson(record: SubTokenRecord, includeToken: Boolean): String {
|
||||||
|
val scripts = record.scripts.sorted().joinToString(",") { "\"${it.jsonEscaped()}\"" }
|
||||||
|
val tokenPart = if (includeToken) "\"${record.token.jsonEscaped()}\"" else "null"
|
||||||
|
return """{"name":"${record.name.jsonEscaped()}","token":$tokenPart,"scripts":[${scripts}]}"""
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ Global options:
|
|||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
health
|
health
|
||||||
|
type
|
||||||
list
|
list
|
||||||
show <script>
|
show <script>
|
||||||
meta <script>
|
meta <script>
|
||||||
@@ -41,11 +42,16 @@ Commands:
|
|||||||
update <script> (--file=<path> | --text=<content>)
|
update <script> (--file=<path> | --text=<content>)
|
||||||
delete <script>
|
delete <script>
|
||||||
|
|
||||||
|
sub-list
|
||||||
|
sub-show <name>
|
||||||
|
sub-create <name> --scripts=a,b,c
|
||||||
|
sub-update <name> --scripts=a,b,c
|
||||||
|
sub-delete <name>
|
||||||
|
|
||||||
Examples:
|
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 type
|
||||||
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 sub-list
|
||||||
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 sub-create demo --scripts=hello,time
|
||||||
elide run api-cli.main.kts --token-file=./scripts/.host-api-token create demo --file=./demo.hub.kts
|
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
fun parseInput(args: List<String>): ParsedInput {
|
fun parseInput(args: List<String>): ParsedInput {
|
||||||
@@ -92,6 +98,11 @@ fun requireScriptName(args: List<String>): String {
|
|||||||
return args.first()
|
return args.first()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun requireNameArg(args: List<String>, label: String): String {
|
||||||
|
if (args.isEmpty()) error("Missing <$label> argument.")
|
||||||
|
return args.first()
|
||||||
|
}
|
||||||
|
|
||||||
fun parseBodyAndSource(args: List<String>): Pair<String?, String?> {
|
fun parseBodyAndSource(args: List<String>): Pair<String?, String?> {
|
||||||
val fileArg = args.firstOrNull { it.startsWith("--file=") }?.substringAfter("=")
|
val fileArg = args.firstOrNull { it.startsWith("--file=") }?.substringAfter("=")
|
||||||
val textArg = args.firstOrNull { it.startsWith("--text=") }?.substringAfter("=")
|
val textArg = args.firstOrNull { it.startsWith("--text=") }?.substringAfter("=")
|
||||||
@@ -131,6 +142,20 @@ fun parseRunArgs(args: List<String>): Triple<String, List<Pair<String, String>>,
|
|||||||
return Triple(script, query, post to body)
|
return Triple(script, query, post to body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun parseScriptsArg(args: List<String>): Set<String> {
|
||||||
|
val raw = args.firstOrNull { it.startsWith("--scripts=") }?.substringAfter("=")
|
||||||
|
?: error("Missing --scripts=a,b,c")
|
||||||
|
val items =
|
||||||
|
raw.split(',')
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.toSet()
|
||||||
|
if (items.any { !Regex("[A-Za-z0-9._-]+$").matches(it) }) {
|
||||||
|
error("Invalid script names in --scripts, only [A-Za-z0-9._-] allowed")
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
fun request(
|
fun request(
|
||||||
client: HttpClient,
|
client: HttpClient,
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
@@ -147,31 +172,13 @@ fun request(
|
|||||||
|
|
||||||
val request =
|
val request =
|
||||||
when (method) {
|
when (method) {
|
||||||
"GET" -> {
|
"GET" -> reqBuilder.GET().build()
|
||||||
reqBuilder.GET().build()
|
"DELETE" -> reqBuilder.DELETE().build()
|
||||||
}
|
"POST" -> reqBuilder.header("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(body ?: "")).build()
|
||||||
"DELETE" -> {
|
"PUT" -> reqBuilder.header("Content-Type", "text/plain; charset=utf-8")
|
||||||
reqBuilder.DELETE().build()
|
.PUT(HttpRequest.BodyPublishers.ofString(body ?: "")).build()
|
||||||
}
|
else -> error("Unsupported method: $method")
|
||||||
|
|
||||||
"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())
|
val response = client.send(request, HttpResponse.BodyHandlers.ofString())
|
||||||
@@ -187,14 +194,9 @@ fun main(args: Array<String>) {
|
|||||||
|
|
||||||
val (status, body) =
|
val (status, body) =
|
||||||
when (input.command) {
|
when (input.command) {
|
||||||
"health" -> {
|
"health" -> request(client, base, null, "GET", "/health")
|
||||||
request(client, base, null, "GET", "/health")
|
"type" -> request(client, base, token, "GET", "/type")
|
||||||
}
|
"list" -> request(client, base, token, "GET", "/scripts")
|
||||||
|
|
||||||
"list" -> {
|
|
||||||
request(client, base, token, "GET", "/scripts")
|
|
||||||
}
|
|
||||||
|
|
||||||
"show" -> {
|
"show" -> {
|
||||||
val script = requireScriptName(input.commandArgs)
|
val script = requireScriptName(input.commandArgs)
|
||||||
request(client, base, token, "GET", "/scripts/${encode(script)}")
|
request(client, base, token, "GET", "/scripts/${encode(script)}")
|
||||||
@@ -208,13 +210,7 @@ fun main(args: Array<String>) {
|
|||||||
"run" -> {
|
"run" -> {
|
||||||
val (script, queryPairs, postAndBody) = parseRunArgs(input.commandArgs)
|
val (script, queryPairs, postAndBody) = parseRunArgs(input.commandArgs)
|
||||||
val query =
|
val query =
|
||||||
if (queryPairs.isEmpty()) {
|
if (queryPairs.isEmpty()) "" else queryPairs.joinToString("&", prefix = "?") { (k, v) -> "${encode(k)}=${encode(v)}" }
|
||||||
""
|
|
||||||
} else {
|
|
||||||
queryPairs.joinToString("&", prefix = "?") { (k, v) ->
|
|
||||||
"${encode(k)}=${encode(v)}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val (post, postBody) = postAndBody
|
val (post, postBody) = postAndBody
|
||||||
val method = if (post) "POST" else "GET"
|
val method = if (post) "POST" else "GET"
|
||||||
request(client, base, token, method, "/run/${encode(script)}$query", postBody)
|
request(client, base, token, method, "/run/${encode(script)}$query", postBody)
|
||||||
@@ -239,9 +235,30 @@ fun main(args: Array<String>) {
|
|||||||
request(client, base, token, "DELETE", "/scripts/${encode(script)}")
|
request(client, base, token, "DELETE", "/scripts/${encode(script)}")
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
"sub-list" -> request(client, base, token, "GET", "/subtokens")
|
||||||
error("Unknown command: ${input.command}\n${usage()}")
|
"sub-show" -> {
|
||||||
|
val name = requireNameArg(input.commandArgs, "name")
|
||||||
|
request(client, base, token, "GET", "/subtokens/${encode(name)}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"sub-create" -> {
|
||||||
|
val name = requireNameArg(input.commandArgs, "name")
|
||||||
|
val scripts = parseScriptsArg(input.commandArgs.drop(1))
|
||||||
|
request(client, base, token, "POST", "/subtokens/${encode(name)}", scripts.joinToString("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
"sub-update" -> {
|
||||||
|
val name = requireNameArg(input.commandArgs, "name")
|
||||||
|
val scripts = parseScriptsArg(input.commandArgs.drop(1))
|
||||||
|
request(client, base, token, "PUT", "/subtokens/${encode(name)}", scripts.joinToString("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
"sub-delete" -> {
|
||||||
|
val name = requireNameArg(input.commandArgs, "name")
|
||||||
|
request(client, base, token, "DELETE", "/subtokens/${encode(name)}")
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> error("Unknown command: ${input.command}\n${usage()}")
|
||||||
}
|
}
|
||||||
|
|
||||||
println(body)
|
println(body)
|
||||||
|
|||||||
@@ -12,16 +12,17 @@ import kotlin.system.exitProcess
|
|||||||
data class GlobalOptions(
|
data class GlobalOptions(
|
||||||
val baseUrl: String,
|
val baseUrl: String,
|
||||||
val token: String?,
|
val token: String?,
|
||||||
val tokenFile: String?
|
val tokenFile: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ParsedInput(
|
data class ParsedInput(
|
||||||
val global: GlobalOptions,
|
val global: GlobalOptions,
|
||||||
val command: String,
|
val command: String,
|
||||||
val commandArgs: List<String>
|
val commandArgs: List<String>,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun usage(): String = """
|
fun usage(): String =
|
||||||
|
"""
|
||||||
Usage:
|
Usage:
|
||||||
elide run api-cli.main.kts [global options] <command> [command options]
|
elide run api-cli.main.kts [global options] <command> [command options]
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ Global options:
|
|||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
health
|
health
|
||||||
|
type
|
||||||
list
|
list
|
||||||
show <script>
|
show <script>
|
||||||
meta <script>
|
meta <script>
|
||||||
@@ -40,12 +42,17 @@ Commands:
|
|||||||
update <script> (--file=<path> | --text=<content>)
|
update <script> (--file=<path> | --text=<content>)
|
||||||
delete <script>
|
delete <script>
|
||||||
|
|
||||||
|
sub-list
|
||||||
|
sub-show <name>
|
||||||
|
sub-create <name> --scripts=a,b,c
|
||||||
|
sub-update <name> --scripts=a,b,c
|
||||||
|
sub-delete <name>
|
||||||
|
|
||||||
Examples:
|
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 type
|
||||||
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 sub-list
|
||||||
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 sub-create demo --scripts=hello,time
|
||||||
elide run api-cli.main.kts --token-file=./scripts/.host-api-token create demo --file=./demo.hub.kts
|
""".trimIndent()
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
fun parseInput(args: List<String>): ParsedInput {
|
fun parseInput(args: List<String>): ParsedInput {
|
||||||
if (args.isEmpty() || args.contains("--help") || args.contains("-h")) {
|
if (args.isEmpty() || args.contains("--help") || args.contains("-h")) {
|
||||||
@@ -91,6 +98,11 @@ fun requireScriptName(args: List<String>): String {
|
|||||||
return args.first()
|
return args.first()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun requireNameArg(args: List<String>, label: String): String {
|
||||||
|
if (args.isEmpty()) error("Missing <$label> argument.")
|
||||||
|
return args.first()
|
||||||
|
}
|
||||||
|
|
||||||
fun parseBodyAndSource(args: List<String>): Pair<String?, String?> {
|
fun parseBodyAndSource(args: List<String>): Pair<String?, String?> {
|
||||||
val fileArg = args.firstOrNull { it.startsWith("--file=") }?.substringAfter("=")
|
val fileArg = args.firstOrNull { it.startsWith("--file=") }?.substringAfter("=")
|
||||||
val textArg = args.firstOrNull { it.startsWith("--text=") }?.substringAfter("=")
|
val textArg = args.firstOrNull { it.startsWith("--text=") }?.substringAfter("=")
|
||||||
@@ -107,41 +119,67 @@ fun parseRunArgs(args: List<String>): Triple<String, List<Pair<String, String>>,
|
|||||||
var post = false
|
var post = false
|
||||||
for (arg in rest) {
|
for (arg in rest) {
|
||||||
when {
|
when {
|
||||||
arg == "--post" -> post = true
|
arg == "--post" -> {
|
||||||
arg.startsWith("--body=") -> body = arg.substringAfter("=")
|
post = true
|
||||||
|
}
|
||||||
|
|
||||||
|
arg.startsWith("--body=") -> {
|
||||||
|
body = arg.substringAfter("=")
|
||||||
|
}
|
||||||
|
|
||||||
arg.startsWith("--arg=") -> {
|
arg.startsWith("--arg=") -> {
|
||||||
val token = arg.substringAfter("--arg=")
|
val token = arg.substringAfter("--arg=")
|
||||||
val idx = token.indexOf('=')
|
val idx = token.indexOf('=')
|
||||||
if (idx <= 0) error("Invalid --arg format: $arg, expected --arg=key=value")
|
if (idx <= 0) error("Invalid --arg format: $arg, expected --arg=key=value")
|
||||||
query += token.substring(0, idx) to token.substring(idx + 1)
|
query += token.substring(0, idx) to token.substring(idx + 1)
|
||||||
}
|
}
|
||||||
else -> error("Unknown run option: $arg")
|
|
||||||
|
else -> {
|
||||||
|
error("Unknown run option: $arg")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Triple(script, query, post to body)
|
return Triple(script, query, post to body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun parseScriptsArg(args: List<String>): Set<String> {
|
||||||
|
val raw = args.firstOrNull { it.startsWith("--scripts=") }?.substringAfter("=")
|
||||||
|
?: error("Missing --scripts=a,b,c")
|
||||||
|
val items =
|
||||||
|
raw.split(',')
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.toSet()
|
||||||
|
if (items.any { !Regex("[A-Za-z0-9._-]+$").matches(it) }) {
|
||||||
|
error("Invalid script names in --scripts, only [A-Za-z0-9._-] allowed")
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
fun request(
|
fun request(
|
||||||
client: HttpClient,
|
client: HttpClient,
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
token: String?,
|
token: String?,
|
||||||
method: String,
|
method: String,
|
||||||
path: String,
|
path: String,
|
||||||
body: String? = null
|
body: String? = null,
|
||||||
): Pair<Int, String> {
|
): Pair<Int, String> {
|
||||||
val reqBuilder = HttpRequest.newBuilder(URI.create("$baseUrl$path"))
|
val reqBuilder =
|
||||||
.header("Accept", "text/plain,application/json")
|
HttpRequest
|
||||||
|
.newBuilder(URI.create("$baseUrl$path"))
|
||||||
|
.header("Accept", "text/plain,application/json")
|
||||||
if (!token.isNullOrBlank()) reqBuilder.header("Authorization", "Bearer $token")
|
if (!token.isNullOrBlank()) reqBuilder.header("Authorization", "Bearer $token")
|
||||||
|
|
||||||
val request = when (method) {
|
val request =
|
||||||
"GET" -> reqBuilder.GET().build()
|
when (method) {
|
||||||
"DELETE" -> reqBuilder.DELETE().build()
|
"GET" -> reqBuilder.GET().build()
|
||||||
"POST" -> reqBuilder.header("Content-Type", "text/plain; charset=utf-8")
|
"DELETE" -> reqBuilder.DELETE().build()
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(body ?: "")).build()
|
"POST" -> reqBuilder.header("Content-Type", "text/plain; charset=utf-8")
|
||||||
"PUT" -> reqBuilder.header("Content-Type", "text/plain; charset=utf-8")
|
.POST(HttpRequest.BodyPublishers.ofString(body ?: "")).build()
|
||||||
.PUT(HttpRequest.BodyPublishers.ofString(body ?: "")).build()
|
"PUT" -> reqBuilder.header("Content-Type", "text/plain; charset=utf-8")
|
||||||
else -> error("Unsupported method: $method")
|
.PUT(HttpRequest.BodyPublishers.ofString(body ?: "")).build()
|
||||||
}
|
else -> error("Unsupported method: $method")
|
||||||
|
}
|
||||||
|
|
||||||
val response = client.send(request, HttpResponse.BodyHandlers.ofString())
|
val response = client.send(request, HttpResponse.BodyHandlers.ofString())
|
||||||
return response.statusCode() to response.body()
|
return response.statusCode() to response.body()
|
||||||
@@ -154,44 +192,74 @@ fun main(args: Array<String>) {
|
|||||||
val client = HttpClient.newHttpClient()
|
val client = HttpClient.newHttpClient()
|
||||||
val base = input.global.baseUrl
|
val base = input.global.baseUrl
|
||||||
|
|
||||||
val (status, body) = when (input.command) {
|
val (status, body) =
|
||||||
"health" -> request(client, base, null, "GET", "/health")
|
when (input.command) {
|
||||||
"list" -> request(client, base, token, "GET", "/scripts")
|
"health" -> request(client, base, null, "GET", "/health")
|
||||||
"show" -> {
|
"type" -> request(client, base, token, "GET", "/type")
|
||||||
val script = requireScriptName(input.commandArgs)
|
"list" -> request(client, base, token, "GET", "/scripts")
|
||||||
request(client, base, token, "GET", "/scripts/${encode(script)}")
|
"show" -> {
|
||||||
}
|
val script = requireScriptName(input.commandArgs)
|
||||||
"meta" -> {
|
request(client, base, token, "GET", "/scripts/${encode(script)}")
|
||||||
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"
|
"meta" -> {
|
||||||
request(client, base, token, method, "/run/${encode(script)}$query", postBody)
|
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)}")
|
||||||
|
}
|
||||||
|
|
||||||
|
"sub-list" -> request(client, base, token, "GET", "/subtokens")
|
||||||
|
"sub-show" -> {
|
||||||
|
val name = requireNameArg(input.commandArgs, "name")
|
||||||
|
request(client, base, token, "GET", "/subtokens/${encode(name)}")
|
||||||
|
}
|
||||||
|
|
||||||
|
"sub-create" -> {
|
||||||
|
val name = requireNameArg(input.commandArgs, "name")
|
||||||
|
val scripts = parseScriptsArg(input.commandArgs.drop(1))
|
||||||
|
request(client, base, token, "POST", "/subtokens/${encode(name)}", scripts.joinToString("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
"sub-update" -> {
|
||||||
|
val name = requireNameArg(input.commandArgs, "name")
|
||||||
|
val scripts = parseScriptsArg(input.commandArgs.drop(1))
|
||||||
|
request(client, base, token, "PUT", "/subtokens/${encode(name)}", scripts.joinToString("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
"sub-delete" -> {
|
||||||
|
val name = requireNameArg(input.commandArgs, "name")
|
||||||
|
request(client, base, token, "DELETE", "/subtokens/${encode(name)}")
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> error("Unknown command: ${input.command}\n${usage()}")
|
||||||
}
|
}
|
||||||
"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)
|
println(body)
|
||||||
if (status >= 400) {
|
if (status >= 400) {
|
||||||
|
|||||||
594
tools/api-tui.main.kts
Normal file → Executable file
594
tools/api-tui.main.kts
Normal file → Executable file
@@ -13,7 +13,18 @@ import kotlin.system.exitProcess
|
|||||||
data class Options(
|
data class Options(
|
||||||
val baseUrl: String,
|
val baseUrl: String,
|
||||||
val token: String?,
|
val token: String?,
|
||||||
val tokenFile: String?
|
val tokenFile: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TokenInfo(
|
||||||
|
val tokenType: String,
|
||||||
|
val subTokenName: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RunProfile(
|
||||||
|
val method: String = "GET",
|
||||||
|
val queryRaw: String = "",
|
||||||
|
val body: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
private val RESET = "\u001b[0m"
|
private val RESET = "\u001b[0m"
|
||||||
@@ -26,22 +37,40 @@ private val RED = "\u001b[31m"
|
|||||||
private val BG_BLUE = "\u001b[44m"
|
private val BG_BLUE = "\u001b[44m"
|
||||||
private val FG_BLACK = "\u001b[30m"
|
private val FG_BLACK = "\u001b[30m"
|
||||||
|
|
||||||
fun ok(text: String) = "$GREEN$text$RESET"
|
private fun ok(text: String) = "$GREEN$text$RESET"
|
||||||
fun warn(text: String) = "$YELLOW$text$RESET"
|
private fun warn(text: String) = "$YELLOW$text$RESET"
|
||||||
fun err(text: String) = "$RED$text$RESET"
|
private fun err(text: String) = "$RED$text$RESET"
|
||||||
fun accent(text: String) = "$CYAN$text$RESET"
|
private fun accent(text: String) = "$CYAN$text$RESET"
|
||||||
fun selected(text: String) = "$BG_BLUE$FG_BLACK$BOLD$text$RESET"
|
private fun selected(text: String) = "$BG_BLUE$FG_BLACK$BOLD$text$RESET"
|
||||||
|
|
||||||
fun usage(): String = """
|
enum class Key {
|
||||||
|
UP, DOWN, LEFT, RIGHT, ENTER, Q, OTHER,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class FocusRow {
|
||||||
|
TARGET,
|
||||||
|
ACTION,
|
||||||
|
LIST,
|
||||||
|
SYSTEM,
|
||||||
|
}
|
||||||
|
|
||||||
|
fun usage(): String =
|
||||||
|
"""
|
||||||
Usage:
|
Usage:
|
||||||
kotlin tools/api-tui.main.kts [--base-url=http://127.0.0.1:8080] [--token=<token> | --token-file=./scripts/.host-api-token]
|
kotlin tools/api-tui.main.kts [--base-url=http://127.0.0.1:8080] [--token=<token> | --token-file=./scripts/.host-api-token]
|
||||||
|
|
||||||
|
Layout:
|
||||||
|
Actions:
|
||||||
|
Target: [Scripts] [Subtokens]
|
||||||
|
Action: [Create] [Edit] [Delete] [Refresh] ... (depends on target)
|
||||||
|
System: [Type] [Quit]
|
||||||
|
|
||||||
Keys:
|
Keys:
|
||||||
Up/Down or j/k Select script
|
Up/Down or j/k Focus row (Target/Action/List/System)
|
||||||
Left/Right or h/l Select action
|
Left/Right or h/l Select item in focused row
|
||||||
Enter Execute action
|
Enter Execute selected Action/System
|
||||||
q Quit
|
q Quit
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
fun parseOptions(args: List<String>): Options {
|
fun parseOptions(args: List<String>): Options {
|
||||||
if (args.contains("--help") || args.contains("-h")) {
|
if (args.contains("--help") || args.contains("-h")) {
|
||||||
@@ -81,24 +110,45 @@ fun request(
|
|||||||
token: String,
|
token: String,
|
||||||
method: String,
|
method: String,
|
||||||
path: String,
|
path: String,
|
||||||
body: String? = null
|
body: String? = null,
|
||||||
): Pair<Int, String> {
|
): Pair<Int, String> {
|
||||||
val reqBuilder = HttpRequest.newBuilder(URI.create("$baseUrl$path"))
|
val reqBuilder =
|
||||||
.header("Accept", "text/plain,application/json")
|
HttpRequest
|
||||||
.header("Authorization", "Bearer $token")
|
.newBuilder(URI.create("$baseUrl$path"))
|
||||||
val request = when (method) {
|
.header("Accept", "text/plain,application/json")
|
||||||
"GET" -> reqBuilder.GET().build()
|
.header("Authorization", "Bearer $token")
|
||||||
"POST" -> reqBuilder.header("Content-Type", "text/plain; charset=utf-8")
|
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(body ?: "")).build()
|
val request =
|
||||||
"PUT" -> reqBuilder.header("Content-Type", "text/plain; charset=utf-8")
|
when (method) {
|
||||||
.PUT(HttpRequest.BodyPublishers.ofString(body ?: "")).build()
|
"GET" -> reqBuilder.GET().build()
|
||||||
"DELETE" -> reqBuilder.DELETE().build()
|
"POST" -> reqBuilder.header("Content-Type", "text/plain; charset=utf-8")
|
||||||
else -> error("Unsupported method: $method")
|
.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())
|
val response = client.send(request, HttpResponse.BodyHandlers.ofString())
|
||||||
return response.statusCode() to response.body()
|
return response.statusCode() to response.body()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun parseTokenInfo(raw: String): TokenInfo {
|
||||||
|
val type = Regex("\"tokenType\"\\s*:\\s*\"([^\"]+)\"").find(raw)?.groupValues?.get(1) ?: "unknown"
|
||||||
|
val subName = Regex("\"subTokenName\"\\s*:\\s*(null|\"([^\"]*)\")").find(raw)?.groupValues?.let {
|
||||||
|
if (it[1] == "null") null else it[2]
|
||||||
|
}
|
||||||
|
return TokenInfo(type.lowercase(), subName)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fetchTokenInfo(client: HttpClient, baseUrl: String, token: String): Pair<TokenInfo, String> {
|
||||||
|
val (status, body) = request(client, baseUrl, token, "GET", "/type")
|
||||||
|
if (status >= 400) error("failed to fetch /type, HTTP $status: $body")
|
||||||
|
val info = parseTokenInfo(body)
|
||||||
|
val label = if (info.tokenType == "sub") "sub:${info.subTokenName ?: "-"}" else info.tokenType
|
||||||
|
return info to "Token type: $label"
|
||||||
|
}
|
||||||
|
|
||||||
fun shell(cmd: String): String {
|
fun shell(cmd: String): String {
|
||||||
val p = ProcessBuilder("bash", "-lc", cmd).redirectErrorStream(true).start()
|
val p = ProcessBuilder("bash", "-lc", cmd).redirectErrorStream(true).start()
|
||||||
val out = p.inputStream.bufferedReader().readText()
|
val out = p.inputStream.bufferedReader().readText()
|
||||||
@@ -109,16 +159,6 @@ fun shell(cmd: String): String {
|
|||||||
fun commandExists(cmd: String): Boolean =
|
fun commandExists(cmd: String): Boolean =
|
||||||
ProcessBuilder("bash", "-lc", "command -v $cmd >/dev/null 2>&1").start().waitFor() == 0
|
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 {
|
fun readKey(): Key {
|
||||||
val first = System.`in`.read()
|
val first = System.`in`.read()
|
||||||
if (first == -1) return Key.OTHER
|
if (first == -1) return Key.OTHER
|
||||||
@@ -150,70 +190,61 @@ fun clearScreen() {
|
|||||||
print("\u001b[2J\u001b[H")
|
print("\u001b[2J\u001b[H")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun drawRunConfig(scriptName: String, profile: RunProfile, selected: Int, hint: String) {
|
fun colorizeStatusLine(line: String): String =
|
||||||
clearScreen()
|
when {
|
||||||
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("[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("Loaded") || line.contains("HTTP 200") || line.startsWith("[RUN") || line.startsWith("[SHOW") || line.startsWith("[META") || line.startsWith("[CREATE") || line.startsWith("[EDIT") || line.startsWith("[DELETE") || line.startsWith("[SUB") || line.startsWith("Token type:") -> ok(line)
|
||||||
line.startsWith("No scripts.") || line.startsWith("[HTTP 3") || line.startsWith("[CANCEL]") -> warn(line)
|
line.startsWith("No scripts.") || line.startsWith("[HTTP 3") || line.startsWith("[CANCEL]") -> warn(line)
|
||||||
else -> line
|
else -> line
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun drawRow(label: String, items: List<String>, selectedIdx: Int, focused: Boolean) {
|
||||||
|
print(" ${if (focused) selected("$label") else "$DIM$label$RESET"}: ")
|
||||||
|
items.forEachIndexed { idx, name ->
|
||||||
|
if (idx == selectedIdx) print(selected(" $name ")) else print("[${accent(name)}] ")
|
||||||
|
}
|
||||||
|
println()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun draw(
|
fun draw(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
scripts: List<String>,
|
targetOptions: List<String>,
|
||||||
selectedScript: Int,
|
targetIdx: Int,
|
||||||
actions: List<String>,
|
actionOptions: List<String>,
|
||||||
selectedAction: Int,
|
actionIdx: Int,
|
||||||
output: String
|
systemOptions: List<String>,
|
||||||
|
systemIdx: Int,
|
||||||
|
listTitle: String,
|
||||||
|
listItems: List<String>,
|
||||||
|
listIdx: Int,
|
||||||
|
focus: FocusRow,
|
||||||
|
output: String,
|
||||||
) {
|
) {
|
||||||
clearScreen()
|
clearScreen()
|
||||||
println("${accent("API TUI")} ${DIM}base=$baseUrl$RESET")
|
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("${DIM}Keys: Up/Down focus row | Left/Right select | Enter execute | q quit$RESET")
|
||||||
println()
|
println()
|
||||||
|
|
||||||
print("${BOLD}Actions:$RESET ")
|
println("${BOLD}Actions:$RESET")
|
||||||
actions.forEachIndexed { idx, name ->
|
drawRow("Target", targetOptions, targetIdx, focus == FocusRow.TARGET)
|
||||||
if (idx == selectedAction) print(selected(" $name ")) else print("[${accent(name)}] ")
|
drawRow("Action", actionOptions, actionIdx, focus == FocusRow.ACTION)
|
||||||
}
|
drawRow("System", systemOptions, systemIdx, focus == FocusRow.SYSTEM)
|
||||||
|
|
||||||
println()
|
println()
|
||||||
println()
|
println("${BOLD}$listTitle:$RESET ${if (focus == FocusRow.LIST) selected(" selected ") else ""}")
|
||||||
println("${BOLD}Scripts:$RESET")
|
if (listItems.isEmpty()) {
|
||||||
if (scripts.isEmpty()) {
|
println(" ${DIM}(no items)$RESET")
|
||||||
println(" ${DIM}(no scripts)$RESET")
|
|
||||||
} else {
|
} else {
|
||||||
scripts.forEachIndexed { idx, name ->
|
print(" ")
|
||||||
if (idx == selectedScript) {
|
listItems.forEachIndexed { idx, name ->
|
||||||
println(" ${selected("> $name")}")
|
if (idx == listIdx) print(selected(" $name ")) else print("[${accent(name)}] ")
|
||||||
} else {
|
|
||||||
println(" $name")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
println()
|
||||||
}
|
}
|
||||||
|
|
||||||
println()
|
println()
|
||||||
println("${BOLD}Output:$RESET")
|
println("${BOLD}Output:$RESET")
|
||||||
val lines = output.lines()
|
output.lines().takeLast(max(1, 18)).forEach { println(colorizeStatusLine(it)) }
|
||||||
lines.takeLast(max(1, 16)).forEach { println(colorizeStatusLine(it)) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fetchScripts(client: HttpClient, baseUrl: String, token: String): Pair<List<String>, String> {
|
fun fetchScripts(client: HttpClient, baseUrl: String, token: String): Pair<List<String>, String> {
|
||||||
@@ -227,11 +258,21 @@ fun fetchScripts(client: HttpClient, baseUrl: String, token: String): Pair<List<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun fetchSubtokens(client: HttpClient, baseUrl: String, token: String): Pair<List<String>, String> {
|
||||||
|
return try {
|
||||||
|
val (status, body) = request(client, baseUrl, token, "GET", "/subtokens")
|
||||||
|
if (status >= 400) return emptyList<String>() to "[HTTP $status]\n$body"
|
||||||
|
val names = Regex("\"name\"\\s*:\\s*\"([^\"]+)\"").findAll(body).map { it.groupValues[1] }.toList()
|
||||||
|
names to "Loaded ${names.size} subtoken(s)."
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
emptyList<String>() to "[ERROR] ${t::class.simpleName}: ${t.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun chooseEditor(): String? {
|
fun chooseEditor(): String? {
|
||||||
val env = System.getenv("EDITOR")?.trim()
|
val env = System.getenv("EDITOR")?.trim()
|
||||||
if (!env.isNullOrBlank()) return env
|
if (!env.isNullOrBlank()) return env
|
||||||
val fallback = listOf("nvim", "vim", "nano").firstOrNull { commandExists(it) }
|
return listOf("nvim", "vim", "nano").firstOrNull { commandExists(it) }
|
||||||
return fallback
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun promptLine(oldStty: String, prompt: String): String {
|
fun promptLine(oldStty: String, prompt: String): String {
|
||||||
@@ -259,7 +300,7 @@ fun openEditor(oldStty: String, file: File): Pair<Boolean, String> {
|
|||||||
shell("stty $oldStty < /dev/tty")
|
shell("stty $oldStty < /dev/tty")
|
||||||
print("\u001b[?25h")
|
print("\u001b[?25h")
|
||||||
println("Opening editor: $editor ${file.absolutePath}")
|
println("Opening editor: $editor ${file.absolutePath}")
|
||||||
val cmd = """$editor "${file.absolutePath.replace("\"", "\\\"")}""""
|
val cmd = "$editor \"${file.absolutePath.replace("\"", "\\\"")}\""
|
||||||
val process = ProcessBuilder("bash", "-lc", cmd).inheritIO().start()
|
val process = ProcessBuilder("bash", "-lc", cmd).inheritIO().start()
|
||||||
val code = process.waitFor()
|
val code = process.waitFor()
|
||||||
shell("stty -echo -icanon min 1 time 0 < /dev/tty")
|
shell("stty -echo -icanon min 1 time 0 < /dev/tty")
|
||||||
@@ -267,7 +308,8 @@ fun openEditor(oldStty: String, file: File): Pair<Boolean, String> {
|
|||||||
return if (code == 0) true to "[OK] Editor closed." else false to "[ERROR] Editor exited with code $code"
|
return if (code == 0) true to "[OK] Editor closed." else false to "[ERROR] Editor exited with code $code"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun initialScriptTemplate(name: String): String = """
|
fun initialScriptTemplate(name: String): String =
|
||||||
|
"""
|
||||||
// @desc: $name
|
// @desc: $name
|
||||||
// @param: sample | default=value | desc=example parameter
|
// @param: sample | default=value | desc=example parameter
|
||||||
|
|
||||||
@@ -279,7 +321,7 @@ val kv = args.mapNotNull {
|
|||||||
|
|
||||||
println("script=$name")
|
println("script=$name")
|
||||||
println("sample=" + (kv["sample"] ?: "value"))
|
println("sample=" + (kv["sample"] ?: "value"))
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
fun buildQueryString(raw: String): String {
|
fun buildQueryString(raw: String): String {
|
||||||
val items = raw.split(Regex("[&\\s]+")).map { it.trim() }.filter { it.isNotBlank() }
|
val items = raw.split(Regex("[&\\s]+")).map { it.trim() }.filter { it.isNotBlank() }
|
||||||
@@ -292,12 +334,7 @@ fun buildQueryString(raw: String): String {
|
|||||||
return pairs.joinToString("&", prefix = "?") { (k, v) -> "${encode(k)}=${encode(v)}" }
|
return pairs.joinToString("&", prefix = "?") { (k, v) -> "${encode(k)}=${encode(v)}" }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun runCreateFlow(
|
fun runCreateFlow(client: HttpClient, baseUrl: String, token: String, oldStty: String): String {
|
||||||
client: HttpClient,
|
|
||||||
baseUrl: String,
|
|
||||||
token: String,
|
|
||||||
oldStty: String
|
|
||||||
): String {
|
|
||||||
val scriptName = promptLine(oldStty, "Create script name: ")
|
val scriptName = promptLine(oldStty, "Create script name: ")
|
||||||
if (scriptName.isBlank()) return "[CANCEL] Empty 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 sourceMode = promptLine(oldStty, "Source mode [e=editor,f=file] (default e): ").lowercase().ifBlank { "e" }
|
||||||
@@ -325,19 +362,13 @@ fun runCreateFlow(
|
|||||||
text
|
text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (content.isBlank()) return "[CANCEL] Empty script content."
|
|
||||||
|
|
||||||
|
if (content.isBlank()) return "[CANCEL] Empty script content."
|
||||||
val (status, body) = request(client, baseUrl, token, "POST", "/scripts/${encode(scriptName)}", content)
|
val (status, body) = request(client, baseUrl, token, "POST", "/scripts/${encode(scriptName)}", content)
|
||||||
return "[CREATE $scriptName] HTTP $status\n$body"
|
return "[CREATE $scriptName] HTTP $status\n$body"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun runEditFlow(
|
fun runEditFlow(client: HttpClient, baseUrl: String, token: String, scriptName: String, oldStty: String): String {
|
||||||
client: HttpClient,
|
|
||||||
baseUrl: String,
|
|
||||||
token: String,
|
|
||||||
scriptName: String,
|
|
||||||
oldStty: String
|
|
||||||
): String {
|
|
||||||
val (statusGet, bodyGet) = request(client, baseUrl, token, "GET", "/scripts/${encode(scriptName)}")
|
val (statusGet, bodyGet) = request(client, baseUrl, token, "GET", "/scripts/${encode(scriptName)}")
|
||||||
if (statusGet >= 400) return "[EDIT $scriptName] HTTP $statusGet\n$bodyGet"
|
if (statusGet >= 400) return "[EDIT $scriptName] HTTP $statusGet\n$bodyGet"
|
||||||
|
|
||||||
@@ -356,26 +387,87 @@ fun runEditFlow(
|
|||||||
return "[EDIT $scriptName] HTTP $statusPut\n$bodyPut"
|
return "[EDIT $scriptName] HTTP $statusPut\n$bodyPut"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun runDeleteFlow(
|
fun runDeleteFlow(client: HttpClient, baseUrl: String, token: String, scriptName: String, oldStty: String): String {
|
||||||
client: HttpClient,
|
|
||||||
baseUrl: String,
|
|
||||||
token: String,
|
|
||||||
scriptName: String,
|
|
||||||
oldStty: String
|
|
||||||
): String {
|
|
||||||
val confirm = promptLine(oldStty, "Delete '$scriptName'? [y/N]: ").lowercase()
|
val confirm = promptLine(oldStty, "Delete '$scriptName'? [y/N]: ").lowercase()
|
||||||
if (confirm != "y" && confirm != "yes") return "[CANCEL] Delete aborted."
|
if (confirm != "y" && confirm != "yes") return "[CANCEL] Delete aborted."
|
||||||
val (status, body) = request(client, baseUrl, token, "DELETE", "/scripts/${encode(scriptName)}")
|
val (status, body) = request(client, baseUrl, token, "DELETE", "/scripts/${encode(scriptName)}")
|
||||||
return "[DELETE $scriptName] HTTP $status\n$body"
|
return "[DELETE $scriptName] HTTP $status\n$body"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun normalizeScriptsInput(raw: String): String {
|
||||||
|
val scripts = raw.split(Regex("[,\\s]+")).map { it.trim() }.filter { it.isNotBlank() }
|
||||||
|
if (scripts.any { !Regex("[A-Za-z0-9._-]+$").matches(it) }) {
|
||||||
|
error("Invalid script names, only [A-Za-z0-9._-] allowed")
|
||||||
|
}
|
||||||
|
return scripts.joinToString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun runSubTokenListFlow(client: HttpClient, baseUrl: String, token: String): String {
|
||||||
|
val (status, body) = request(client, baseUrl, token, "GET", "/subtokens")
|
||||||
|
return "[SUB-LIST] HTTP $status\n$body"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun runSubTokenShowFlow(client: HttpClient, baseUrl: String, token: String, selectedName: String?, oldStty: String): String {
|
||||||
|
val name = selectedName ?: promptLine(oldStty, "Subtoken name: ")
|
||||||
|
if (name.isBlank()) return "[CANCEL] Empty subtoken name."
|
||||||
|
val (status, body) = request(client, baseUrl, token, "GET", "/subtokens/${encode(name)}")
|
||||||
|
return "[SUB-SHOW $name] HTTP $status\n$body"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun runSubTokenCreateFlow(client: HttpClient, baseUrl: String, token: String, oldStty: String): String {
|
||||||
|
val name = promptLine(oldStty, "Subtoken name: ")
|
||||||
|
if (name.isBlank()) return "[CANCEL] Empty subtoken name."
|
||||||
|
val scriptsRaw = promptLine(oldStty, "Allowed scripts (comma/space separated): ")
|
||||||
|
val body = normalizeScriptsInput(scriptsRaw)
|
||||||
|
val (status, content) = request(client, baseUrl, token, "POST", "/subtokens/${encode(name)}", body)
|
||||||
|
return "[SUB-CREATE $name] HTTP $status\n$content"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun runSubTokenUpdateFlow(client: HttpClient, baseUrl: String, token: String, selectedName: String?, oldStty: String): String {
|
||||||
|
val name = selectedName ?: promptLine(oldStty, "Subtoken name to update: ")
|
||||||
|
if (name.isBlank()) return "[CANCEL] Empty subtoken name."
|
||||||
|
val scriptsRaw = promptLine(oldStty, "Allowed scripts (comma/space separated): ")
|
||||||
|
val body = normalizeScriptsInput(scriptsRaw)
|
||||||
|
val (status, content) = request(client, baseUrl, token, "PUT", "/subtokens/${encode(name)}", body)
|
||||||
|
return "[SUB-UPDATE $name] HTTP $status\n$content"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun runSubTokenDeleteFlow(client: HttpClient, baseUrl: String, token: String, selectedName: String?, oldStty: String): String {
|
||||||
|
val name = selectedName ?: promptLine(oldStty, "Subtoken name to delete: ")
|
||||||
|
if (name.isBlank()) return "[CANCEL] Empty subtoken name."
|
||||||
|
val confirm = promptLine(oldStty, "Delete subtoken '$name'? [y/N]: ").lowercase()
|
||||||
|
if (confirm != "y" && confirm != "yes") return "[CANCEL] Delete aborted."
|
||||||
|
val (status, body) = request(client, baseUrl, token, "DELETE", "/subtokens/${encode(name)}")
|
||||||
|
return "[SUB-DELETE $name] HTTP $status\n$body"
|
||||||
|
}
|
||||||
|
|
||||||
|
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 runScriptFlow(
|
fun runScriptFlow(
|
||||||
client: HttpClient,
|
client: HttpClient,
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
token: String,
|
token: String,
|
||||||
scriptName: String,
|
scriptName: String,
|
||||||
oldStty: String,
|
oldStty: String,
|
||||||
initialProfile: RunProfile
|
initialProfile: RunProfile,
|
||||||
): Pair<String, RunProfile?> {
|
): Pair<String, RunProfile?> {
|
||||||
var profile = initialProfile
|
var profile = initialProfile
|
||||||
var selected = 0
|
var selected = 0
|
||||||
@@ -386,11 +478,9 @@ fun runScriptFlow(
|
|||||||
when (readKey()) {
|
when (readKey()) {
|
||||||
Key.UP -> selected = if (selected == 0) 4 else selected - 1
|
Key.UP -> selected = if (selected == 0) 4 else selected - 1
|
||||||
Key.DOWN -> selected = if (selected == 4) 0 else selected + 1
|
Key.DOWN -> selected = if (selected == 4) 0 else selected + 1
|
||||||
Key.LEFT, Key.RIGHT -> {
|
Key.LEFT, Key.RIGHT -> if (selected == 0) {
|
||||||
if (selected == 0) {
|
profile = profile.copy(method = if (profile.method == "GET") "POST" else "GET")
|
||||||
profile = profile.copy(method = if (profile.method == "GET") "POST" else "GET")
|
hint = "Method set to ${profile.method}"
|
||||||
hint = "Method set to ${profile.method}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Key.Q -> return "[CANCEL] Run aborted." to null
|
Key.Q -> return "[CANCEL] Run aborted." to null
|
||||||
Key.ENTER -> {
|
Key.ENTER -> {
|
||||||
@@ -427,100 +517,226 @@ fun runScriptFlow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun targetOptions(tokenInfo: TokenInfo): List<String> =
|
||||||
|
if (tokenInfo.tokenType == "sub") listOf("Scripts") else listOf("Scripts", "Subtokens")
|
||||||
|
|
||||||
|
fun actionOptions(tokenInfo: TokenInfo, target: String): List<String> =
|
||||||
|
when (target) {
|
||||||
|
"Subtokens" -> listOf("Refresh", "List", "Show", "Create", "Update", "Delete")
|
||||||
|
else -> {
|
||||||
|
if (tokenInfo.tokenType == "sub") {
|
||||||
|
listOf("Refresh", "Run", "Meta")
|
||||||
|
} else {
|
||||||
|
listOf("Refresh", "Show", "Run", "Meta", "Create", "Edit", "Delete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun main(args: Array<String>) {
|
fun main(args: Array<String>) {
|
||||||
val options = parseOptions(args.toList())
|
val options = parseOptions(args.toList())
|
||||||
val token = readToken(options)
|
val token = readToken(options)
|
||||||
val client = HttpClient.newHttpClient()
|
val client = HttpClient.newHttpClient()
|
||||||
val actions = listOf("Refresh", "Show", "Run", "Meta", "Create", "Edit", "Delete", "Quit")
|
val (tokenInfo, tokenInfoText) =
|
||||||
|
runCatching { fetchTokenInfo(client, options.baseUrl, token) }
|
||||||
|
.getOrElse { t ->
|
||||||
|
val reason = t.message?.ifBlank { null } ?: t::class.simpleName ?: "unknown"
|
||||||
|
System.err.println("[WARN] Backend unavailable at ${options.baseUrl}, TUI exited. reason=$reason")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var scripts = emptyList<String>()
|
||||||
|
var subtokens = emptyList<String>()
|
||||||
|
var output = tokenInfoText
|
||||||
|
|
||||||
|
var focus = FocusRow.ACTION
|
||||||
|
var targetIdx = 0
|
||||||
|
var actionIdx = 0
|
||||||
|
var systemIdx = 0
|
||||||
|
var listIdx = 0
|
||||||
|
|
||||||
val runProfiles = mutableMapOf<String, RunProfile>()
|
val runProfiles = mutableMapOf<String, RunProfile>()
|
||||||
var selectedAction = 0
|
val systemOptions = listOf("Type", "Quit")
|
||||||
var selectedScript = 0
|
|
||||||
var output = ""
|
|
||||||
|
|
||||||
val oldStty = shell("stty -g < /dev/tty")
|
val oldStty = shell("stty -g < /dev/tty")
|
||||||
shell("stty -echo -icanon min 1 time 0 < /dev/tty")
|
shell("stty -echo -icanon min 1 time 0 < /dev/tty")
|
||||||
print("\u001b[?25l")
|
print("\u001b[?25l")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var (scripts, initMessage) = fetchScripts(client, options.baseUrl, token)
|
val initScripts = fetchScripts(client, options.baseUrl, token)
|
||||||
output = initMessage
|
scripts = initScripts.first
|
||||||
if (selectedScript >= scripts.size) selectedScript = max(0, scripts.size - 1)
|
output += "\n" + initScripts.second
|
||||||
|
if (tokenInfo.tokenType == "root") {
|
||||||
|
val initSubs = fetchSubtokens(client, options.baseUrl, token)
|
||||||
|
subtokens = initSubs.first
|
||||||
|
output += "\n" + initSubs.second
|
||||||
|
}
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
draw(options.baseUrl, scripts, selectedScript, actions, selectedAction, output)
|
val targets = targetOptions(tokenInfo)
|
||||||
|
if (targetIdx > targets.lastIndex) targetIdx = 0
|
||||||
|
val currentTarget = targets[targetIdx]
|
||||||
|
val actions = actionOptions(tokenInfo, currentTarget)
|
||||||
|
if (actionIdx > actions.lastIndex) actionIdx = 0
|
||||||
|
|
||||||
|
val listTitle = currentTarget
|
||||||
|
val listItems = if (currentTarget == "Subtokens") subtokens else scripts
|
||||||
|
if (listIdx > listItems.lastIndex) listIdx = max(0, listItems.lastIndex)
|
||||||
|
|
||||||
|
draw(
|
||||||
|
baseUrl = options.baseUrl,
|
||||||
|
targetOptions = targets,
|
||||||
|
targetIdx = targetIdx,
|
||||||
|
actionOptions = actions,
|
||||||
|
actionIdx = actionIdx,
|
||||||
|
systemOptions = systemOptions,
|
||||||
|
systemIdx = systemIdx,
|
||||||
|
listTitle = listTitle,
|
||||||
|
listItems = listItems,
|
||||||
|
listIdx = listIdx,
|
||||||
|
focus = focus,
|
||||||
|
output = output,
|
||||||
|
)
|
||||||
|
|
||||||
when (readKey()) {
|
when (readKey()) {
|
||||||
Key.UP -> if (scripts.isNotEmpty()) selectedScript = max(0, selectedScript - 1)
|
Key.UP -> {
|
||||||
Key.DOWN -> if (scripts.isNotEmpty()) selectedScript = minOf(scripts.lastIndex, selectedScript + 1)
|
focus = when (focus) {
|
||||||
Key.LEFT -> selectedAction = if (selectedAction == 0) actions.lastIndex else selectedAction - 1
|
FocusRow.TARGET -> FocusRow.LIST
|
||||||
Key.RIGHT -> selectedAction = if (selectedAction == actions.lastIndex) 0 else selectedAction + 1
|
FocusRow.ACTION -> FocusRow.TARGET
|
||||||
|
FocusRow.SYSTEM -> FocusRow.ACTION
|
||||||
|
FocusRow.LIST -> FocusRow.SYSTEM
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Key.DOWN -> {
|
||||||
|
focus = when (focus) {
|
||||||
|
FocusRow.TARGET -> FocusRow.ACTION
|
||||||
|
FocusRow.ACTION -> FocusRow.SYSTEM
|
||||||
|
FocusRow.SYSTEM -> FocusRow.LIST
|
||||||
|
FocusRow.LIST -> FocusRow.TARGET
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Key.LEFT -> {
|
||||||
|
when (focus) {
|
||||||
|
FocusRow.TARGET -> {
|
||||||
|
targetIdx = if (targetIdx == 0) targets.lastIndex else targetIdx - 1
|
||||||
|
actionIdx = 0
|
||||||
|
listIdx = 0
|
||||||
|
}
|
||||||
|
FocusRow.ACTION -> actionIdx = if (actionIdx == 0) actions.lastIndex else actionIdx - 1
|
||||||
|
FocusRow.LIST -> if (listItems.isNotEmpty()) listIdx = if (listIdx == 0) listItems.lastIndex else listIdx - 1
|
||||||
|
FocusRow.SYSTEM -> systemIdx = if (systemIdx == 0) systemOptions.lastIndex else systemIdx - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Key.RIGHT -> {
|
||||||
|
when (focus) {
|
||||||
|
FocusRow.TARGET -> {
|
||||||
|
targetIdx = if (targetIdx == targets.lastIndex) 0 else targetIdx + 1
|
||||||
|
actionIdx = 0
|
||||||
|
listIdx = 0
|
||||||
|
}
|
||||||
|
FocusRow.ACTION -> actionIdx = if (actionIdx == actions.lastIndex) 0 else actionIdx + 1
|
||||||
|
FocusRow.LIST -> if (listItems.isNotEmpty()) listIdx = if (listIdx == listItems.lastIndex) 0 else listIdx + 1
|
||||||
|
FocusRow.SYSTEM -> systemIdx = if (systemIdx == systemOptions.lastIndex) 0 else systemIdx + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
Key.Q -> break
|
Key.Q -> break
|
||||||
Key.ENTER -> {
|
Key.ENTER -> {
|
||||||
when (actions[selectedAction]) {
|
if (focus == FocusRow.SYSTEM) {
|
||||||
"Refresh" -> {
|
when (systemOptions[systemIdx]) {
|
||||||
val result = fetchScripts(client, options.baseUrl, token)
|
"Type" -> {
|
||||||
scripts = result.first
|
output = runCatching {
|
||||||
if (selectedScript >= scripts.size) selectedScript = max(0, scripts.size - 1)
|
val (info, text) = fetchTokenInfo(client, options.baseUrl, token)
|
||||||
output = result.second
|
val suffix = if (info.tokenType == "sub") "\nsubToken=${info.subTokenName ?: "-"}" else ""
|
||||||
|
"$text$suffix"
|
||||||
|
}.getOrElse { "[ERROR] ${it::class.simpleName}: ${it.message}" }
|
||||||
|
}
|
||||||
|
"Quit" -> break
|
||||||
}
|
}
|
||||||
"Show" -> {
|
} else {
|
||||||
output = if (scripts.isEmpty()) {
|
val selectedItem = listItems.getOrNull(listIdx)
|
||||||
"No scripts."
|
output = runCatching {
|
||||||
} else runCatching {
|
when (currentTarget) {
|
||||||
val script = scripts[selectedScript]
|
"Subtokens" -> {
|
||||||
val (status, body) = request(client, options.baseUrl, token, "GET", "/scripts/${encode(script)}")
|
when (actions[actionIdx]) {
|
||||||
"[SHOW $script] HTTP $status\n$body"
|
"Refresh", "List" -> {
|
||||||
}.getOrElse { "[ERROR] ${it::class.simpleName}: ${it.message}" }
|
val res = fetchSubtokens(client, options.baseUrl, token)
|
||||||
}
|
subtokens = res.first
|
||||||
"Run" -> {
|
listIdx = if (subtokens.isEmpty()) 0 else minOf(listIdx, subtokens.lastIndex)
|
||||||
output = if (scripts.isEmpty()) {
|
"[SUB-LIST]\n${res.second}"
|
||||||
"No scripts."
|
}
|
||||||
} else runCatching {
|
"Show" -> runSubTokenShowFlow(client, options.baseUrl, token, selectedItem, oldStty)
|
||||||
val script = scripts[selectedScript]
|
"Create" -> {
|
||||||
val initial = runProfiles[script] ?: RunProfile()
|
val text = runSubTokenCreateFlow(client, options.baseUrl, token, oldStty)
|
||||||
val (text, updated) = runScriptFlow(client, options.baseUrl, token, script, oldStty, initial)
|
val res = fetchSubtokens(client, options.baseUrl, token)
|
||||||
if (updated != null) runProfiles[script] = updated
|
subtokens = res.first
|
||||||
text
|
text + "\n" + res.second
|
||||||
}.getOrElse { "[ERROR] ${it::class.simpleName}: ${it.message}" }
|
}
|
||||||
}
|
"Update" -> {
|
||||||
"Meta" -> {
|
val text = runSubTokenUpdateFlow(client, options.baseUrl, token, selectedItem, oldStty)
|
||||||
output = if (scripts.isEmpty()) {
|
val res = fetchSubtokens(client, options.baseUrl, token)
|
||||||
"No scripts."
|
subtokens = res.first
|
||||||
} else runCatching {
|
text + "\n" + res.second
|
||||||
val script = scripts[selectedScript]
|
}
|
||||||
val (status, body) = request(client, options.baseUrl, token, "GET", "/meta/${encode(script)}")
|
"Delete" -> {
|
||||||
"[META $script] HTTP $status\n$body"
|
val text = runSubTokenDeleteFlow(client, options.baseUrl, token, selectedItem, oldStty)
|
||||||
}.getOrElse { "[ERROR] ${it::class.simpleName}: ${it.message}" }
|
val res = fetchSubtokens(client, options.baseUrl, token)
|
||||||
}
|
subtokens = res.first
|
||||||
"Create" -> {
|
listIdx = if (subtokens.isEmpty()) 0 else minOf(listIdx, subtokens.lastIndex)
|
||||||
output = runCatching { runCreateFlow(client, options.baseUrl, token, oldStty) }
|
text + "\n" + res.second
|
||||||
.getOrElse { "[ERROR] ${it::class.simpleName}: ${it.message}" }
|
}
|
||||||
val refreshed = fetchScripts(client, options.baseUrl, token)
|
else -> "[ERROR] Unsupported subtoken action"
|
||||||
scripts = refreshed.first
|
}
|
||||||
if (selectedScript >= scripts.size) selectedScript = max(0, scripts.size - 1)
|
}
|
||||||
}
|
else -> {
|
||||||
"Edit" -> {
|
when (actions[actionIdx]) {
|
||||||
output = if (scripts.isEmpty()) {
|
"Refresh" -> {
|
||||||
"No scripts."
|
val res = fetchScripts(client, options.baseUrl, token)
|
||||||
} else runCatching {
|
scripts = res.first
|
||||||
val script = scripts[selectedScript]
|
listIdx = if (scripts.isEmpty()) 0 else minOf(listIdx, scripts.lastIndex)
|
||||||
runEditFlow(client, options.baseUrl, token, script, oldStty)
|
res.second
|
||||||
}.getOrElse { "[ERROR] ${it::class.simpleName}: ${it.message}" }
|
}
|
||||||
val refreshed = fetchScripts(client, options.baseUrl, token)
|
"Show" -> {
|
||||||
scripts = refreshed.first
|
val script = selectedItem ?: return@runCatching "No scripts."
|
||||||
if (selectedScript >= scripts.size) selectedScript = max(0, scripts.size - 1)
|
val (status, body) = request(client, options.baseUrl, token, "GET", "/scripts/${encode(script)}")
|
||||||
}
|
"[SHOW $script] HTTP $status\n$body"
|
||||||
"Delete" -> {
|
}
|
||||||
output = if (scripts.isEmpty()) {
|
"Run" -> {
|
||||||
"No scripts."
|
val script = selectedItem ?: return@runCatching "No scripts."
|
||||||
} else runCatching {
|
val initial = runProfiles[script] ?: RunProfile()
|
||||||
val script = scripts[selectedScript]
|
val (text, updated) = runScriptFlow(client, options.baseUrl, token, script, oldStty, initial)
|
||||||
runDeleteFlow(client, options.baseUrl, token, script, oldStty)
|
if (updated != null) runProfiles[script] = updated
|
||||||
}.getOrElse { "[ERROR] ${it::class.simpleName}: ${it.message}" }
|
text
|
||||||
val refreshed = fetchScripts(client, options.baseUrl, token)
|
}
|
||||||
scripts = refreshed.first
|
"Meta" -> {
|
||||||
if (selectedScript >= scripts.size) selectedScript = max(0, scripts.size - 1)
|
val script = selectedItem ?: return@runCatching "No scripts."
|
||||||
}
|
val (status, body) = request(client, options.baseUrl, token, "GET", "/meta/${encode(script)}")
|
||||||
"Quit" -> break
|
"[META $script] HTTP $status\n$body"
|
||||||
|
}
|
||||||
|
"Create" -> {
|
||||||
|
val text = runCreateFlow(client, options.baseUrl, token, oldStty)
|
||||||
|
val res = fetchScripts(client, options.baseUrl, token)
|
||||||
|
scripts = res.first
|
||||||
|
text + "\n" + res.second
|
||||||
|
}
|
||||||
|
"Edit" -> {
|
||||||
|
val script = selectedItem ?: return@runCatching "No scripts."
|
||||||
|
val text = runEditFlow(client, options.baseUrl, token, script, oldStty)
|
||||||
|
val res = fetchScripts(client, options.baseUrl, token)
|
||||||
|
scripts = res.first
|
||||||
|
text + "\n" + res.second
|
||||||
|
}
|
||||||
|
"Delete" -> {
|
||||||
|
val script = selectedItem ?: return@runCatching "No scripts."
|
||||||
|
val text = runDeleteFlow(client, options.baseUrl, token, script, oldStty)
|
||||||
|
val res = fetchScripts(client, options.baseUrl, token)
|
||||||
|
scripts = res.first
|
||||||
|
listIdx = if (scripts.isEmpty()) 0 else minOf(listIdx, scripts.lastIndex)
|
||||||
|
text + "\n" + res.second
|
||||||
|
}
|
||||||
|
else -> "[ERROR] Unsupported script action"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.getOrElse { "[ERROR] ${it::class.simpleName}: ${it.message}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {}
|
else -> {}
|
||||||
|
|||||||
Reference in New Issue
Block a user