diff --git a/clients/groovy/sync/src/un.groovy b/clients/groovy/sync/src/un.groovy index 6569f45..05c2667 100644 --- a/clients/groovy/sync/src/un.groovy +++ b/clients/groovy/sync/src/un.groovy @@ -222,42 +222,67 @@ def signRequest(String secretKey, long timestamp, String method, String path, St * @return Tuple of [publicKey, secretKey] * @throws AuthenticationError if no credentials found */ -def getCredentials(String publicKey = null, String secretKey = null, int accountIndex = 0) { +def loadAccountsFromCsv(File path) { + def validAccounts = [] + if (!path.exists()) return validAccounts + try { + def lines = path.text.trim().split('\n') + lines.each { line -> + def trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) return + if (trimmed.contains(',')) { + def parts = trimmed.split(',', 2) + def pk = parts[0].trim() + def sk = parts[1].trim() + if (pk.startsWith('unsb-pk-') && sk.startsWith('unsb-sk-')) { + validAccounts << [pk, sk] + } + } + } + } catch (Exception e) { + // Ignore file read errors + } + return validAccounts +} + +def getCredentials(String publicKey = null, String secretKey = null, int accountIndex = -1) { // Priority 1: Function arguments if (publicKey && secretKey) { return [publicKey, secretKey] } - // Priority 2: Environment variables + // Priority 2: --account N => accounts.csv row N (bypasses env vars) + if (accountIndex >= 0) { + def searchPaths = [ + new File(System.getProperty('user.home'), '.unsandbox/accounts.csv'), + new File('accounts.csv') + ] + for (path in searchPaths) { + def accts = loadAccountsFromCsv(path) + if (accts && accountIndex < accts.size()) { + return accts[accountIndex] + } + } + throw new AuthenticationError("No account at index ${accountIndex} in accounts.csv") + } + + // Priority 3: Environment variables def envPk = System.getenv('UNSANDBOX_PUBLIC_KEY') def envSk = System.getenv('UNSANDBOX_SECRET_KEY') if (envPk && envSk) { return [envPk, envSk] } - // Priority 3: Config file - def accountsPath = new File(System.getProperty('user.home'), '.unsandbox/accounts.csv') - if (accountsPath.exists()) { - try { - def lines = accountsPath.text.trim().split('\n') - def validAccounts = [] - lines.each { line -> - def trimmed = line.trim() - if (!trimmed || trimmed.startsWith('#')) return - if (trimmed.contains(',')) { - def parts = trimmed.split(',', 2) - def pk = parts[0] - def sk = parts[1] - if (pk.startsWith('unsb-pk-') && sk.startsWith('unsb-sk-')) { - validAccounts << [pk, sk] - } - } - } - if (validAccounts && accountIndex < validAccounts.size()) { - return validAccounts[accountIndex] - } - } catch (Exception e) { - // Ignore file read errors + // Priority 4: ~/.unsandbox/accounts.csv row 0 (or UNSANDBOX_ACCOUNT env) + def defaultIdx = (System.getenv('UNSANDBOX_ACCOUNT') ?: '0').toInteger() + def searchPaths = [ + new File(System.getProperty('user.home'), '.unsandbox/accounts.csv'), + new File('accounts.csv') + ] + for (path in searchPaths) { + def accts = loadAccountsFromCsv(path) + if (accts && defaultIdx < accts.size()) { + return accts[defaultIdx] } } @@ -267,21 +292,14 @@ def getCredentials(String publicKey = null, String secretKey = null, int account ) } -// Legacy compatibility -def getApiKeys(argsKey) { - def publicKey = System.getenv('UNSANDBOX_PUBLIC_KEY') - def secretKey = System.getenv('UNSANDBOX_SECRET_KEY') - - if (!publicKey || !secretKey) { - def legacyKey = argsKey ?: System.getenv('UNSANDBOX_API_KEY') - if (!legacyKey) { - System.err.println("${RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set${RESET}") - System.exit(1) - } - return [legacyKey, null] +// Legacy compatibility - now delegates to getCredentials for proper priority +def getApiKeys(argsKey, int accountIndex = -1) { + try { + return getCredentials(argsKey ?: null, null, accountIndex) + } catch (AuthenticationError e) { + System.err.println("${RED}Error: ${e.message}${RESET}") + System.exit(1) } - - return [publicKey, secretKey] } // ============================================================================ @@ -606,7 +624,7 @@ def execute(String language, String code, Map options = [:]) { def (publicKey, secretKey) = getCredentials( options.publicKey, options.secretKey, - options.accountIndex ?: 0 + options.accountIndex != null ? options.accountIndex : -1 ) def payload = [ @@ -656,7 +674,7 @@ def executeAsync(String language, String code, Map options = [:]) { def (publicKey, secretKey) = getCredentials( options.publicKey, options.secretKey, - options.accountIndex ?: 0 + options.accountIndex != null ? options.accountIndex : -1 ) def payload = [ @@ -703,7 +721,7 @@ def run(String code, Map options = [:]) { def (publicKey, secretKey) = getCredentials( options.publicKey, options.secretKey, - options.accountIndex ?: 0 + options.accountIndex != null ? options.accountIndex : -1 ) def ttl = options.ttl ?: DEFAULT_TTL @@ -728,7 +746,7 @@ def runAsync(String code, Map options = [:]) { def (publicKey, secretKey) = getCredentials( options.publicKey, options.secretKey, - options.accountIndex ?: 0 + options.accountIndex != null ? options.accountIndex : -1 ) def ttl = options.ttl ?: DEFAULT_TTL @@ -1589,45 +1607,72 @@ class Client { def creds = getCredentialsStatic( options.publicKey, options.secretKey, - options.accountIndex ?: 0 + options.accountIndex != null ? options.accountIndex : -1 ) this.publicKey = creds[0] this.secretKey = creds[1] } + private static loadCsvAccounts(File path) { + def accounts = [] + if (!path.exists()) return accounts + try { + path.text.trim().split('\n').each { line -> + def trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) return + if (trimmed.contains(',')) { + def parts = trimmed.split(',', 2) + def pk = parts[0].trim() + def sk = parts[1].trim() + if (pk.startsWith('unsb-pk-') && sk.startsWith('unsb-sk-')) { + accounts << [pk, sk] + } + } + } + } catch (Exception e) { + // Ignore + } + return accounts + } + private static getCredentialsStatic(String publicKey, String secretKey, int accountIndex) { + // Priority 1: explicit arguments if (publicKey && secretKey) { return [publicKey, secretKey] } + // Priority 2: --account N => CSV row N (bypasses env vars) + if (accountIndex >= 0) { + def searchPaths = [ + new File(System.getProperty('user.home'), '.unsandbox/accounts.csv'), + new File('accounts.csv') + ] + for (path in searchPaths) { + def accts = loadCsvAccounts(path) + if (accts && accountIndex < accts.size()) { + return accts[accountIndex] + } + } + throw new AuthenticationError("No account at index ${accountIndex} in accounts.csv") + } + + // Priority 3: Environment variables def envPk = System.getenv('UNSANDBOX_PUBLIC_KEY') def envSk = System.getenv('UNSANDBOX_SECRET_KEY') if (envPk && envSk) { return [envPk, envSk] } - def accountsPath = new File(System.getProperty('user.home'), '.unsandbox/accounts.csv') - if (accountsPath.exists()) { - try { - def lines = accountsPath.text.trim().split('\n') - def validAccounts = [] - lines.each { line -> - def trimmed = line.trim() - if (!trimmed || trimmed.startsWith('#')) return - if (trimmed.contains(',')) { - def parts = trimmed.split(',', 2) - def pk = parts[0] - def sk = parts[1] - if (pk.startsWith('unsb-pk-') && sk.startsWith('unsb-sk-')) { - validAccounts << [pk, sk] - } - } - } - if (validAccounts && accountIndex < validAccounts.size()) { - return validAccounts[accountIndex] - } - } catch (Exception e) { - // Ignore + // Priority 4: ~/.unsandbox/accounts.csv row 0 (or UNSANDBOX_ACCOUNT env) + def defaultIdx = (System.getenv('UNSANDBOX_ACCOUNT') ?: '0').toInteger() + def searchPaths = [ + new File(System.getProperty('user.home'), '.unsandbox/accounts.csv'), + new File('accounts.csv') + ] + for (path in searchPaths) { + def accts = loadCsvAccounts(path) + if (accts && defaultIdx < accts.size()) { + return accts[defaultIdx] } } @@ -1738,6 +1783,7 @@ class Args { String sourceFile = null String inlineLang = null String apiKey = null + Integer accountIndex = -1 String network = null Integer vcpu = 0 List env = [] @@ -1839,7 +1885,7 @@ def serviceEnvSet(serviceId, content, publicKey, secretKey) { } def cmdServiceEnv(args) { - def (publicKey, secretKey) = getApiKeys(args.apiKey) + def (publicKey, secretKey) = getApiKeys(args.apiKey, args.accountIndex ?: -1) switch (args.envAction) { case 'status': @@ -1875,7 +1921,7 @@ def cmdServiceEnv(args) { } def cmdExecute(args) { - def (publicKey, secretKey) = getApiKeys(args.apiKey) + def (publicKey, secretKey) = getApiKeys(args.apiKey, args.accountIndex ?: -1) String code String language @@ -1956,7 +2002,7 @@ def cmdExecute(args) { } def cmdSession(args) { - def (publicKey, secretKey) = getApiKeys(args.apiKey) + def (publicKey, secretKey) = getApiKeys(args.apiKey, args.accountIndex ?: -1) if (args.sessionSnapshot) { def payload = [:] @@ -2033,7 +2079,7 @@ def openBrowser(url) { } def cmdSnapshot(args) { - def (publicKey, secretKey) = getApiKeys(args.apiKey) + def (publicKey, secretKey) = getApiKeys(args.apiKey, args.accountIndex ?: -1) if (args.snapshotList) { def output = apiRequest('/snapshots', 'GET', null, publicKey, secretKey) @@ -2073,7 +2119,7 @@ def cmdSnapshot(args) { } def cmdImage(args) { - def (publicKey, secretKey) = getApiKeys(args.apiKey) + def (publicKey, secretKey) = getApiKeys(args.apiKey, args.accountIndex ?: -1) if (args.imageList) { def output = apiRequest('/images', 'GET', null, publicKey, secretKey) @@ -2153,7 +2199,7 @@ def cmdImage(args) { } def cmdLanguages(args) { - def (publicKey, secretKey) = getApiKeys(args.apiKey) + def (publicKey, secretKey) = getApiKeys(args.apiKey, args.accountIndex ?: -1) def result = languages([publicKey: publicKey, secretKey: secretKey, forceRefresh: true]) def langList = result.languages ?: [] @@ -2168,7 +2214,7 @@ def cmdLanguages(args) { } def cmdKey(args) { - def (publicKey, secretKey) = getApiKeys(args.apiKey) + def (publicKey, secretKey) = getApiKeys(args.apiKey, args.accountIndex ?: -1) def curlCmd = ['curl', '-s', '-X', 'POST', "${PORTAL_BASE}/keys/validate", '-H', 'Content-Type: application/json'] @@ -2240,7 +2286,7 @@ def cmdKey(args) { } def cmdService(args) { - def (publicKey, secretKey) = getApiKeys(args.apiKey) + def (publicKey, secretKey) = getApiKeys(args.apiKey, args.accountIndex ?: -1) if (args.serviceSnapshot) { def payload = [:] @@ -2457,6 +2503,9 @@ def parseArgs(argv) { case '--public-key': args.apiKey = argv[++i] // For compatibility break + case '--account': + args.accountIndex = argv[++i].toInteger() + break case '-n': case '--network': args.network = argv[++i] diff --git a/clients/julia/sync/src/un.jl b/clients/julia/sync/src/un.jl index b5b6236..81e91b7 100755 --- a/clients/julia/sync/src/un.jl +++ b/clients/julia/sync/src/un.jl @@ -78,28 +78,77 @@ function detect_language(filename::String)::String return get(EXT_MAP, ext, "unknown") end -function get_api_keys(args_key=nothing)::Tuple{String,String} - # Try new-style keys first - public_key = something(args_key, get(ENV, "UNSANDBOX_PUBLIC_KEY", "")) - secret_key = get(ENV, "UNSANDBOX_SECRET_KEY", "") - - # Fall back to old-style single key for backwards compatibility - if isempty(public_key) - old_key = get(ENV, "UNSANDBOX_API_KEY", "") - if isempty(old_key) - println(stderr, "$(RED)Error: UNSANDBOX_PUBLIC_KEY/UNSANDBOX_SECRET_KEY or UNSANDBOX_API_KEY not set$(RESET)") - exit(1) +function load_accounts_csv(path::String)::Vector{Tuple{String,String}} + accounts = Tuple{String,String}[] + isfile(path) || return accounts + try + for line in eachline(path) + trimmed = strip(line) + isempty(trimmed) && continue + startswith(trimmed, "#") && continue + parts = split(trimmed, ","; limit=2) + length(parts) >= 2 || continue + pk = strip(parts[1]) + sk = strip(parts[2]) + if startswith(pk, "unsb-pk-") && startswith(sk, "unsb-sk-") + push!(accounts, (pk, sk)) + end end - # Old-style: use same key for both public and secret - return (old_key, old_key) + catch end + return accounts +end - if isempty(secret_key) - println(stderr, "$(RED)Error: UNSANDBOX_SECRET_KEY not set$(RESET)") +function get_credentials(; account_index::Int=-1)::Tuple{String,String} + # Priority 2: --account N => accounts.csv row N (bypasses env vars) + if account_index >= 0 + for path in [joinpath(homedir(), ".unsandbox", "accounts.csv"), "accounts.csv"] + accts = load_accounts_csv(path) + if account_index < length(accts) + return accts[account_index + 1] + end + end + println(stderr, "$(RED)Error: No account at index $account_index in accounts.csv$(RESET)") exit(1) end - return (public_key, secret_key) + # Priority 3: Environment variables + public_key = get(ENV, "UNSANDBOX_PUBLIC_KEY", "") + secret_key = get(ENV, "UNSANDBOX_SECRET_KEY", "") + if !isempty(public_key) && !isempty(secret_key) + return (public_key, secret_key) + end + + # Priority 4: ~/.unsandbox/accounts.csv row 0 (or UNSANDBOX_ACCOUNT env) + default_idx = tryparse(Int, get(ENV, "UNSANDBOX_ACCOUNT", "0")) + default_idx = something(default_idx, 0) + for path in [joinpath(homedir(), ".unsandbox", "accounts.csv"), "accounts.csv"] + accts = load_accounts_csv(path) + if default_idx < length(accts) + return accts[default_idx + 1] + end + end + + # Legacy fallback + old_key = get(ENV, "UNSANDBOX_API_KEY", "") + if !isempty(old_key) + return (old_key, old_key) + end + + println(stderr, "$(RED)Error: UNSANDBOX_PUBLIC_KEY/UNSANDBOX_SECRET_KEY or UNSANDBOX_API_KEY not set$(RESET)") + exit(1) +end + +function get_api_keys(args_key=nothing; account_index::Int=-1)::Tuple{String,String} + # Priority 1: explicit -k flag + if args_key !== nothing && !isempty(string(args_key)) + public_key = string(args_key) + secret_key = get(ENV, "UNSANDBOX_SECRET_KEY", "") + if !isempty(secret_key) + return (public_key, secret_key) + end + end + return get_credentials(account_index=account_index) end function hmac_sha256_hex(key::String, message::String)::String @@ -356,7 +405,7 @@ function service_env_delete(service_id::String, public_key::String, secret_key:: end function cmd_service_env(args) - (public_key, secret_key) = get_api_keys(args["api-key"]) + (public_key, secret_key) = get_api_keys(args["api-key"]; account_index=something(get(args, "account", nothing), -1)) action = get(args, "env-action", nothing) target = get(args, "env-target", nothing) @@ -428,7 +477,7 @@ function cmd_service_env(args) end function cmd_execute(args) - (public_key, secret_key) = get_api_keys(args["api-key"]) + (public_key, secret_key) = get_api_keys(args["api-key"]; account_index=something(get(args, "account", nothing), -1)) filename = args["source_file"] if !isfile(filename) @@ -518,7 +567,7 @@ function cmd_execute(args) end function cmd_session(args) - (public_key, secret_key) = get_api_keys(args["api-key"]) + (public_key, secret_key) = get_api_keys(args["api-key"]; account_index=something(get(args, "account", nothing), -1)) if args["list"] result = api_request("/sessions", public_key, secret_key) @@ -577,7 +626,7 @@ function cmd_session(args) end function cmd_service(args) - (public_key, secret_key) = get_api_keys(args["api-key"]) + (public_key, secret_key) = get_api_keys(args["api-key"]; account_index=something(get(args, "account", nothing), -1)) # Handle env subcommand if get(args, "env-action", nothing) !== nothing @@ -914,7 +963,7 @@ function cmd_languages(args) if langs === nothing # Cache miss or expired, fetch from API - (public_key, secret_key) = get_api_keys(args["api-key"]) + (public_key, secret_key) = get_api_keys(args["api-key"]; account_index=something(get(args, "account", nothing), -1)) result = api_request("/languages", public_key, secret_key) langs = get(result, "languages", []) save_languages_cache(langs) @@ -930,7 +979,7 @@ function cmd_languages(args) end function cmd_key(args) - (public_key, secret_key) = get_api_keys(args["api-key"]) + (public_key, secret_key) = get_api_keys(args["api-key"]; account_index=something(get(args, "account", nothing), -1)) # For portal validation, we still use public_key as bearer token api_key = public_key @@ -996,6 +1045,9 @@ function main() required = false "--api-key", "-k" help = "API key (or set UNSANDBOX_API_KEY)" + "--account" + help = "Account index in ~/.unsandbox/accounts.csv (bypasses env vars)" + arg_type = Int "--network", "-n" help = "Network mode" arg_type = String @@ -1057,6 +1109,9 @@ function main() help = "Comma-separated ports for cloned service" "--api-key", "-k" help = "API key" + "--account" + help = "Account index in ~/.unsandbox/accounts.csv (bypasses env vars)" + arg_type = Int end @add_arg_table! s["session"] begin @@ -1074,6 +1129,9 @@ function main() range_tester = x -> x in ["zerotrust", "semitrusted"] "--api-key", "-k" help = "API key" + "--account" + help = "Account index in ~/.unsandbox/accounts.csv (bypasses env vars)" + arg_type = Int end @add_arg_table! s["service"] begin @@ -1135,6 +1193,9 @@ function main() help = "Service ID for env commands" "--api-key", "-k" help = "API key" + "--account" + help = "Account index in ~/.unsandbox/accounts.csv (bypasses env vars)" + arg_type = Int "env" help = "Manage service environment vault" action = :command @@ -1155,6 +1216,9 @@ function main() help = "Load vault variables from file" "--api-key", "-k" help = "API key" + "--account" + help = "Account index in ~/.unsandbox/accounts.csv (bypasses env vars)" + arg_type = Int end @add_arg_table! s["key"] begin @@ -1163,6 +1227,9 @@ function main() action = :store_true "--api-key", "-k" help = "API key" + "--account" + help = "Account index in ~/.unsandbox/accounts.csv (bypasses env vars)" + arg_type = Int end @add_arg_table! s["languages"] begin @@ -1171,6 +1238,9 @@ function main() action = :store_true "--api-key", "-k" help = "API key" + "--account" + help = "Account index in ~/.unsandbox/accounts.csv (bypasses env vars)" + arg_type = Int end @add_arg_table! s["image"] begin @@ -1203,6 +1273,9 @@ function main() help = "Comma-separated ports for spawned service" "--api-key", "-k" help = "API key" + "--account" + help = "Account index in ~/.unsandbox/accounts.csv (bypasses env vars)" + arg_type = Int end args = parse_args(ARGS, s) @@ -1220,6 +1293,7 @@ function main() service_args["vault-env"] = get(env_args, "vault-env", nothing) service_args["env-file"] = get(env_args, "env-file", nothing) service_args["api-key"] = get(env_args, "api-key", nothing) + service_args["account"] = get(env_args, "account", nothing) end cmd_service(service_args) elseif args["%COMMAND%"] == "languages" @@ -1239,7 +1313,7 @@ function main() end function cmd_image(args) - (public_key, secret_key) = get_api_keys(args["api-key"]) + (public_key, secret_key) = get_api_keys(args["api-key"]; account_index=something(get(args, "account", nothing), -1)) if args["list"] result = api_request("/images", public_key, secret_key) @@ -1330,7 +1404,7 @@ function cmd_image(args) end function cmd_snapshot(args) - (public_key, secret_key) = get_api_keys(args["api-key"]) + (public_key, secret_key) = get_api_keys(args["api-key"]; account_index=something(get(args, "account", nothing), -1)) if args["list"] result = api_request("/snapshots", public_key, secret_key) diff --git a/clients/r/sync/src/un.r b/clients/r/sync/src/un.r index 0170a24..f7098ad 100644 --- a/clients/r/sync/src/un.r +++ b/clients/r/sync/src/un.r @@ -121,29 +121,64 @@ MAX_ENV_CONTENT_SIZE <- 65536 #' creds <- get_credentials() #' creds <- get_credentials(public_key = "unsb-pk-xxxx", secret_key = "unsb-sk-xxxx") #' } -get_credentials <- function(public_key = NULL, secret_key = NULL) { +get_credentials <- function(public_key = NULL, secret_key = NULL, account_index = -1) { # Priority 1: Function arguments if (!is.null(public_key) && !is.null(secret_key)) { return(list(public_key = public_key, secret_key = secret_key)) } - # Priority 2: Environment variables + # Priority 2: --account N => accounts.csv row N (bypasses env vars) + load_account_from_csv <- function(idx) { + search_paths <- c( + file.path(Sys.getenv("HOME"), ".unsandbox", "accounts.csv"), + "accounts.csv" + ) + for (accounts_file in search_paths) { + if (file.exists(accounts_file)) { + lines <- readLines(accounts_file, warn = FALSE) + valid <- list() + for (line in lines) { + trimmed <- trimws(line) + if (nchar(trimmed) == 0 || startsWith(trimmed, "#")) next + parts <- strsplit(trimmed, ",")[[1]] + if (length(parts) >= 2 && + startsWith(parts[1], "unsb-pk-") && + startsWith(parts[2], "unsb-sk-")) { + valid <- c(valid, list(list(public_key = parts[1], secret_key = parts[2]))) + } + } + if (length(valid) > idx) { + return(valid[[idx + 1]]) + } + } + } + return(NULL) + } + + if (account_index >= 0) { + result <- load_account_from_csv(account_index) + if (!is.null(result)) { + return(result) + } + stop(sprintf("No account at index %d in accounts.csv", account_index)) + } + + # Priority 3: Environment variables env_public <- Sys.getenv("UNSANDBOX_PUBLIC_KEY") env_secret <- Sys.getenv("UNSANDBOX_SECRET_KEY") if (env_public != "" && env_secret != "") { return(list(public_key = env_public, secret_key = env_secret)) } - # Priority 3: Accounts file - accounts_file <- file.path(Sys.getenv("HOME"), ".unsandbox", "accounts.csv") - if (file.exists(accounts_file)) { - lines <- readLines(accounts_file, warn = FALSE) - for (line in lines) { - parts <- strsplit(trimws(line), ",")[[1]] - if (length(parts) >= 2) { - return(list(public_key = parts[1], secret_key = parts[2])) - } - } + # Priority 4: ~/.unsandbox/accounts.csv row 0 (or UNSANDBOX_ACCOUNT env) + default_idx <- 0 + env_account <- Sys.getenv("UNSANDBOX_ACCOUNT") + if (env_account != "") { + default_idx <- as.integer(env_account) + } + result <- load_account_from_csv(default_idx) + if (!is.null(result)) { + return(result) } # Fallback to legacy UNSANDBOX_API_KEY @@ -1665,7 +1700,7 @@ build_env_content <- function(envs, env_file) { } cmd_service_env <- function(args) { - keys <- get_api_keys(args$api_key) + keys <- get_credentials(account_index = if (!is.null(args$account_index)) args$account_index else -1) public_key <- keys$public_key secret_key <- keys$secret_key @@ -1743,7 +1778,7 @@ cmd_service_env <- function(args) { } cmd_execute <- function(args) { - keys <- get_api_keys(args$api_key) + keys <- get_credentials(account_index = if (!is.null(args$account_index)) args$account_index else -1) public_key <- keys$public_key secret_key <- keys$secret_key @@ -1837,7 +1872,7 @@ cmd_execute <- function(args) { } cmd_session <- function(args) { - keys <- get_api_keys(args$api_key) + keys <- get_credentials(account_index = if (!is.null(args$account_index)) args$account_index else -1) public_key <- keys$public_key secret_key <- keys$secret_key @@ -1995,7 +2030,7 @@ cmd_languages <- function(args) { if (is.null(langs)) { # Cache miss or expired, fetch from API - keys <- get_api_keys(args$api_key) + keys <- get_credentials(account_index = if (!is.null(args$account_index)) args$account_index else -1) public_key <- keys$public_key secret_key <- keys$secret_key @@ -2016,7 +2051,7 @@ cmd_languages <- function(args) { } cmd_key <- function(args) { - keys <- get_api_keys(args$api_key) + keys <- get_credentials(account_index = if (!is.null(args$account_index)) args$account_index else -1) public_key <- keys$public_key secret_key <- keys$secret_key @@ -2115,7 +2150,7 @@ cmd_key <- function(args) { } cmd_snapshot <- function(args) { - keys <- get_api_keys(args$api_key) + keys <- get_credentials(account_index = if (!is.null(args$account_index)) args$account_index else -1) public_key <- keys$public_key secret_key <- keys$secret_key @@ -2195,7 +2230,7 @@ cmd_snapshot <- function(args) { } cmd_image <- function(args) { - keys <- get_api_keys(args$api_key) + keys <- get_credentials(account_index = if (!is.null(args$account_index)) args$account_index else -1) public_key <- keys$public_key secret_key <- keys$secret_key @@ -2308,7 +2343,7 @@ cmd_service <- function(args) { return() } - keys <- get_api_keys(args$api_key) + keys <- get_credentials(account_index = if (!is.null(args$account_index)) args$account_index else -1) public_key <- keys$public_key secret_key <- keys$secret_key @@ -2532,6 +2567,7 @@ parse_args <- function() { result <- list( source_file = NULL, api_key = NULL, + account_index = -1L, network = NULL, env = NULL, files = NULL, @@ -2628,6 +2664,10 @@ parse_args <- function() { i <- i + 1 result$api_key <- args[i] i <- i + 1 + } else if (arg == "--account") { + i <- i + 1 + result$account_index <- as.integer(args[i]) + i <- i + 1 } else if (arg %in% c("-n", "--network")) { i <- i + 1 result$network <- args[i] diff --git a/clients/raku/sync/src/un.raku b/clients/raku/sync/src/un.raku index 8c081c0..358def4 100644 --- a/clients/raku/sync/src/un.raku +++ b/clients/raku/sync/src/un.raku @@ -143,38 +143,59 @@ sub sign-request(Str $secret-key, Int $timestamp, Str $method, Str $path, Str $b #| 1. Function arguments #| 2. Environment variables #| 3. ~/.unsandbox/accounts.csv -sub get-credentials(Str :$public-key, Str :$secret-key, Int :$account-index = 0) returns List is export { +sub get-credentials(Str :$public-key, Str :$secret-key, Int :$account-index = -1) returns List is export { # Priority 1: Function arguments if $public-key && $secret-key { return ($public-key, $secret-key); } - # Priority 2: Environment variables + # Helper: load valid accounts from a CSV path + sub load-accounts-from(IO::Path $path) { + my @valid; + if $path.e { + try { + my @lines = $path.slurp.trim.split("\n"); + for @lines -> $line { + my $trimmed = $line.trim; + next if !$trimmed || $trimmed.starts-with('#'); + if $trimmed.contains(',') { + my ($pk, $sk) = $trimmed.split(',', 2); + if $pk.starts-with('unsb-pk-') && $sk.starts-with('unsb-sk-') { + @valid.push(($pk, $sk)); + } + } + } + } + } + return @valid; + } + + # Priority 2: --account N => accounts.csv row N (bypasses env vars) + if $account-index >= 0 { + for ($*HOME.add('.unsandbox').add('accounts.csv'), + 'accounts.csv'.IO) -> $path { + my @accts = load-accounts-from($path); + if @accts && $account-index < @accts.elems { + return @accts[$account-index]; + } + } + die AuthenticationError.new("No account at index $account-index in accounts.csv"); + } + + # Priority 3: Environment variables my $env-pk = %*ENV // ''; my $env-sk = %*ENV // ''; if $env-pk && $env-sk { return ($env-pk, $env-sk); } - # Priority 3: Config file - my $accounts-path = $*HOME.add('.unsandbox').add('accounts.csv'); - if $accounts-path.e { - try { - my @lines = $accounts-path.slurp.trim.split("\n"); - my @valid-accounts; - for @lines -> $line { - my $trimmed = $line.trim; - next if !$trimmed || $trimmed.starts-with('#'); - if $trimmed.contains(',') { - my ($pk, $sk) = $trimmed.split(',', 2); - if $pk.starts-with('unsb-pk-') && $sk.starts-with('unsb-sk-') { - @valid-accounts.push(($pk, $sk)); - } - } - } - if @valid-accounts && $account-index < @valid-accounts.elems { - return @valid-accounts[$account-index]; - } + # Priority 4: ~/.unsandbox/accounts.csv row 0 (or UNSANDBOX_ACCOUNT env) + my $default-idx = (%*ENV // '0').Int; + for ($*HOME.add('.unsandbox').add('accounts.csv'), + 'accounts.csv'.IO) -> $path { + my @accts = load-accounts-from($path); + if @accts && $default-idx < @accts.elems { + return @accts[$default-idx]; } } @@ -1366,8 +1387,8 @@ sub uri-encode(Str $s) { return $s.subst(/<-[A-Za-z0-9\-_.~]>/, { .encode.list.map({ '%' ~ .fmt('%02X') }).join }, :g); } -sub cmd-execute(@args) { - my ($public-key, $secret-key) = get-credentials(); +sub cmd-execute(@args, Int :$account-index = -1) { + my ($public-key, $secret-key) = get-credentials(:$account-index); my $source-file = ''; my %env-vars; my @input-files; @@ -1490,8 +1511,8 @@ sub cmd-execute(@args) { exit %result // 0; } -sub cmd-session(@args) { - my ($public-key, $secret-key) = get-credentials(); +sub cmd-session(@args, Int :$account-index = -1) { + my ($public-key, $secret-key) = get-credentials(:$account-index); my $list-mode = False; my $kill-id = ''; my $shell = ''; @@ -1579,8 +1600,8 @@ sub cmd-session(@args) { say "{$YELLOW}(Interactive sessions require WebSocket - use un2 for full support){$RESET}"; } -sub cmd-service(@args) { - my ($public-key, $secret-key) = get-credentials(); +sub cmd-service(@args, Int :$account-index = -1) { + my ($public-key, $secret-key) = get-credentials(:$account-index); my $list-mode = False; my $info-id = ''; my $logs-id = ''; @@ -1813,8 +1834,8 @@ sub cmd-service(@args) { exit 1; } -sub cmd-languages(@args) { - my ($public-key, $secret-key) = get-credentials(); +sub cmd-languages(@args, Int :$account-index = -1) { + my ($public-key, $secret-key) = get-credentials(:$account-index); my $json-output = False; for @args -> $arg { @@ -1837,8 +1858,8 @@ sub cmd-languages(@args) { } } -sub cmd-key(@args) { - my ($public-key, $secret-key) = get-credentials(); +sub cmd-key(@args, Int :$account-index = -1) { + my ($public-key, $secret-key) = get-credentials(:$account-index); my $extend = False; for @args -> $arg { @@ -1897,8 +1918,8 @@ sub cmd-key(@args) { say "Concurrency: {%result // 'N/A'}"; } -sub cmd-image(@args) { - my ($public-key, $secret-key) = get-credentials(); +sub cmd-image(@args, Int :$account-index = -1) { + my ($public-key, $secret-key) = get-credentials(:$account-index); my $list-mode = False; my $info-id = ''; my $delete-id = ''; @@ -2090,8 +2111,8 @@ sub cmd-image(@args) { exit 1; } -sub cmd-snapshot(@args) { - my ($public-key, $secret-key) = get-credentials(); +sub cmd-snapshot(@args, Int :$account-index = -1) { + my ($public-key, $secret-key) = get-credentials(:$account-index); my $list-mode = False; my $info-id = ''; my $delete-id = ''; @@ -2224,7 +2245,19 @@ sub cmd-snapshot(@args) { exit 1; } -sub MAIN(*@args) is export { +sub MAIN(*@args is copy) is export { + # Pre-parse --account N (global credential flag) before dispatching + my $account-index = -1; + my $i = 0; + while $i < @args.elems { + if @args[$i] eq '--account' && $i + 1 < @args.elems { + $account-index = @args[$i + 1].Int; + @args.splice($i, 2); + } else { + $i++; + } + } + unless @args { note "Usage: un.raku [options] "; note " un.raku session [options]"; @@ -2275,25 +2308,25 @@ sub MAIN(*@args) is export { given @args[0] { when 'session' { - cmd-session(@args[1..*]); + cmd-session(@args[1..*], :$account-index); } when 'service' { - cmd-service(@args[1..*]); + cmd-service(@args[1..*], :$account-index); } when 'snapshot' { - cmd-snapshot(@args[1..*]); + cmd-snapshot(@args[1..*], :$account-index); } when 'image' { - cmd-image(@args[1..*]); + cmd-image(@args[1..*], :$account-index); } when 'key' { - cmd-key(@args[1..*]); + cmd-key(@args[1..*], :$account-index); } when 'languages' { - cmd-languages(@args[1..*]); + cmd-languages(@args[1..*], :$account-index); } default { - cmd-execute(@args); + cmd-execute(@args, :$account-index); } } } diff --git a/un.groovy b/un.groovy deleted file mode 120000 index c143828..0000000 --- a/un.groovy +++ /dev/null @@ -1 +0,0 @@ -clients/groovy/sync/src/un.groovy \ No newline at end of file diff --git a/un.groovy b/un.groovy new file mode 100644 index 0000000..05c2667 --- /dev/null +++ b/un.groovy @@ -0,0 +1,2806 @@ +#!/usr/bin/env groovy +// PUBLIC DOMAIN - NO LICENSE, NO WARRANTY +// +// This is free public domain software for the public good of a permacomputer hosted +// at permacomputer.com - an always-on computer by the people, for the people. One +// which is durable, easy to repair, and distributed like tap water for machine +// learning intelligence. +// +// The permacomputer is community-owned infrastructure optimized around four values: +// +// TRUTH - First principles, math & science, open source code freely distributed +// FREEDOM - Voluntary partnerships, freedom from tyranny & corporate control +// HARMONY - Minimal waste, self-renewing systems with diverse thriving connections +// LOVE - Be yourself without hurting others, cooperation through natural law +// +// This software contributes to that vision by enabling code execution across 42+ +// programming languages through a unified interface, accessible to all. Code is +// seeds to sprout on any abandoned technology. +// +// Learn more: https://www.permacomputer.com +// +// Anyone is free to copy, modify, publish, use, compile, sell, or distribute this +// software, either in source code form or as a compiled binary, for any purpose, +// commercial or non-commercial, and by any means. +// +// NO WARRANTY. THE SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND. +// +// That said, our permacomputer's digital membrane stratum continuously runs unit, +// integration, and functional tests on all of it's own software - with our +// permacomputer monitoring itself, repairing itself, with minimal human in the +// loop guidance. Our agents do their best. +// +// Copyright 2025 TimeHexOn & foxhop & russell@unturf +// https://www.timehexon.com +// https://www.foxhop.net +// https://www.unturf.com/software + + +#!/usr/bin/env groovy +/** + * unsandbox SDK for Groovy - Execute code in secure sandboxes + * https://unsandbox.com | https://api.unsandbox.com/openapi + * + *

Library Usage:

+ *
{@code
+ * import un
+ *
+ * // Simple execution
+ * def result = un.execute("python", 'print("Hello")')
+ * println result.stdout
+ *
+ * // Async execution
+ * def job = un.executeAsync("python", longCode)
+ * def result = un.wait(job.job_id)
+ *
+ * // Using Client class
+ * def client = new un.Client(publicKey: "unsb-pk-...", secretKey: "unsb-sk-...")
+ * def result = client.execute("python", code)
+ * }
+ * + *

CLI Usage:

+ *
+ * groovy un.groovy script.py
+ * groovy un.groovy -s python 'print("Hello")'
+ * groovy un.groovy session --shell python3
+ * 
+ * + *

Authentication (in priority order):

+ *
    + *
  1. Function arguments: execute(..., publicKey: "...", secretKey: "...")
  2. + *
  3. Environment variables: UNSANDBOX_PUBLIC_KEY + UNSANDBOX_SECRET_KEY
  4. + *
  5. Config file: ~/.unsandbox/accounts.csv (public_key,secret_key per line)
  6. + *
+ * + * @author Permacomputer Project + * @version 4.3.4 + */ + +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import groovy.json.JsonSlurper +import groovy.json.JsonOutput + +// ============================================================================ +// Configuration +// ============================================================================ + +/** API base URL for unsandbox */ +def API_BASE = 'https://api.unsandbox.com' + +/** Portal base URL for unsandbox */ +def PORTAL_BASE = 'https://unsandbox.com' + +/** Default execution timeout in seconds */ +def DEFAULT_TIMEOUT = 300 + +/** Default TTL for code execution */ +def DEFAULT_TTL = 60 + +/** Maximum vault content size (64KB) */ +def MAX_ENV_CONTENT_SIZE = 65536 + +/** Polling delays (ms) - exponential backoff */ +def POLL_DELAYS = [300, 450, 700, 900, 650, 1600, 2000] + +// ANSI colors +def BLUE = '\033[34m' +def RED = '\033[31m' +def GREEN = '\033[32m' +def YELLOW = '\033[33m' +def RESET = '\033[0m' + +/** Extension to language mapping */ +def EXT_MAP = [ + '.java': 'java', '.kt': 'kotlin', '.cs': 'csharp', '.fs': 'fsharp', + '.groovy': 'groovy', '.dart': 'dart', '.scala': 'scala', + '.py': 'python', '.js': 'javascript', '.ts': 'typescript', + '.rb': 'ruby', '.go': 'go', '.rs': 'rust', '.cpp': 'cpp', '.c': 'c', + '.sh': 'bash', '.pl': 'perl', '.lua': 'lua', '.php': 'php', + '.hs': 'haskell', '.ml': 'ocaml', '.clj': 'clojure', '.scm': 'scheme', + '.lisp': 'commonlisp', '.erl': 'erlang', '.ex': 'elixir', + '.jl': 'julia', '.r': 'r', '.cr': 'crystal', '.f90': 'fortran', + '.cob': 'cobol', '.pro': 'prolog', '.forth': 'forth', '.tcl': 'tcl', + '.raku': 'raku', '.d': 'd', '.nim': 'nim', '.zig': 'zig', '.v': 'v', + '.awk': 'awk', '.m': 'objc' +] + +// ============================================================================ +// Exceptions +// ============================================================================ + +/** + * Base exception for unsandbox errors. + */ +class UnsandboxError extends Exception { + UnsandboxError(String message) { + super(message) + } +} + +/** + * Authentication failed - invalid or missing credentials. + */ +class AuthenticationError extends UnsandboxError { + AuthenticationError(String message) { + super(message) + } +} + +/** + * Code execution failed. + */ +class ExecutionError extends UnsandboxError { + Integer exitCode + String stderr + + ExecutionError(String message, Integer exitCode = null, String stderr = null) { + super(message) + this.exitCode = exitCode + this.stderr = stderr + } +} + +/** + * API request failed. + */ +class APIError extends UnsandboxError { + Integer statusCode + String response + + APIError(String message, Integer statusCode = null, String response = null) { + super(message) + this.statusCode = statusCode + this.response = response + } +} + +/** + * Execution timed out. + */ +class TimeoutError extends UnsandboxError { + TimeoutError(String message) { + super(message) + } +} + +// ============================================================================ +// HMAC Authentication +// ============================================================================ + +/** + * Generate HMAC-SHA256 signature for API request. + * + *

Signature format: HMAC-SHA256(secretKey, "timestamp:METHOD:path:body")

+ * + * @param secretKey The secret key for HMAC + * @param timestamp Unix timestamp + * @param method HTTP method (GET, POST, etc.) + * @param path API endpoint path + * @param body Request body (empty string if none) + * @return Hex-encoded signature + */ +def signRequest(String secretKey, long timestamp, String method, String path, String body = "") { + def message = "${timestamp}:${method}:${path}:${body}" + def mac = Mac.getInstance("HmacSHA256") + mac.init(new SecretKeySpec(secretKey.getBytes("UTF-8"), "HmacSHA256")) + return mac.doFinal(message.getBytes("UTF-8")).encodeHex().toString() +} + +/** + * Get API credentials in priority order. + * + *
    + *
  1. Function arguments
  2. + *
  3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
  4. + *
  5. Config file (~/.unsandbox/accounts.csv)
  6. + *
+ * + * @param publicKey Optional public key argument + * @param secretKey Optional secret key argument + * @param accountIndex Account index in config file (default 0) + * @return Tuple of [publicKey, secretKey] + * @throws AuthenticationError if no credentials found + */ +def loadAccountsFromCsv(File path) { + def validAccounts = [] + if (!path.exists()) return validAccounts + try { + def lines = path.text.trim().split('\n') + lines.each { line -> + def trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) return + if (trimmed.contains(',')) { + def parts = trimmed.split(',', 2) + def pk = parts[0].trim() + def sk = parts[1].trim() + if (pk.startsWith('unsb-pk-') && sk.startsWith('unsb-sk-')) { + validAccounts << [pk, sk] + } + } + } + } catch (Exception e) { + // Ignore file read errors + } + return validAccounts +} + +def getCredentials(String publicKey = null, String secretKey = null, int accountIndex = -1) { + // Priority 1: Function arguments + if (publicKey && secretKey) { + return [publicKey, secretKey] + } + + // Priority 2: --account N => accounts.csv row N (bypasses env vars) + if (accountIndex >= 0) { + def searchPaths = [ + new File(System.getProperty('user.home'), '.unsandbox/accounts.csv'), + new File('accounts.csv') + ] + for (path in searchPaths) { + def accts = loadAccountsFromCsv(path) + if (accts && accountIndex < accts.size()) { + return accts[accountIndex] + } + } + throw new AuthenticationError("No account at index ${accountIndex} in accounts.csv") + } + + // Priority 3: Environment variables + def envPk = System.getenv('UNSANDBOX_PUBLIC_KEY') + def envSk = System.getenv('UNSANDBOX_SECRET_KEY') + if (envPk && envSk) { + return [envPk, envSk] + } + + // Priority 4: ~/.unsandbox/accounts.csv row 0 (or UNSANDBOX_ACCOUNT env) + def defaultIdx = (System.getenv('UNSANDBOX_ACCOUNT') ?: '0').toInteger() + def searchPaths = [ + new File(System.getProperty('user.home'), '.unsandbox/accounts.csv'), + new File('accounts.csv') + ] + for (path in searchPaths) { + def accts = loadAccountsFromCsv(path) + if (accts && defaultIdx < accts.size()) { + return accts[defaultIdx] + } + } + + throw new AuthenticationError( + "No credentials found. Set UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY, " + + "or create ~/.unsandbox/accounts.csv, or pass credentials to function." + ) +} + +// Legacy compatibility - now delegates to getCredentials for proper priority +def getApiKeys(argsKey, int accountIndex = -1) { + try { + return getCredentials(argsKey ?: null, null, accountIndex) + } catch (AuthenticationError e) { + System.err.println("${RED}Error: ${e.message}${RESET}") + System.exit(1) + } +} + +// ============================================================================ +// HTTP Client +// ============================================================================ + +/** + * Make authenticated API request with HMAC signature. + * + * @param endpoint API endpoint path + * @param method HTTP method + * @param data Request body data (will be JSON-encoded if Map) + * @param publicKey API public key + * @param secretKey API secret key + * @param timeout Request timeout in seconds + * @param contentType Content-Type header + * @return Parsed JSON response as Map + * @throws APIError on request failure + */ +def apiRequest(String endpoint, String method, data, String publicKey, String secretKey, + int timeout = DEFAULT_TIMEOUT, String contentType = 'application/json') { + def tempFile = File.createTempFile('un_request_', '.json') + try { + def body = "" + if (data) { + body = data instanceof Map ? JsonOutput.toJson(data) : data.toString() + tempFile.text = body + } + + def curlCmd = ['curl', '-s', '-X', method, "${API_BASE}${endpoint}", + '-H', "Content-Type: ${contentType}"] + + // Add HMAC authentication headers if secretKey is provided + if (secretKey) { + def timestamp = (System.currentTimeMillis() / 1000) as long + def signature = signRequest(secretKey, timestamp, method, endpoint, body) + + curlCmd += ['-H', "Authorization: Bearer ${publicKey}"] + curlCmd += ['-H', "X-Timestamp: ${timestamp}"] + curlCmd += ['-H', "X-Signature: ${signature}"] + } else { + curlCmd += ['-H', "Authorization: Bearer ${publicKey}"] + } + + if (data) { + curlCmd += ['-d', "@${tempFile.absolutePath}"] + } + + def proc = curlCmd.execute() + def output = proc.text + proc.waitFor() + + if (proc.exitValue() != 0) { + throw new APIError("curl failed with exit code ${proc.exitValue()}") + } + + // Check for timestamp authentication errors + if (output.toLowerCase().contains('timestamp') && + (output.contains('401') || output.toLowerCase().contains('expired') || output.toLowerCase().contains('invalid'))) { + throw new AuthenticationError( + "Request timestamp expired. Your system clock may be out of sync. " + + "Run: sudo ntpdate -s time.nist.gov" + ) + } + + try { + return new JsonSlurper().parseText(output) + } catch (Exception e) { + return [raw: output] + } + } finally { + tempFile.delete() + } +} + +def apiRequestPatch(endpoint, data, publicKey, secretKey) { + return apiRequest(endpoint, 'PATCH', data, publicKey, secretKey) +} + +/** + * Exception for 428 Sudo Challenge requiring OTP confirmation. + */ +class SudoChallengeError extends UnsandboxError { + String challengeId + String responseBody + + SudoChallengeError(String challengeId, String responseBody) { + super("Sudo challenge required") + this.challengeId = challengeId + this.responseBody = responseBody + } +} + +/** + * Make API request for destructive operations with 428 handling. + * Uses curl with -w to capture HTTP status code. + */ +def apiRequestDestructive(String endpoint, String method, data, String publicKey, String secretKey) { + def tempFile = File.createTempFile('un_request_', '.json') + def statusFile = File.createTempFile('un_status_', '.txt') + try { + def body = "" + if (data) { + body = data instanceof Map ? JsonOutput.toJson(data) : data.toString() + tempFile.text = body + } + + def timestamp = (System.currentTimeMillis() / 1000) as long + def signature = signRequest(secretKey, timestamp, method, endpoint, body) + + def curlCmd = ['curl', '-s', '-X', method, "${API_BASE}${endpoint}", + '-H', "Content-Type: application/json", + '-H', "Authorization: Bearer ${publicKey}", + '-H', "X-Timestamp: ${timestamp}", + '-H', "X-Signature: ${signature}", + '-w', '\\n%{http_code}', + '-o', statusFile.absolutePath] + + if (data) { + curlCmd += ['-d', "@${tempFile.absolutePath}"] + } + + def proc = curlCmd.execute() + def statusOutput = proc.text.trim() + proc.waitFor() + + def responseBody = statusFile.exists() ? statusFile.text : "" + def httpCode = 0 + try { + httpCode = statusOutput.toInteger() + } catch (Exception e) { + // Failed to parse status code + } + + if (httpCode == 428) { + // Extract challenge_id from response + def challengeId = null + try { + def parsed = new JsonSlurper().parseText(responseBody) + challengeId = parsed?.challenge_id + } catch (Exception e) { + // Ignore parse errors + } + throw new SudoChallengeError(challengeId, responseBody) + } + + if (httpCode < 200 || httpCode >= 300) { + throw new APIError("HTTP ${httpCode} - ${responseBody}", httpCode, responseBody) + } + + try { + return new JsonSlurper().parseText(responseBody) + } catch (Exception e) { + return [raw: responseBody] + } + } finally { + tempFile.delete() + statusFile.delete() + } +} + +/** + * Make API request with sudo OTP headers. + */ +def apiRequestWithSudo(String endpoint, String method, data, String publicKey, String secretKey, String otp, String challengeId) { + def tempFile = File.createTempFile('un_request_', '.json') + def statusFile = File.createTempFile('un_status_', '.txt') + try { + def body = "" + if (data) { + body = data instanceof Map ? JsonOutput.toJson(data) : data.toString() + tempFile.text = body + } + + def timestamp = (System.currentTimeMillis() / 1000) as long + def signature = signRequest(secretKey, timestamp, method, endpoint, body) + + def curlCmd = ['curl', '-s', '-X', method, "${API_BASE}${endpoint}", + '-H', "Content-Type: application/json", + '-H', "Authorization: Bearer ${publicKey}", + '-H', "X-Timestamp: ${timestamp}", + '-H', "X-Signature: ${signature}", + '-H', "X-Sudo-OTP: ${otp}", + '-w', '\\n%{http_code}', + '-o', statusFile.absolutePath] + + if (challengeId) { + curlCmd += ['-H', "X-Sudo-Challenge: ${challengeId}"] + } + + if (data) { + curlCmd += ['-d', "@${tempFile.absolutePath}"] + } + + def proc = curlCmd.execute() + def statusOutput = proc.text.trim() + proc.waitFor() + + def responseBody = statusFile.exists() ? statusFile.text : "" + def httpCode = 0 + try { + httpCode = statusOutput.toInteger() + } catch (Exception e) { + // Failed to parse status code + } + + if (httpCode < 200 || httpCode >= 300) { + throw new APIError("HTTP ${httpCode} - ${responseBody}", httpCode, responseBody) + } + + try { + return new JsonSlurper().parseText(responseBody) + } catch (Exception e) { + return [raw: responseBody] + } + } finally { + tempFile.delete() + statusFile.delete() + } +} + +/** + * Handle sudo challenge by prompting for OTP and retrying. + */ +def handleSudoChallenge(String challengeId, String method, String endpoint, data, String publicKey, String secretKey) { + System.err.println("${YELLOW}Confirmation required. Check your email for a one-time code.${RESET}") + System.err.print("Enter OTP: ") + System.err.flush() + + def reader = new BufferedReader(new InputStreamReader(System.in)) + def otp = reader.readLine()?.trim() + + if (!otp) { + throw new RuntimeException("Operation cancelled - no OTP provided") + } + + return apiRequestWithSudo(endpoint, method, data, publicKey, secretKey, otp, challengeId) +} + +/** + * Execute a destructive operation with 428 sudo challenge handling. + */ +def executeDestructive(String endpoint, String method, data, String publicKey, String secretKey) { + try { + return apiRequestDestructive(endpoint, method, data, publicKey, secretKey) + } catch (SudoChallengeError e) { + return handleSudoChallenge(e.challengeId, method, endpoint, data, publicKey, secretKey) + } +} + +def apiRequestText(endpoint, method, body, publicKey, secretKey) { + def tempFile = File.createTempFile('un_env_', '.txt') + try { + if (body) { + tempFile.text = body + } + + def curlCmd = ['curl', '-s', '-X', method, "${API_BASE}${endpoint}", + '-H', 'Content-Type: text/plain'] + + if (secretKey) { + def timestamp = (System.currentTimeMillis() / 1000) as long + def message = "${timestamp}:${method}:${endpoint}:${body ?: ''}" + + def mac = Mac.getInstance("HmacSHA256") + mac.init(new SecretKeySpec(secretKey.getBytes("UTF-8"), "HmacSHA256")) + def signature = mac.doFinal(message.getBytes("UTF-8")).encodeHex().toString() + + curlCmd += ['-H', "Authorization: Bearer ${publicKey}"] + curlCmd += ['-H', "X-Timestamp: ${timestamp}"] + curlCmd += ['-H', "X-Signature: ${signature}"] + } else { + curlCmd += ['-H', "Authorization: Bearer ${publicKey}"] + } + + if (body) { + curlCmd += ['--data-binary', "@${tempFile.absolutePath}"] + } + + def proc = curlCmd.execute() + def output = proc.text + proc.waitFor() + + return proc.exitValue() == 0 + } finally { + tempFile.delete() + } +} + +// ============================================================================ +// Core Library Functions +// ============================================================================ + +/** + * Execute code synchronously and return results. + * + * @param language Programming language (python, javascript, go, rust, etc.) + * @param code Source code to execute + * @param options Optional parameters: + *
    + *
  • env: Map of environment variables
  • + *
  • inputFiles: List of [filename: "...", content: "..."] or [filename: "...", contentBase64: "..."]
  • + *
  • networkMode: "zerotrust" (no network) or "semitrusted" (internet access)
  • + *
  • ttl: Execution timeout in seconds (1-900, default 60)
  • + *
  • vcpu: Virtual CPUs (1-8, default 1)
  • + *
  • returnArtifact: Return compiled binary
  • + *
  • returnWasmArtifact: Compile to WebAssembly
  • + *
  • publicKey: API public key
  • + *
  • secretKey: API secret key
  • + *
+ * @return Map with keys: success, stdout, stderr, exit_code, language, job_id, total_time_ms, network_mode, artifacts + * @throws AuthenticationError Invalid or missing credentials + * @throws ExecutionError Code execution failed + * @throws APIError API request failed + * + *
{@code
+ * def result = un.execute("python", 'print("Hello World")')
+ * println result.stdout  // "Hello World\n"
+ * }
+ */ +def execute(String language, String code, Map options = [:]) { + def (publicKey, secretKey) = getCredentials( + options.publicKey, + options.secretKey, + options.accountIndex != null ? options.accountIndex : -1 + ) + + def payload = [ + language: language, + code: code, + network_mode: options.networkMode ?: 'zerotrust', + ttl: options.ttl ?: DEFAULT_TTL, + vcpu: options.vcpu ?: 1 + ] + + if (options.env) { + payload.env = options.env + } + + if (options.inputFiles) { + payload.input_files = options.inputFiles.collect { f -> + if (f.contentBase64 || f.content_base64) { + return [filename: f.filename, content_base64: f.contentBase64 ?: f.content_base64] + } else if (f.content) { + return [filename: f.filename, content_base64: f.content.bytes.encodeBase64().toString()] + } + return f + } + } + + if (options.returnArtifact) payload.return_artifact = true + if (options.returnWasmArtifact) payload.return_wasm_artifact = true + + return apiRequest('/execute', 'POST', payload, publicKey, secretKey) +} + +/** + * Execute code asynchronously. Returns immediately with job_id for polling. + * + * @param language Programming language + * @param code Source code to execute + * @param options Same options as execute() + * @return Map with keys: job_id, status ("pending") + * + *
{@code
+ * def job = un.executeAsync("python", longRunningCode)
+ * println "Job submitted: ${job.job_id}"
+ * def result = un.wait(job.job_id)
+ * }
+ */ +def executeAsync(String language, String code, Map options = [:]) { + def (publicKey, secretKey) = getCredentials( + options.publicKey, + options.secretKey, + options.accountIndex != null ? options.accountIndex : -1 + ) + + def payload = [ + language: language, + code: code, + network_mode: options.networkMode ?: 'zerotrust', + ttl: options.ttl ?: DEFAULT_TTL, + vcpu: options.vcpu ?: 1 + ] + + if (options.env) payload.env = options.env + if (options.inputFiles) { + payload.input_files = options.inputFiles.collect { f -> + if (f.contentBase64 || f.content_base64) { + return [filename: f.filename, content_base64: f.contentBase64 ?: f.content_base64] + } else if (f.content) { + return [filename: f.filename, content_base64: f.content.bytes.encodeBase64().toString()] + } + return f + } + } + if (options.returnArtifact) payload.return_artifact = true + if (options.returnWasmArtifact) payload.return_wasm_artifact = true + + return apiRequest('/execute/async', 'POST', payload, publicKey, secretKey) +} + +/** + * Execute code with automatic language detection from shebang. + * + * @param code Source code with shebang (e.g., #!/usr/bin/env python3) + * @param options Optional parameters (env, networkMode, ttl, publicKey, secretKey) + * @return Map with keys: success, stdout, stderr, exit_code, detected_language, ... + * + *
{@code
+ * def code = '''#!/usr/bin/env python3
+ * print("Auto-detected!")
+ * '''
+ * def result = un.run(code)
+ * println result.detected_language  // "python"
+ * }
+ */ +def run(String code, Map options = [:]) { + def (publicKey, secretKey) = getCredentials( + options.publicKey, + options.secretKey, + options.accountIndex != null ? options.accountIndex : -1 + ) + + def ttl = options.ttl ?: DEFAULT_TTL + def networkMode = options.networkMode ?: 'zerotrust' + def endpoint = "/run?ttl=${ttl}&network_mode=${networkMode}" + + if (options.env) { + endpoint += "&env=${URLEncoder.encode(JsonOutput.toJson(options.env), 'UTF-8')}" + } + + return apiRequest(endpoint, 'POST', code, publicKey, secretKey, DEFAULT_TIMEOUT, 'text/plain') +} + +/** + * Execute code asynchronously with automatic language detection. + * + * @param code Source code with shebang + * @param options Optional parameters + * @return Map with keys: job_id, detected_language, status ("pending") + */ +def runAsync(String code, Map options = [:]) { + def (publicKey, secretKey) = getCredentials( + options.publicKey, + options.secretKey, + options.accountIndex != null ? options.accountIndex : -1 + ) + + def ttl = options.ttl ?: DEFAULT_TTL + def networkMode = options.networkMode ?: 'zerotrust' + def endpoint = "/run/async?ttl=${ttl}&network_mode=${networkMode}" + + if (options.env) { + endpoint += "&env=${URLEncoder.encode(JsonOutput.toJson(options.env), 'UTF-8')}" + } + + return apiRequest(endpoint, 'POST', code, publicKey, secretKey, DEFAULT_TIMEOUT, 'text/plain') +} + +// ============================================================================ +// Job Management +// ============================================================================ + +/** + * Get job status and results. + * + * @param jobId Job ID from executeAsync or runAsync + * @param options Optional parameters (publicKey, secretKey) + * @return Map with keys: job_id, status, result (if completed), timestamps + * + *

Status values: pending, running, completed, failed, timeout, cancelled

+ */ +def getJob(String jobId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/jobs/${jobId}", 'GET', null, publicKey, secretKey) +} + +/** + * Wait for job completion with exponential backoff polling. + * + * @param jobId Job ID from executeAsync or runAsync + * @param options Optional parameters: + *
    + *
  • maxPolls: Maximum number of poll attempts (default 100)
  • + *
  • publicKey: API public key
  • + *
  • secretKey: API secret key
  • + *
+ * @return Final job result Map + * @throws TimeoutError Max polls exceeded + * @throws ExecutionError Job failed + * + *
{@code
+ * def job = un.executeAsync("python", code)
+ * def result = un.wait(job.job_id)
+ * println result.stdout
+ * }
+ */ +def wait(String jobId, Map options = [:]) { + def maxPolls = options.maxPolls ?: 100 + def terminalStates = ['completed', 'failed', 'timeout', 'cancelled'] as Set + + for (int i = 0; i < maxPolls; i++) { + // Exponential backoff delay + def delayIdx = Math.min(i, POLL_DELAYS.size() - 1) + Thread.sleep(POLL_DELAYS[delayIdx]) + + def result = getJob(jobId, options) + def status = result.status ?: '' + + if (status in terminalStates) { + if (status == 'failed') { + throw new ExecutionError( + "Job failed: ${result.error ?: 'Unknown error'}", + result.exit_code, + result.stderr + ) + } + if (status == 'timeout') { + throw new TimeoutError("Job timed out: ${jobId}") + } + return result + } + } + + throw new TimeoutError("Max polls (${maxPolls}) exceeded for job ${jobId}") +} + +/** + * Cancel a running job. + * + * @param jobId Job ID to cancel + * @param options Optional parameters (publicKey, secretKey) + * @return Partial output and artifacts collected before cancellation + */ +def cancelJob(String jobId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/jobs/${jobId}", 'DELETE', null, publicKey, secretKey) +} + +/** + * List all active jobs for this API key. + * + * @param options Optional parameters (publicKey, secretKey) + * @return List of job summary Maps with keys: job_id, language, status, submitted_at + */ +def listJobs(Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def result = apiRequest('/jobs', 'GET', null, publicKey, secretKey) + return result.jobs ?: [] +} + +// ============================================================================ +// Image Generation +// ============================================================================ + +/** + * Generate images from text prompt. + * + * @param prompt Text description of the image to generate + * @param options Optional parameters: + *
    + *
  • model: Model to use (optional, uses default)
  • + *
  • size: Image size (e.g., "1024x1024", "512x512")
  • + *
  • quality: "standard" or "hd"
  • + *
  • n: Number of images to generate
  • + *
  • publicKey: API public key
  • + *
  • secretKey: API secret key
  • + *
+ * @return Map with keys: images (list of base64 or URLs), created_at + * + *
{@code
+ * def result = un.image("A sunset over mountains")
+ * println result.images[0]
+ * }
+ */ +def image(String prompt, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + + def payload = [ + prompt: prompt, + size: options.size ?: '1024x1024', + quality: options.quality ?: 'standard', + n: options.n ?: 1 + ] + if (options.model) payload.model = options.model + + return apiRequest('/image', 'POST', payload, publicKey, secretKey) +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** Cache max age for languages (1 hour in milliseconds) */ +def LANGUAGES_CACHE_MAX_AGE = 3600000 + +/** + * Get list of supported programming languages. + * + *

Results are cached in ~/.unsandbox/languages.json for 1 hour.

+ * + * @param options Optional parameters: + *
    + *
  • forceRefresh: Bypass cache and fetch fresh data
  • + *
  • publicKey: API public key
  • + *
  • secretKey: API secret key
  • + *
+ * @return Map with keys: languages (list), count, aliases (map) + */ +def languages(Map options = [:]) { + def cachePath = new File(System.getProperty('user.home'), '.unsandbox/languages.json') + + // Check cache unless force refresh + if (!options.forceRefresh && cachePath.exists()) { + try { + def cacheAge = System.currentTimeMillis() - cachePath.lastModified() + if (cacheAge < LANGUAGES_CACHE_MAX_AGE) { + return new JsonSlurper().parseText(cachePath.text) + } + } catch (Exception e) { + // Cache read failed, fetch from API + } + } + + // Fetch from API + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def result = apiRequest('/languages', 'GET', null, publicKey, secretKey) + + // Save to cache + try { + cachePath.parentFile.mkdirs() + cachePath.text = JsonOutput.toJson(result) + } catch (Exception e) { + // Cache write failed, continue anyway + } + + return result +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * Get SDK version string. + */ +def version() { + return "4.2.0" +} + +/** + * Check API health status. + */ +def healthCheck() { + try { + def url = new URL("${API_BASE}/health") + def connection = url.openConnection() as java.net.HttpURLConnection + connection.requestMethod = "GET" + connection.connectTimeout = 5000 + connection.readTimeout = 5000 + return connection.responseCode == 200 + } catch (Exception e) { + return false + } +} + +/** + * Generate HMAC-SHA256 signature. + */ +def hmacSign(String secretKey, String message) { + def mac = Mac.getInstance("HmacSHA256") + mac.init(new SecretKeySpec(secretKey.getBytes("UTF-8"), "HmacSHA256")) + return mac.doFinal(message.getBytes("UTF-8")).encodeHex().toString() +} + +// ============================================================================ +// Session Functions +// ============================================================================ + +/** + * List all sessions. + */ +def sessionList(Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def result = apiRequest('/sessions', 'GET', null, publicKey, secretKey) + return result.sessions ?: [] +} + +/** + * Get session details. + */ +def sessionGet(String sessionId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/sessions/${sessionId}", 'GET', null, publicKey, secretKey) +} + +/** + * Create a new session. + */ +def sessionCreate(Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def payload = [ + network_mode: options.networkMode ?: 'zerotrust', + shell: options.shell ?: 'bash' + ] + if (options.vcpu) payload.vcpu = options.vcpu + return apiRequest('/sessions', 'POST', payload, publicKey, secretKey) +} + +/** + * Destroy a session. + */ +def sessionDestroy(String sessionId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/sessions/${sessionId}", 'DELETE', null, publicKey, secretKey) +} + +/** + * Freeze a session. + */ +def sessionFreeze(String sessionId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/sessions/${sessionId}/freeze", 'POST', null, publicKey, secretKey) +} + +/** + * Unfreeze a session. + */ +def sessionUnfreeze(String sessionId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/sessions/${sessionId}/unfreeze", 'POST', null, publicKey, secretKey) +} + +/** + * Boost a session. + */ +def sessionBoost(String sessionId, int vcpu = 2, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/sessions/${sessionId}/boost", 'POST', [vcpu: vcpu], publicKey, secretKey) +} + +/** + * Unboost a session. + */ +def sessionUnboost(String sessionId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/sessions/${sessionId}/unboost", 'POST', null, publicKey, secretKey) +} + +/** + * Execute command in a session. + */ +def sessionExecute(String sessionId, String command, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/sessions/${sessionId}/shell", 'POST', [command: command], publicKey, secretKey) +} + +// ============================================================================ +// Service Functions +// ============================================================================ + +/** + * List all services. + */ +def serviceList(Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def result = apiRequest('/services', 'GET', null, publicKey, secretKey) + return result.services ?: [] +} + +/** + * Get service details. + */ +def serviceGet(String serviceId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/services/${serviceId}", 'GET', null, publicKey, secretKey) +} + +/** + * Create a new service. + */ +def serviceCreate(String name, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def payload = [name: name] + if (options.ports) payload.ports = options.ports.split(',').collect { it.trim().toInteger() } + if (options.domains) payload.domains = options.domains + if (options.bootstrap) payload.bootstrap = options.bootstrap + if (options.networkMode) payload.network_mode = options.networkMode + def result = apiRequest('/services', 'POST', payload, publicKey, secretKey) + return result.id +} + +/** + * Destroy a service. + */ +def serviceDestroy(String serviceId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return executeDestructive("/services/${serviceId}", 'DELETE', null, publicKey, secretKey) +} + +/** + * Freeze a service. + */ +def serviceFreeze(String serviceId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/services/${serviceId}/freeze", 'POST', null, publicKey, secretKey) +} + +/** + * Unfreeze a service. + */ +def serviceUnfreeze(String serviceId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/services/${serviceId}/unfreeze", 'POST', null, publicKey, secretKey) +} + +/** + * Lock a service. + */ +def serviceLock(String serviceId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/services/${serviceId}/lock", 'POST', null, publicKey, secretKey) +} + +/** + * Unlock a service. + */ +def serviceUnlock(String serviceId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return executeDestructive("/services/${serviceId}/unlock", 'POST', null, publicKey, secretKey) +} + +/** + * Set unfreeze on demand for a service. + */ +def serviceSetUnfreezeOnDemand(String serviceId, boolean enabled, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequestPatch("/services/${serviceId}", [unfreeze_on_demand: enabled], publicKey, secretKey) +} + +/** + * Redeploy a service. + */ +def serviceRedeploy(String serviceId, String bootstrap = null, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def payload = bootstrap ? [bootstrap: bootstrap] : [:] + return apiRequest("/services/${serviceId}/redeploy", 'POST', payload, publicKey, secretKey) +} + +/** + * Get service logs. + */ +def serviceLogs(String serviceId, boolean allLogs = false, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def path = allLogs ? "/services/${serviceId}/logs?all=true" : "/services/${serviceId}/logs" + def result = apiRequest(path, 'GET', null, publicKey, secretKey) + return result.logs +} + +/** + * Execute command in a service. + */ +def serviceExecute(String serviceId, String command, int timeoutMs = 0, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def payload = [command: command] + if (timeoutMs > 0) payload.timeout = timeoutMs + return apiRequest("/services/${serviceId}/execute", 'POST', payload, publicKey, secretKey) +} + +/** + * Get service environment vault status. + */ +def serviceEnvGet(String serviceId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/services/${serviceId}/env", 'GET', null, publicKey, secretKey) +} + +/** + * Set service environment vault. + */ +def serviceEnvSet(String serviceId, String envContent, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequestText("/services/${serviceId}/env", 'PUT', envContent, publicKey, secretKey) +} + +/** + * Delete service environment vault. + */ +def serviceEnvDelete(String serviceId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/services/${serviceId}/env", 'DELETE', null, publicKey, secretKey) +} + +/** + * Export service environment vault. + */ +def serviceEnvExport(String serviceId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/services/${serviceId}/env/export", 'POST', [:], publicKey, secretKey) +} + +/** + * Resize a service. + */ +def serviceResize(String serviceId, int vcpu, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequestPatch("/services/${serviceId}", [vcpu: vcpu], publicKey, secretKey) +} + +// ============================================================================ +// Snapshot Functions +// ============================================================================ + +/** + * List all snapshots. + */ +def snapshotList(Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def result = apiRequest('/snapshots', 'GET', null, publicKey, secretKey) + return result.snapshots ?: [] +} + +/** + * Get snapshot details. + */ +def snapshotGet(String snapshotId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/snapshots/${snapshotId}", 'GET', null, publicKey, secretKey) +} + +/** + * Create snapshot from session. + */ +def snapshotSession(String sessionId, String name = null, boolean hot = false, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def payload = [session_id: sessionId, hot: hot] + if (name) payload.name = name + def result = apiRequest('/snapshots', 'POST', payload, publicKey, secretKey) + return result.snapshot_id +} + +/** + * Create snapshot from service. + */ +def snapshotService(String serviceId, String name = null, boolean hot = false, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def payload = [service_id: serviceId, hot: hot] + if (name) payload.name = name + def result = apiRequest('/snapshots', 'POST', payload, publicKey, secretKey) + return result.snapshot_id +} + +/** + * Restore a snapshot. + */ +def snapshotRestore(String snapshotId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/snapshots/${snapshotId}/restore", 'POST', [:], publicKey, secretKey) +} + +/** + * Delete a snapshot. + */ +def snapshotDelete(String snapshotId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return executeDestructive("/snapshots/${snapshotId}", 'DELETE', null, publicKey, secretKey) +} + +/** + * Lock a snapshot. + */ +def snapshotLock(String snapshotId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/snapshots/${snapshotId}/lock", 'POST', null, publicKey, secretKey) +} + +/** + * Unlock a snapshot. + */ +def snapshotUnlock(String snapshotId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return executeDestructive("/snapshots/${snapshotId}/unlock", 'POST', null, publicKey, secretKey) +} + +/** + * Clone a snapshot. + */ +def snapshotClone(String snapshotId, String cloneType, String name = null, String ports = null, String shell = null, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def payload = [type: cloneType] + if (name) payload.name = name + if (ports) payload.ports = ports.split(',').collect { it.trim().toInteger() } + if (shell) payload.shell = shell + def result = apiRequest("/snapshots/${snapshotId}/clone", 'POST', payload, publicKey, secretKey) + return result.session_id ?: result.service_id +} + +// ============================================================================ +// Image Functions +// ============================================================================ + +/** + * List all images. + */ +def imageList(String filter = null, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def path = filter ? "/images/${filter}" : '/images' + def result = apiRequest(path, 'GET', null, publicKey, secretKey) + return result.images ?: [] +} + +/** + * Get image details. + */ +def imageGet(String imageId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/images/${imageId}", 'GET', null, publicKey, secretKey) +} + +/** + * Publish an image. + */ +def imagePublish(String sourceType, String sourceId, String name = null, String description = null, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def payload = [source_type: sourceType, source_id: sourceId] + if (name) payload.name = name + if (description) payload.description = description + def result = apiRequest('/images', 'POST', payload, publicKey, secretKey) + return result.image_id +} + +/** + * Delete an image. + */ +def imageDelete(String imageId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return executeDestructive("/images/${imageId}", 'DELETE', null, publicKey, secretKey) +} + +/** + * Lock an image. + */ +def imageLock(String imageId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/images/${imageId}/lock", 'POST', null, publicKey, secretKey) +} + +/** + * Unlock an image. + */ +def imageUnlock(String imageId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return executeDestructive("/images/${imageId}/unlock", 'POST', null, publicKey, secretKey) +} + +/** + * Set image visibility. + */ +def imageSetVisibility(String imageId, String visibility, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/images/${imageId}/visibility", 'POST', [visibility: visibility], publicKey, secretKey) +} + +/** + * Grant access to an image. + */ +def imageGrantAccess(String imageId, String trustedApiKey, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/images/${imageId}/grant", 'POST', [trusted_api_key: trustedApiKey], publicKey, secretKey) +} + +/** + * Revoke access to an image. + */ +def imageRevokeAccess(String imageId, String trustedApiKey, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/images/${imageId}/revoke", 'POST', [trusted_api_key: trustedApiKey], publicKey, secretKey) +} + +/** + * List trusted keys for an image. + */ +def imageListTrusted(String imageId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def result = apiRequest("/images/${imageId}/trusted", 'GET', null, publicKey, secretKey) + return result.trusted ?: [] +} + +/** + * Transfer image ownership. + */ +def imageTransfer(String imageId, String toApiKey, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/images/${imageId}/transfer", 'POST', [to_api_key: toApiKey], publicKey, secretKey) +} + +/** + * Spawn a service from an image. + */ +def imageSpawn(String imageId, String name = null, String ports = null, String bootstrap = null, String networkMode = null, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def payload = [:] + if (name) payload.name = name + if (ports) payload.ports = ports.split(',').collect { it.trim().toInteger() } + if (bootstrap) payload.bootstrap = bootstrap + if (networkMode) payload.network_mode = networkMode + def result = apiRequest("/images/${imageId}/spawn", 'POST', payload, publicKey, secretKey) + return result.service_id +} + +/** + * Clone an image. + */ +def imageClone(String imageId, String name = null, String description = null, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def payload = [:] + if (name) payload.name = name + if (description) payload.description = description + def result = apiRequest("/images/${imageId}/clone", 'POST', payload, publicKey, secretKey) + return result.image_id +} + +// ============================================================================ +// PaaS Logs Functions +// ============================================================================ + +/** + * Fetch batch logs. + */ +def logsFetch(String source = 'all', int lines = 100, String since = null, String grep = null, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def params = ["source=${source}", "lines=${lines}"] + if (since) params << "since=${since}" + if (grep) params << "grep=${URLEncoder.encode(grep, 'UTF-8')}" + return apiRequest("/paas/logs?${params.join('&')}", 'GET', null, publicKey, secretKey) +} + +/** + * Callback interface for log streaming. + */ +interface LogCallback { + void onLogLine(String source, String line) +} + +/** + * Stream logs via SSE. Blocks until interrupted or server closes. + * + * @param source Log source ('all', 'api', 'portal', 'pool/cammy', 'pool/ai') + * @param grep Optional filter pattern + * @param callback Callback for each log line + * @param options Optional parameters (publicKey, secretKey) + * @return true on clean shutdown, false on error + */ +def logsStream(String source = 'all', String grep = null, LogCallback callback, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + + def path = "/paas/logs/stream?source=${source ?: 'all'}" + if (grep) { + path += "&grep=${URLEncoder.encode(grep, 'UTF-8')}" + } + + def timestamp = (System.currentTimeMillis() / 1000) as long + def signature = signRequest(secretKey, timestamp, 'GET', path, '') + + def url = new URL("${API_BASE}${path}") + def connection = url.openConnection() as java.net.HttpURLConnection + + connection.requestMethod = 'GET' + connection.setRequestProperty('Authorization', "Bearer ${publicKey}") + connection.setRequestProperty('X-Timestamp', timestamp.toString()) + connection.setRequestProperty('X-Signature', signature) + connection.setRequestProperty('Accept', 'text/event-stream') + connection.connectTimeout = 30000 + connection.readTimeout = 0 // No timeout for streaming + + if (connection.responseCode != 200) { + return false + } + + try { + def reader = new BufferedReader(new InputStreamReader(connection.inputStream, 'UTF-8')) + def currentSource = source ?: 'all' + def line + + while ((line = reader.readLine()) != null) { + if (line.startsWith('data: ')) { + def data = line.substring(6) + if (callback) { + callback.onLogLine(currentSource, data) + } + } else if (line.startsWith('event: ')) { + currentSource = line.substring(7) + } + } + return true + } catch (Exception e) { + return false + } +} + +/** + * Validate API keys. + */ +def validateKeys(Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + + def timestamp = (System.currentTimeMillis() / 1000) as long + def message = "${timestamp}:POST:/keys/validate:{}" + def signature = signRequest(secretKey, timestamp, 'POST', '/keys/validate', '{}') + + def url = new URL("${PORTAL_BASE}/keys/validate") + def connection = url.openConnection() as java.net.HttpURLConnection + + connection.requestMethod = 'POST' + connection.setRequestProperty('Authorization', "Bearer ${publicKey}") + connection.setRequestProperty('X-Timestamp', timestamp.toString()) + connection.setRequestProperty('X-Signature', signature) + connection.setRequestProperty('Content-Type', 'application/json') + connection.connectTimeout = 30000 + connection.readTimeout = 30000 + connection.doOutput = true + connection.outputStream.withWriter { it.write('{}') } + + if (connection.responseCode !in 200..299) { + throw new APIError("HTTP ${connection.responseCode}") + } + + return new JsonSlurper().parseText(connection.inputStream.text) +} + +/** + * Detect programming language from file extension or shebang. + * + * @param filename File path + * @return Language name or null if undetected + */ +def detectLanguage(String filename) { + def dotIndex = filename.lastIndexOf('.') + if (dotIndex == -1) return null + + def ext = filename.substring(dotIndex) + def language = EXT_MAP[ext] + if (language) return language + + // Try shebang + try { + def file = new File(filename) + if (file.exists()) { + def firstLine = file.readLines()[0] + if (firstLine?.startsWith('#!')) { + if (firstLine.contains('python')) return 'python' + if (firstLine.contains('node')) return 'javascript' + if (firstLine.contains('ruby')) return 'ruby' + if (firstLine.contains('perl')) return 'perl' + if (firstLine.contains('bash') || firstLine.contains('/sh')) return 'bash' + if (firstLine.contains('lua')) return 'lua' + if (firstLine.contains('php')) return 'php' + } + } + } catch (Exception e) { + // Ignore file read errors + } + + return null +} + +// ============================================================================ +// Client Class +// ============================================================================ + +/** + * Unsandbox API client with stored credentials. + * + *

Use the Client class when making multiple API calls to avoid + * repeated credential resolution.

+ * + *
{@code
+ * // With explicit credentials
+ * def client = new un.Client(publicKey: "unsb-pk-...", secretKey: "unsb-sk-...")
+ * def result = client.execute("python", 'print("Hello")')
+ *
+ * // Or load from environment/config automatically
+ * def client = new un.Client()
+ * def result = client.execute("python", code)
+ * }
+ * + * @author Permacomputer Project + */ +class Client { + String publicKey + String secretKey + + /** + * Initialize client with credentials. + * + * @param options Optional parameters: + *
    + *
  • publicKey: API public key (unsb-pk-...)
  • + *
  • secretKey: API secret key (unsb-sk-...)
  • + *
  • accountIndex: Account index in ~/.unsandbox/accounts.csv (default 0)
  • + *
+ */ + Client(Map options = [:]) { + def creds = getCredentialsStatic( + options.publicKey, + options.secretKey, + options.accountIndex != null ? options.accountIndex : -1 + ) + this.publicKey = creds[0] + this.secretKey = creds[1] + } + + private static loadCsvAccounts(File path) { + def accounts = [] + if (!path.exists()) return accounts + try { + path.text.trim().split('\n').each { line -> + def trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) return + if (trimmed.contains(',')) { + def parts = trimmed.split(',', 2) + def pk = parts[0].trim() + def sk = parts[1].trim() + if (pk.startsWith('unsb-pk-') && sk.startsWith('unsb-sk-')) { + accounts << [pk, sk] + } + } + } + } catch (Exception e) { + // Ignore + } + return accounts + } + + private static getCredentialsStatic(String publicKey, String secretKey, int accountIndex) { + // Priority 1: explicit arguments + if (publicKey && secretKey) { + return [publicKey, secretKey] + } + + // Priority 2: --account N => CSV row N (bypasses env vars) + if (accountIndex >= 0) { + def searchPaths = [ + new File(System.getProperty('user.home'), '.unsandbox/accounts.csv'), + new File('accounts.csv') + ] + for (path in searchPaths) { + def accts = loadCsvAccounts(path) + if (accts && accountIndex < accts.size()) { + return accts[accountIndex] + } + } + throw new AuthenticationError("No account at index ${accountIndex} in accounts.csv") + } + + // Priority 3: Environment variables + def envPk = System.getenv('UNSANDBOX_PUBLIC_KEY') + def envSk = System.getenv('UNSANDBOX_SECRET_KEY') + if (envPk && envSk) { + return [envPk, envSk] + } + + // Priority 4: ~/.unsandbox/accounts.csv row 0 (or UNSANDBOX_ACCOUNT env) + def defaultIdx = (System.getenv('UNSANDBOX_ACCOUNT') ?: '0').toInteger() + def searchPaths = [ + new File(System.getProperty('user.home'), '.unsandbox/accounts.csv'), + new File('accounts.csv') + ] + for (path in searchPaths) { + def accts = loadCsvAccounts(path) + if (accts && defaultIdx < accts.size()) { + return accts[defaultIdx] + } + } + + throw new AuthenticationError( + "No credentials found. Set UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY." + ) + } + + /** + * Execute code synchronously. + * @see #execute(String, String, Map) + */ + def execute(String language, String code, Map options = [:]) { + options.publicKey = this.publicKey + options.secretKey = this.secretKey + return binding.execute(language, code, options) + } + + /** + * Execute code asynchronously. + * @see #executeAsync(String, String, Map) + */ + def executeAsync(String language, String code, Map options = [:]) { + options.publicKey = this.publicKey + options.secretKey = this.secretKey + return binding.executeAsync(language, code, options) + } + + /** + * Execute with auto-detect. + * @see #run(String, Map) + */ + def run(String code, Map options = [:]) { + options.publicKey = this.publicKey + options.secretKey = this.secretKey + return binding.run(code, options) + } + + /** + * Execute async with auto-detect. + * @see #runAsync(String, Map) + */ + def runAsync(String code, Map options = [:]) { + options.publicKey = this.publicKey + options.secretKey = this.secretKey + return binding.runAsync(code, options) + } + + /** + * Get job status. + * @see #getJob(String, Map) + */ + def getJob(String jobId) { + return binding.getJob(jobId, [publicKey: this.publicKey, secretKey: this.secretKey]) + } + + /** + * Wait for job completion. + * @see #wait(String, Map) + */ + def wait(String jobId, Map options = [:]) { + options.publicKey = this.publicKey + options.secretKey = this.secretKey + return binding.wait(jobId, options) + } + + /** + * Cancel a job. + * @see #cancelJob(String, Map) + */ + def cancelJob(String jobId) { + return binding.cancelJob(jobId, [publicKey: this.publicKey, secretKey: this.secretKey]) + } + + /** + * List active jobs. + * @see #listJobs(Map) + */ + def listJobs() { + return binding.listJobs([publicKey: this.publicKey, secretKey: this.secretKey]) + } + + /** + * Generate image. + * @see #image(String, Map) + */ + def image(String prompt, Map options = [:]) { + options.publicKey = this.publicKey + options.secretKey = this.secretKey + return binding.image(prompt, options) + } + + /** + * Get supported languages. + * @see #languages(Map) + */ + def languages() { + return binding.languages([publicKey: this.publicKey, secretKey: this.secretKey]) + } +} + +// ============================================================================ +// CLI Support Classes and Functions +// ============================================================================ + +class Args { + String command = null + String sourceFile = null + String inlineLang = null + String apiKey = null + Integer accountIndex = -1 + String network = null + Integer vcpu = 0 + List env = [] + List files = [] + Boolean artifacts = false + String outputDir = null + Boolean sessionList = false + String sessionShell = null + String sessionKill = null + String sessionSnapshot = null + String sessionRestore = null + String sessionFrom = null + String sessionSnapshotName = null + Boolean sessionHot = false + Boolean serviceList = false + String serviceName = null + String servicePorts = null + String serviceType = null + String serviceBootstrap = null + String serviceBootstrapFile = null + String serviceInfo = null + String serviceLogs = null + String serviceTail = null + String serviceSleep = null + String serviceWake = null + String serviceDestroy = null + String serviceExecute = null + String serviceCommand = null + String serviceDumpBootstrap = null + String serviceDumpFile = null + String serviceResize = null + String serviceSetUnfreezeOnDemand = null + String serviceUnfreezeOnDemandValue = null + String serviceSnapshot = null + String serviceRestore = null + String serviceFrom = null + String serviceSnapshotName = null + Boolean serviceHot = false + Boolean snapshotList = false + String snapshotInfo = null + String snapshotDelete = null + String snapshotClone = null + String snapshotType = null + String snapshotName = null + String snapshotShell = null + String snapshotPorts = null + Boolean keyExtend = false + Boolean imageList = false + String imageInfo = null + String imageDelete = null + String imageLock = null + String imageUnlock = null + String imagePublish = null + String imageSourceType = null + String imageVisibility = null + String imageVisibilityMode = null + String imageSpawn = null + String imageClone = null + String imageName = null + String imagePorts = null + List svcEnvs = [] + String svcEnvFile = null + String envAction = null + String envTarget = null + Boolean jsonOutput = false +} + +def readEnvFile(filename) { + def file = new File(filename) + if (!file.exists()) { + System.err.println("${RED}Error: Cannot read env file: ${filename}${RESET}") + return '' + } + return file.text +} + +def buildEnvContent(envs, envFile) { + def result = new StringBuilder() + + envs.each { env -> + result.append(env).append('\n') + } + + if (envFile) { + def content = readEnvFile(envFile) + content.split('\n').each { line -> + def trimmed = line.trim() + if (trimmed && !trimmed.startsWith('#')) { + result.append(trimmed).append('\n') + } + } + } + + return result.toString() +} + +def serviceEnvSet(serviceId, content, publicKey, secretKey) { + return apiRequestText("/services/${serviceId}/env", 'PUT', content, publicKey, secretKey) +} + +def cmdServiceEnv(args) { + def (publicKey, secretKey) = getApiKeys(args.apiKey, args.accountIndex ?: -1) + + switch (args.envAction) { + case 'status': + def output = apiRequest("/services/${args.envTarget}/env", 'GET', null, publicKey, secretKey) + println(JsonOutput.prettyPrint(JsonOutput.toJson(output))) + break + case 'set': + if (!args.svcEnvs && !args.svcEnvFile) { + System.err.println("${RED}Error: No environment variables specified. Use -e KEY=VALUE or --env-file FILE${RESET}") + return + } + def content = buildEnvContent(args.svcEnvs, args.svcEnvFile) + if (content.length() > MAX_ENV_CONTENT_SIZE) { + System.err.println("${RED}Error: Environment content exceeds 64KB limit${RESET}") + return + } + if (serviceEnvSet(args.envTarget, content, publicKey, secretKey)) { + println("${GREEN}Vault updated for service ${args.envTarget}${RESET}") + } + break + case 'export': + def output = apiRequest("/services/${args.envTarget}/env/export", 'POST', null, publicKey, secretKey) + println(JsonOutput.prettyPrint(JsonOutput.toJson(output))) + break + case 'delete': + apiRequest("/services/${args.envTarget}/env", 'DELETE', null, publicKey, secretKey) + println("${GREEN}Vault deleted for service ${args.envTarget}${RESET}") + break + default: + System.err.println("${RED}Error: Unknown env action: ${args.envAction}${RESET}") + System.err.println("Usage: un service env ") + } +} + +def cmdExecute(args) { + def (publicKey, secretKey) = getApiKeys(args.apiKey, args.accountIndex ?: -1) + + String code + String language + + if (args.inlineLang) { + language = args.inlineLang + code = args.sourceFile ?: "" + } else { + def file = new File(args.sourceFile) + if (!file.exists()) { + System.err.println("${RED}Error: File not found: ${args.sourceFile}${RESET}") + System.exit(1) + } + code = file.text + language = detectLanguage(args.sourceFile) + if (!language) { + System.err.println("${RED}Error: Cannot detect language for ${args.sourceFile}${RESET}") + System.exit(1) + } + } + + def options = [ + networkMode: args.network ?: 'zerotrust', + vcpu: args.vcpu > 0 ? args.vcpu : 1, + publicKey: publicKey, + secretKey: secretKey + ] + + if (args.env) { + def envMap = [:] + args.env.each { e -> + def parts = e.split('=', 2) + if (parts.size() == 2) { + envMap[parts[0]] = parts[1] + } + } + if (envMap) options.env = envMap + } + + if (args.files) { + options.inputFiles = args.files.collect { filepath -> + def f = new File(filepath) + if (!f.exists()) { + System.err.println("${RED}Error: Input file not found: ${filepath}${RESET}") + System.exit(1) + } + return [filename: f.name, contentBase64: f.bytes.encodeBase64().toString()] + } + } + + if (args.artifacts) { + options.returnArtifact = true + } + + def result = execute(language, code, options) + + if (result.stdout) { + print("${BLUE}${result.stdout}${RESET}") + } + if (result.stderr) { + System.err.print("${RED}${result.stderr}${RESET}") + } + + if (args.artifacts && result.artifacts) { + def outDir = args.outputDir ?: '.' + new File(outDir).mkdirs() + result.artifacts.each { artifact -> + def filename = artifact.filename ?: 'artifact' + def content = artifact.content_base64.decodeBase64() + def filepath = new File(outDir, filename) + filepath.bytes = content + "chmod 755 ${filepath.absolutePath}".execute().waitFor() + System.err.println("${GREEN}Saved: ${filepath.absolutePath}${RESET}") + } + } + + System.exit(result.exit_code ?: 0) +} + +def cmdSession(args) { + def (publicKey, secretKey) = getApiKeys(args.apiKey, args.accountIndex ?: -1) + + if (args.sessionSnapshot) { + def payload = [:] + if (args.sessionSnapshotName) payload.name = args.sessionSnapshotName + if (args.sessionHot) payload.hot = true + def output = apiRequest("/sessions/${args.sessionSnapshot}/snapshot", 'POST', payload, publicKey, secretKey) + println("${GREEN}Snapshot created${RESET}") + println(JsonOutput.prettyPrint(JsonOutput.toJson(output))) + return + } + + if (args.sessionRestore) { + def output = apiRequest("/snapshots/${args.sessionRestore}/restore", 'POST', [:], publicKey, secretKey) + println("${GREEN}Session restored from snapshot${RESET}") + println(JsonOutput.prettyPrint(JsonOutput.toJson(output))) + return + } + + if (args.sessionList) { + def output = apiRequest('/sessions', 'GET', null, publicKey, secretKey) + def sessions = output.sessions ?: [] + if (sessions.isEmpty()) { + println("No active sessions") + } else { + println(String.format("%-40s %-10s %-10s %s", "ID", "Shell", "Status", "Created")) + sessions.each { s -> + println(String.format("%-40s %-10s %-10s %s", + s.id ?: '', s.shell ?: '', s.status ?: '', s.created_at ?: '')) + } + } + return + } + + if (args.sessionKill) { + apiRequest("/sessions/${args.sessionKill}", 'DELETE', null, publicKey, secretKey) + println("${GREEN}Session terminated: ${args.sessionKill}${RESET}") + return + } + + def payload = [shell: args.sessionShell ?: 'bash'] + if (args.network) payload.network = args.network + if (args.vcpu > 0) payload.vcpu = args.vcpu + + if (args.files) { + payload.input_files = args.files.collect { filepath -> + def f = new File(filepath) + if (!f.exists()) { + System.err.println("${RED}Error: Input file not found: ${filepath}${RESET}") + System.exit(1) + } + return [filename: f.name, content_base64: f.bytes.encodeBase64().toString()] + } + } + + println("${YELLOW}Creating session...${RESET}") + def output = apiRequest('/sessions', 'POST', payload, publicKey, secretKey) + println("${GREEN}Session created: ${output.id ?: 'unknown'}${RESET}") + println("${YELLOW}(Interactive sessions require WebSocket - use un2 for full support)${RESET}") +} + +def openBrowser(url) { + def osName = System.getProperty('os.name').toLowerCase() + try { + if (osName.contains('linux')) { + Runtime.runtime.exec(['xdg-open', url] as String[]) + } else if (osName.contains('mac')) { + Runtime.runtime.exec(['open', url] as String[]) + } else if (osName.contains('win')) { + Runtime.runtime.exec(['cmd', '/c', 'start', url] as String[]) + } + } catch (Exception e) { + System.err.println("${RED}Error opening browser: ${e.message}${RESET}") + } +} + +def cmdSnapshot(args) { + def (publicKey, secretKey) = getApiKeys(args.apiKey, args.accountIndex ?: -1) + + if (args.snapshotList) { + def output = apiRequest('/snapshots', 'GET', null, publicKey, secretKey) + println(JsonOutput.prettyPrint(JsonOutput.toJson(output))) + return + } + + if (args.snapshotInfo) { + def output = apiRequest("/snapshots/${args.snapshotInfo}", 'GET', null, publicKey, secretKey) + println(JsonOutput.prettyPrint(JsonOutput.toJson(output))) + return + } + + if (args.snapshotDelete) { + executeDestructive("/snapshots/${args.snapshotDelete}", 'DELETE', null, publicKey, secretKey) + println("${GREEN}Snapshot deleted: ${args.snapshotDelete}${RESET}") + return + } + + if (args.snapshotClone) { + if (!args.snapshotType) { + System.err.println("${RED}Error: --type required (session or service)${RESET}") + System.exit(1) + } + def payload = [type: args.snapshotType] + if (args.snapshotName) payload.name = args.snapshotName + if (args.snapshotShell) payload.shell = args.snapshotShell + if (args.snapshotPorts) payload.ports = args.snapshotPorts.split(',').collect { it.trim().toInteger() } + def output = apiRequest("/snapshots/${args.snapshotClone}/clone", 'POST', payload, publicKey, secretKey) + println("${GREEN}Created from snapshot${RESET}") + println(JsonOutput.prettyPrint(JsonOutput.toJson(output))) + return + } + + System.err.println("Error: Use --list, --info ID, --delete ID, or --clone ID --type TYPE") + System.exit(1) +} + +def cmdImage(args) { + def (publicKey, secretKey) = getApiKeys(args.apiKey, args.accountIndex ?: -1) + + if (args.imageList) { + def output = apiRequest('/images', 'GET', null, publicKey, secretKey) + println(JsonOutput.prettyPrint(JsonOutput.toJson(output))) + return + } + + if (args.imageInfo) { + def output = apiRequest("/images/${args.imageInfo}", 'GET', null, publicKey, secretKey) + println(JsonOutput.prettyPrint(JsonOutput.toJson(output))) + return + } + + if (args.imageDelete) { + executeDestructive("/images/${args.imageDelete}", 'DELETE', null, publicKey, secretKey) + println("${GREEN}Image deleted: ${args.imageDelete}${RESET}") + return + } + + if (args.imageLock) { + apiRequest("/images/${args.imageLock}/lock", 'POST', null, publicKey, secretKey) + println("${GREEN}Image locked: ${args.imageLock}${RESET}") + return + } + + if (args.imageUnlock) { + executeDestructive("/images/${args.imageUnlock}/unlock", 'POST', null, publicKey, secretKey) + println("${GREEN}Image unlocked: ${args.imageUnlock}${RESET}") + return + } + + if (args.imagePublish) { + if (!args.imageSourceType) { + System.err.println("${RED}Error: --source-type required (service or snapshot)${RESET}") + System.exit(1) + } + def payload = [source_type: args.imageSourceType, source_id: args.imagePublish] + if (args.imageName) payload.name = args.imageName + def output = apiRequest("/images/publish", 'POST', payload, publicKey, secretKey) + println("${GREEN}Image published${RESET}") + println(JsonOutput.prettyPrint(JsonOutput.toJson(output))) + return + } + + if (args.imageVisibility) { + if (!args.imageVisibilityMode) { + System.err.println("${RED}Error: --visibility requires MODE (private, unlisted, or public)${RESET}") + System.exit(1) + } + def payload = [visibility: args.imageVisibilityMode] + apiRequest("/images/${args.imageVisibility}/visibility", 'POST', payload, publicKey, secretKey) + println("${GREEN}Image visibility set to ${args.imageVisibilityMode}: ${args.imageVisibility}${RESET}") + return + } + + if (args.imageSpawn) { + def payload = [:] + if (args.imageName) payload.name = args.imageName + if (args.imagePorts) payload.ports = args.imagePorts.split(',').collect { it.trim().toInteger() } + def output = apiRequest("/images/${args.imageSpawn}/spawn", 'POST', payload, publicKey, secretKey) + println("${GREEN}Service spawned from image${RESET}") + println(JsonOutput.prettyPrint(JsonOutput.toJson(output))) + return + } + + if (args.imageClone) { + def payload = [:] + if (args.imageName) payload.name = args.imageName + def output = apiRequest("/images/${args.imageClone}/clone", 'POST', payload, publicKey, secretKey) + println("${GREEN}Image cloned${RESET}") + println(JsonOutput.prettyPrint(JsonOutput.toJson(output))) + return + } + + System.err.println("${RED}Error: Use --list, --info ID, --delete ID, --lock ID, --unlock ID, --publish ID, --visibility ID MODE, --spawn ID, or --clone ID${RESET}") + System.exit(1) +} + +def cmdLanguages(args) { + def (publicKey, secretKey) = getApiKeys(args.apiKey, args.accountIndex ?: -1) + + def result = languages([publicKey: publicKey, secretKey: secretKey, forceRefresh: true]) + def langList = result.languages ?: [] + + if (args.jsonOutput) { + println(JsonOutput.toJson(langList)) + } else { + langList.each { lang -> + println(lang) + } + } +} + +def cmdKey(args) { + def (publicKey, secretKey) = getApiKeys(args.apiKey, args.accountIndex ?: -1) + + def curlCmd = ['curl', '-s', '-X', 'POST', "${PORTAL_BASE}/keys/validate", + '-H', 'Content-Type: application/json'] + + if (secretKey) { + def timestamp = (System.currentTimeMillis() / 1000) as long + def message = "${timestamp}:POST:/keys/validate:{}" + + def mac = Mac.getInstance("HmacSHA256") + mac.init(new SecretKeySpec(secretKey.getBytes("UTF-8"), "HmacSHA256")) + def signature = mac.doFinal(message.getBytes("UTF-8")).encodeHex().toString() + + curlCmd += ['-H', "Authorization: Bearer ${publicKey}"] + curlCmd += ['-H', "X-Timestamp: ${timestamp}"] + curlCmd += ['-H', "X-Signature: ${signature}"] + } else { + curlCmd += ['-H', "Authorization: Bearer ${publicKey}"] + } + + curlCmd += ['-d', '{}'] + + def proc = curlCmd.execute() + def output = proc.text + proc.waitFor() + + if (proc.exitValue() != 0) { + println("${RED}Invalid${RESET}") + System.err.println("${RED}Error: Failed to validate key${RESET}") + System.exit(1) + } + + def result = new JsonSlurper().parseText(output) + + def fetchedPublicKey = result.public_key ?: 'N/A' + def tier = result.tier ?: 'N/A' + def status = result.status ?: 'N/A' + def expiresAt = result.expires_at ?: 'N/A' + def timeRemaining = result.time_remaining ?: 'N/A' + def rateLimit = result.rate_limit ?: 'N/A' + def burst = result.burst ?: 'N/A' + def concurrency = result.concurrency ?: 'N/A' + def expired = result.expired ?: false + + if (args.keyExtend && fetchedPublicKey != 'N/A') { + def extendUrl = "${PORTAL_BASE}/keys/extend?pk=${fetchedPublicKey}" + println("${BLUE}Opening browser to extend key...${RESET}") + openBrowser(extendUrl) + return + } + + if (expired) { + println("${RED}Expired${RESET}") + println("Public Key: ${fetchedPublicKey}") + println("Tier: ${tier}") + println("Expired: ${expiresAt}") + println("${YELLOW}To renew: Visit https://unsandbox.com/keys/extend${RESET}") + System.exit(1) + } + + println("${GREEN}Valid${RESET}") + println("Public Key: ${fetchedPublicKey}") + println("Tier: ${tier}") + println("Status: ${status}") + println("Expires: ${expiresAt}") + println("Time Remaining: ${timeRemaining}") + println("Rate Limit: ${rateLimit}") + println("Burst: ${burst}") + println("Concurrency: ${concurrency}") +} + +def cmdService(args) { + def (publicKey, secretKey) = getApiKeys(args.apiKey, args.accountIndex ?: -1) + + if (args.serviceSnapshot) { + def payload = [:] + if (args.serviceSnapshotName) payload.name = args.serviceSnapshotName + if (args.serviceHot) payload.hot = true + def output = apiRequest("/services/${args.serviceSnapshot}/snapshot", 'POST', payload, publicKey, secretKey) + println("${GREEN}Snapshot created${RESET}") + println(JsonOutput.prettyPrint(JsonOutput.toJson(output))) + return + } + + if (args.serviceRestore) { + def output = apiRequest("/snapshots/${args.serviceRestore}/restore", 'POST', [:], publicKey, secretKey) + println("${GREEN}Service restored from snapshot${RESET}") + println(JsonOutput.prettyPrint(JsonOutput.toJson(output))) + return + } + + if (args.serviceList) { + def output = apiRequest('/services', 'GET', null, publicKey, secretKey) + def services = output.services ?: [] + if (services.isEmpty()) { + println("No services") + } else { + println(String.format("%-20s %-15s %-10s %-15s %s", "ID", "Name", "Status", "Ports", "Domains")) + services.each { s -> + def ports = (s.ports ?: []).join(',') + def domains = (s.domains ?: []).join(',') + println(String.format("%-20s %-15s %-10s %-15s %s", + s.id ?: '', s.name ?: '', s.status ?: '', ports, domains)) + } + } + return + } + + if (args.serviceInfo) { + def output = apiRequest("/services/${args.serviceInfo}", 'GET', null, publicKey, secretKey) + println(JsonOutput.prettyPrint(JsonOutput.toJson(output))) + return + } + + if (args.serviceLogs) { + def output = apiRequest("/services/${args.serviceLogs}/logs", 'GET', null, publicKey, secretKey) + println(output.logs ?: '') + return + } + + if (args.serviceTail) { + def output = apiRequest("/services/${args.serviceTail}/logs?lines=9000", 'GET', null, publicKey, secretKey) + println(output.logs ?: '') + return + } + + if (args.serviceSleep) { + apiRequest("/services/${args.serviceSleep}/freeze", 'POST', null, publicKey, secretKey) + println("${GREEN}Service frozen: ${args.serviceSleep}${RESET}") + return + } + + if (args.serviceWake) { + apiRequest("/services/${args.serviceWake}/unfreeze", 'POST', null, publicKey, secretKey) + println("${GREEN}Service unfreezing: ${args.serviceWake}${RESET}") + return + } + + if (args.serviceDestroy) { + executeDestructive("/services/${args.serviceDestroy}", 'DELETE', null, publicKey, secretKey) + println("${GREEN}Service destroyed: ${args.serviceDestroy}${RESET}") + return + } + + if (args.serviceResize) { + if (args.vcpu <= 0) { + System.err.println("${RED}Error: --resize requires --vcpu N (1-8)${RESET}") + System.exit(1) + } + apiRequestPatch("/services/${args.serviceResize}", [vcpu: args.vcpu], publicKey, secretKey) + def ram = args.vcpu * 2 + println("${GREEN}Service resized to ${args.vcpu} vCPU, ${ram} GB RAM${RESET}") + return + } + + if (args.serviceSetUnfreezeOnDemand) { + def enabledStr = (args.serviceUnfreezeOnDemandValue ?: 'false').toLowerCase() + def enabled = enabledStr in ['true', '1', 'yes', 'on'] + apiRequestPatch("/services/${args.serviceSetUnfreezeOnDemand}", [unfreeze_on_demand: enabled], publicKey, secretKey) + println("${GREEN}Service unfreeze_on_demand set to ${enabled}: ${args.serviceSetUnfreezeOnDemand}${RESET}") + return + } + + if (args.serviceExecute) { + def output = apiRequest("/services/${args.serviceExecute}/execute", 'POST', + [command: args.serviceCommand], publicKey, secretKey) + if (output.stdout) print("${BLUE}${output.stdout}${RESET}") + if (output.stderr) System.err.print("${RED}${output.stderr}${RESET}") + return + } + + if (args.serviceDumpBootstrap) { + System.err.println("Fetching bootstrap script from ${args.serviceDumpBootstrap}...") + def output = apiRequest("/services/${args.serviceDumpBootstrap}/execute", 'POST', + [command: 'cat /tmp/bootstrap.sh'], publicKey, secretKey) + + if (output.stdout) { + if (args.serviceDumpFile) { + try { + new File(args.serviceDumpFile).text = output.stdout + "chmod 755 ${args.serviceDumpFile}".execute().waitFor() + println("Bootstrap saved to ${args.serviceDumpFile}") + } catch (Exception e) { + System.err.println("${RED}Error: Could not write to ${args.serviceDumpFile}: ${e.message}${RESET}") + System.exit(1) + } + } else { + print(output.stdout) + } + } else { + System.err.println("${RED}Error: Failed to fetch bootstrap (service not running or no bootstrap file)${RESET}") + System.exit(1) + } + return + } + + if (args.serviceName) { + def payload = [name: args.serviceName] + + if (args.servicePorts) { + payload.ports = args.servicePorts.split(',').collect { it.trim().toInteger() } + } + if (args.serviceType) payload.service_type = args.serviceType + if (args.serviceBootstrap) payload.bootstrap = args.serviceBootstrap + if (args.serviceBootstrapFile) { + def file = new File(args.serviceBootstrapFile) + if (file.exists()) { + payload.bootstrap_content = file.text + } else { + System.err.println("${RED}Error: Bootstrap file not found: ${args.serviceBootstrapFile}${RESET}") + System.exit(1) + } + } + if (args.network) payload.network = args.network + if (args.vcpu > 0) payload.vcpu = args.vcpu + + if (args.files) { + payload.input_files = args.files.collect { filepath -> + def f = new File(filepath) + if (!f.exists()) { + System.err.println("${RED}Error: Input file not found: ${filepath}${RESET}") + System.exit(1) + } + return [filename: f.name, content_base64: f.bytes.encodeBase64().toString()] + } + } + + def output = apiRequest('/services', 'POST', payload, publicKey, secretKey) + def serviceId = output.id + println("${GREEN}Service created: ${serviceId ?: 'unknown'}${RESET}") + println("Name: ${output.name ?: ''}") + if (output.url) println("URL: ${output.url}") + + // Auto-set vault if -e or --env-file provided + if (serviceId && (args.svcEnvs || args.svcEnvFile)) { + def envContent = buildEnvContent(args.svcEnvs, args.svcEnvFile) + if (envContent) { + if (serviceEnvSet(serviceId, envContent, publicKey, secretKey)) { + println("${GREEN}Vault configured for service ${serviceId}${RESET}") + } + } + } + return + } + + System.err.println("${RED}Error: Specify --name to create a service, or use --list, --info, etc.${RESET}") + System.exit(1) +} + +def parseArgs(argv) { + def args = new Args() + def i = 0 + while (i < argv.size()) { + switch (argv[i]) { + case 'languages': + args.command = 'languages' + break + case 'session': + args.command = 'session' + break + case 'service': + args.command = 'service' + break + case 'env': + if (args.command == 'service' && i + 2 < argv.size()) { + args.envAction = argv[++i] + args.envTarget = argv[++i] + } + break + case 'snapshot': + args.command = 'snapshot' + break + case 'image': + args.command = 'image' + break + case 'key': + args.command = 'key' + break + case '-s': + args.inlineLang = argv[++i] + break + case '-k': + case '--api-key': + args.apiKey = argv[++i] + break + case '-p': + case '--public-key': + args.apiKey = argv[++i] // For compatibility + break + case '--account': + args.accountIndex = argv[++i].toInteger() + break + case '-n': + case '--network': + args.network = argv[++i] + break + case '-v': + case '--vcpu': + args.vcpu = argv[++i].toInteger() + break + case '-e': + case '--env': + def envVal = argv[++i] + args.env << envVal + if (args.command == 'service') { + args.svcEnvs << envVal + } + break + case '--env-file': + args.svcEnvFile = argv[++i] + break + case '-f': + case '--files': + args.files << argv[++i] + break + case '-a': + case '--artifacts': + args.artifacts = true + break + case '-o': + case '--output-dir': + args.outputDir = argv[++i] + break + case '-l': + case '--list': + if (args.command == 'session') args.sessionList = true + else if (args.command == 'service') args.serviceList = true + else if (args.command == 'snapshot') args.snapshotList = true + else if (args.command == 'image') args.imageList = true + break + case '--shell': + if (args.command == 'snapshot') args.snapshotShell = argv[++i] + else args.sessionShell = argv[++i] + break + case '--kill': + args.sessionKill = argv[++i] + break + case '--snapshot': + if (args.command == 'session') args.sessionSnapshot = argv[++i] + else if (args.command == 'service') args.serviceSnapshot = argv[++i] + break + case '--restore': + if (args.command == 'session') args.sessionRestore = argv[++i] + else if (args.command == 'service') args.serviceRestore = argv[++i] + break + case '--from': + if (args.command == 'session') args.sessionFrom = argv[++i] + else if (args.command == 'service') args.serviceFrom = argv[++i] + break + case '--snapshot-name': + if (args.command == 'session') args.sessionSnapshotName = argv[++i] + else if (args.command == 'service') args.serviceSnapshotName = argv[++i] + break + case '--hot': + if (args.command == 'session') args.sessionHot = true + else if (args.command == 'service') args.serviceHot = true + break + case '--info': + if (args.command == 'snapshot') args.snapshotInfo = argv[++i] + else if (args.command == 'image') args.imageInfo = argv[++i] + else args.serviceInfo = argv[++i] + break + case '--delete': + if (args.command == 'snapshot') args.snapshotDelete = argv[++i] + else if (args.command == 'image') args.imageDelete = argv[++i] + break + case '--clone': + if (args.command == 'image') args.imageClone = argv[++i] + else args.snapshotClone = argv[++i] + break + case '--lock': + if (args.command == 'image') args.imageLock = argv[++i] + break + case '--unlock': + if (args.command == 'image') args.imageUnlock = argv[++i] + break + case '--publish': + if (args.command == 'image') args.imagePublish = argv[++i] + break + case '--source-type': + args.imageSourceType = argv[++i] + break + case '--visibility': + if (args.command == 'image') { + args.imageVisibility = argv[++i] + if (i + 1 < argv.size() && !argv[i + 1].startsWith('-')) { + args.imageVisibilityMode = argv[++i] + } + } + break + case '--spawn': + if (args.command == 'image') args.imageSpawn = argv[++i] + break + case '--type': + if (args.command == 'snapshot') args.snapshotType = argv[++i] + else args.serviceType = argv[++i] + break + case '--name': + if (args.command == 'snapshot') args.snapshotName = argv[++i] + else if (args.command == 'image') args.imageName = argv[++i] + else args.serviceName = argv[++i] + break + case '--ports': + if (args.command == 'snapshot') args.snapshotPorts = argv[++i] + else if (args.command == 'image') args.imagePorts = argv[++i] + else args.servicePorts = argv[++i] + break + case '--bootstrap': + args.serviceBootstrap = argv[++i] + break + case '--bootstrap-file': + args.serviceBootstrapFile = argv[++i] + break + case '--logs': + args.serviceLogs = argv[++i] + break + case '--tail': + args.serviceTail = argv[++i] + break + case '--freeze': + args.serviceSleep = argv[++i] + break + case '--unfreeze': + args.serviceWake = argv[++i] + break + case '--destroy': + args.serviceDestroy = argv[++i] + break + case '--resize': + args.serviceResize = argv[++i] + break + case '--set-unfreeze-on-demand': + args.serviceSetUnfreezeOnDemand = argv[++i] + if (i + 1 < argv.size() && !argv[i + 1].startsWith('-')) { + args.serviceUnfreezeOnDemandValue = argv[++i] + } + break + case '--execute': + args.serviceExecute = argv[++i] + break + case '--command': + args.serviceCommand = argv[++i] + break + case '--dump-bootstrap': + args.serviceDumpBootstrap = argv[++i] + break + case '--dump-file': + args.serviceDumpFile = argv[++i] + break + case '--extend': + args.keyExtend = true + break + case '--json': + args.jsonOutput = true + break + default: + if (argv[i].startsWith('-')) { + System.err.println("${RED}Unknown option: ${argv[i]}${RESET}") + System.exit(1) + } else { + args.sourceFile = argv[i] + } + } + i++ + } + return args +} + +def printHelp() { + println '''unsandbox SDK for Groovy - Execute code in secure sandboxes +https://unsandbox.com | https://api.unsandbox.com/openapi + +Usage: groovy un.groovy [options] + groovy un.groovy -s '' + groovy un.groovy session [options] + groovy un.groovy service [options] + groovy un.groovy service env [options] + groovy un.groovy image [options] + groovy un.groovy languages [--json] + groovy un.groovy key [options] + +Execute options: + -s LANG Execute inline code with specified language + -e KEY=VALUE Set environment variable + -f FILE Add input file + -a Return artifacts + -o DIR Output directory for artifacts + -n MODE Network mode (zerotrust/semitrusted) + -v N vCPU count (1-8) + -k KEY API key (legacy) + -p KEY Public key + +Session options: + --list List active sessions + --shell NAME Shell/REPL to use + --kill ID Terminate session + --snapshot ID Create snapshot of session + --restore ID Restore session from snapshot + +Service options: + --list List services + --name NAME Service name (creates service) + --ports PORTS Comma-separated ports + --type TYPE Service type + --bootstrap CMD Bootstrap command + -e KEY=VALUE Set env var in vault (when creating) + --env-file FILE Load env vars from file + --info ID Get service details + --logs ID Get all logs + --tail ID Get last 9000 lines + --freeze ID Freeze service + --unfreeze ID Unfreeze service + --destroy ID Destroy service + --resize ID Resize service (requires --vcpu N) + --set-unfreeze-on-demand ID true|false + Set unfreeze_on_demand for service + --execute ID Execute command in service + --command CMD Command to execute (with --execute) + --dump-bootstrap ID Dump bootstrap script + --dump-file FILE File to save bootstrap + +Vault commands: + service env status Check vault status + service env set Set vault (-e KEY=VAL or --env-file FILE) + service env export Export vault contents + service env delete Delete vault + +Key options: + --extend Open browser to extend key + +Image options: + --list List images + --info ID Get image details + --delete ID Delete an image + --lock ID Lock image to prevent deletion + --unlock ID Unlock image + --publish ID Publish image from service/snapshot (requires --source-type) + --source-type TYPE Source type: service or snapshot + --visibility ID MODE Set visibility: private, unlisted, or public + --spawn ID Spawn new service from image + --clone ID Clone an image + --name NAME Name for spawned service or cloned image + --ports PORTS Ports for spawned service + +Languages options: + --json Output as JSON array + +Library Usage: + import un + def result = un.execute("python", 'print("Hello")') + def client = new un.Client(publicKey: "unsb-pk-...", secretKey: "unsb-sk-...") +''' +} + +// ============================================================================ +// Main Execution (CLI) +// ============================================================================ + +try { + def args = parseArgs(this.args as List) + + if (args.command == 'languages') { + cmdLanguages(args) + } else if (args.command == 'session') { + cmdSession(args) + } else if (args.command == 'service') { + if (args.envAction && args.envTarget) { + cmdServiceEnv(args) + } else { + cmdService(args) + } + } else if (args.command == 'snapshot') { + cmdSnapshot(args) + } else if (args.command == 'image') { + cmdImage(args) + } else if (args.command == 'key') { + cmdKey(args) + } else if (args.sourceFile || args.inlineLang) { + cmdExecute(args) + } else { + printHelp() + System.exit(1) + } +} catch (UnsandboxError e) { + System.err.println("${RED}Error: ${e.message}${RESET}") + System.exit(1) +} catch (Exception e) { + System.err.println("${RED}Error: ${e.message}${RESET}") + System.exit(1) +} diff --git a/un.jl b/un.jl deleted file mode 120000 index 2b79cde..0000000 --- a/un.jl +++ /dev/null @@ -1 +0,0 @@ -clients/julia/sync/src/un.jl \ No newline at end of file diff --git a/un.jl b/un.jl new file mode 100755 index 0000000..81e91b7 --- /dev/null +++ b/un.jl @@ -0,0 +1,2743 @@ +#!/usr/bin/env julia +# PUBLIC DOMAIN - NO LICENSE, NO WARRANTY +# +# This is free public domain software for the public good of a permacomputer hosted +# at permacomputer.com - an always-on computer by the people, for the people. One +# which is durable, easy to repair, and distributed like tap water for machine +# learning intelligence. +# +# The permacomputer is community-owned infrastructure optimized around four values: +# +# TRUTH - First principles, math & science, open source code freely distributed +# FREEDOM - Voluntary partnerships, freedom from tyranny & corporate control +# HARMONY - Minimal waste, self-renewing systems with diverse thriving connections +# LOVE - Be yourself without hurting others, cooperation through natural law +# +# This software contributes to that vision by enabling code execution across 42+ +# programming languages through a unified interface, accessible to all. Code is +# seeds to sprout on any abandoned technology. +# +# Learn more: https://www.permacomputer.com +# +# Anyone is free to copy, modify, publish, use, compile, sell, or distribute this +# software, either in source code form or as a compiled binary, for any purpose, +# commercial or non-commercial, and by any means. +# +# NO WARRANTY. THE SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND. +# +# That said, our permacomputer's digital membrane stratum continuously runs unit, +# integration, and functional tests on all of it's own software - with our +# permacomputer monitoring itself, repairing itself, with minimal human in the +# loop guidance. Our agents do their best. +# +# Copyright 2025 TimeHexOn & foxhop & russell@unturf +# https://www.timehexon.com +# https://www.foxhop.net +# https://www.unturf.com/software + + +#!/usr/bin/env julia + +using HTTP +using JSON +using Base64 +using ArgParse +using Printf +using SHA + +# Extension to language mapping +const EXT_MAP = Dict( + ".jl" => "julia", ".r" => "r", ".cr" => "crystal", + ".f90" => "fortran", ".cob" => "cobol", ".pro" => "prolog", + ".forth" => "forth", ".4th" => "forth", ".py" => "python", + ".js" => "javascript", ".ts" => "typescript", ".rb" => "ruby", + ".php" => "php", ".pl" => "perl", ".lua" => "lua", ".sh" => "bash", + ".go" => "go", ".rs" => "rust", ".c" => "c", ".cpp" => "cpp", + ".cc" => "cpp", ".cxx" => "cpp", ".java" => "java", ".kt" => "kotlin", + ".cs" => "csharp", ".fs" => "fsharp", ".hs" => "haskell", + ".ml" => "ocaml", ".clj" => "clojure", ".scm" => "scheme", + ".lisp" => "commonlisp", ".erl" => "erlang", ".ex" => "elixir", + ".exs" => "elixir", ".d" => "d", ".nim" => "nim", ".zig" => "zig", + ".v" => "v", ".dart" => "dart", ".groovy" => "groovy", + ".scala" => "scala", ".tcl" => "tcl", ".raku" => "raku", ".m" => "objc" +) + +# ANSI color codes +const BLUE = "\033[34m" +const RED = "\033[31m" +const GREEN = "\033[32m" +const YELLOW = "\033[33m" +const RESET = "\033[0m" + +const API_BASE = "https://api.unsandbox.com" +const PORTAL_BASE = "https://unsandbox.com" +const LANGUAGES_CACHE_TTL = 3600 + +function detect_language(filename::String)::String + ext = lowercase(match(r"\.[^.]+$", filename).match) + return get(EXT_MAP, ext, "unknown") +end + +function load_accounts_csv(path::String)::Vector{Tuple{String,String}} + accounts = Tuple{String,String}[] + isfile(path) || return accounts + try + for line in eachline(path) + trimmed = strip(line) + isempty(trimmed) && continue + startswith(trimmed, "#") && continue + parts = split(trimmed, ","; limit=2) + length(parts) >= 2 || continue + pk = strip(parts[1]) + sk = strip(parts[2]) + if startswith(pk, "unsb-pk-") && startswith(sk, "unsb-sk-") + push!(accounts, (pk, sk)) + end + end + catch + end + return accounts +end + +function get_credentials(; account_index::Int=-1)::Tuple{String,String} + # Priority 2: --account N => accounts.csv row N (bypasses env vars) + if account_index >= 0 + for path in [joinpath(homedir(), ".unsandbox", "accounts.csv"), "accounts.csv"] + accts = load_accounts_csv(path) + if account_index < length(accts) + return accts[account_index + 1] + end + end + println(stderr, "$(RED)Error: No account at index $account_index in accounts.csv$(RESET)") + exit(1) + end + + # Priority 3: Environment variables + public_key = get(ENV, "UNSANDBOX_PUBLIC_KEY", "") + secret_key = get(ENV, "UNSANDBOX_SECRET_KEY", "") + if !isempty(public_key) && !isempty(secret_key) + return (public_key, secret_key) + end + + # Priority 4: ~/.unsandbox/accounts.csv row 0 (or UNSANDBOX_ACCOUNT env) + default_idx = tryparse(Int, get(ENV, "UNSANDBOX_ACCOUNT", "0")) + default_idx = something(default_idx, 0) + for path in [joinpath(homedir(), ".unsandbox", "accounts.csv"), "accounts.csv"] + accts = load_accounts_csv(path) + if default_idx < length(accts) + return accts[default_idx + 1] + end + end + + # Legacy fallback + old_key = get(ENV, "UNSANDBOX_API_KEY", "") + if !isempty(old_key) + return (old_key, old_key) + end + + println(stderr, "$(RED)Error: UNSANDBOX_PUBLIC_KEY/UNSANDBOX_SECRET_KEY or UNSANDBOX_API_KEY not set$(RESET)") + exit(1) +end + +function get_api_keys(args_key=nothing; account_index::Int=-1)::Tuple{String,String} + # Priority 1: explicit -k flag + if args_key !== nothing && !isempty(string(args_key)) + public_key = string(args_key) + secret_key = get(ENV, "UNSANDBOX_SECRET_KEY", "") + if !isempty(secret_key) + return (public_key, secret_key) + end + end + return get_credentials(account_index=account_index) +end + +function hmac_sha256_hex(key::String, message::String)::String + h = hmac_sha256(Vector{UInt8}(key), Vector{UInt8}(message)) + return bytes2hex(h) +end + +function compute_signature(secret_key::String, timestamp::Int64, method::String, path::String, body::String)::String + message = "$(timestamp):$(method):$(path):$(body)" + return hmac_sha256_hex(secret_key, message) +end + +function api_request(endpoint::String, public_key::String, secret_key::String; method="GET", data=nothing, sudo_otp=nothing, sudo_challenge=nothing) + url = API_BASE * endpoint + + # Prepare body + body = data !== nothing ? JSON.json(data) : "" + + # Generate timestamp and signature + timestamp = Int64(floor(time())) + signature = compute_signature(secret_key, timestamp, method, endpoint, body) + + headers = [ + "Authorization" => "Bearer $public_key", + "X-Timestamp" => string(timestamp), + "X-Signature" => signature, + "Content-Type" => "application/json" + ] + + # Add sudo headers if provided + if sudo_otp !== nothing + push!(headers, "X-Sudo-OTP" => sudo_otp) + end + if sudo_challenge !== nothing + push!(headers, "X-Sudo-Challenge" => sudo_challenge) + end + + try + if method == "GET" + response = HTTP.get(url, headers, readtimeout=300) + elseif method == "POST" + response = HTTP.post(url, headers, body, readtimeout=300) + elseif method == "DELETE" + response = HTTP.delete(url, headers, readtimeout=300) + else + error("Unsupported method: $method") + end + + return JSON.parse(String(response.body)) + catch e + if isa(e, HTTP.ExceptionRequest.StatusError) + error_body = String(e.response.body) + if e.status == 401 && occursin("timestamp", lowercase(error_body)) + println(stderr, "$(RED)Error: Request timestamp expired (must be within 5 minutes of server time)$(RESET)") + println(stderr, "$(YELLOW)Your computer's clock may have drifted.$(RESET)") + println(stderr, "Check your system time and sync with NTP if needed:") + println(stderr, " Linux: sudo ntpdate -s time.nist.gov") + println(stderr, " macOS: sudo sntp -sS time.apple.com") + println(stderr, " Windows: w32tm /resync") + else + println(stderr, "$(RED)Error: HTTP $(e.status) - $(error_body)$(RESET)") + end + else + println(stderr, "$(RED)Error: Request failed: $e$(RESET)") + end + exit(1) + end +end + +# Handle 428 sudo OTP challenge - prompts user for OTP and retries the request +function handle_sudo_challenge(endpoint::String, public_key::String, secret_key::String, method::String, data, response_body::String) + # Extract challenge_id from response + parsed = JSON.parse(response_body) + challenge_id = get(parsed, "challenge_id", nothing) + + println(stderr, "$(YELLOW)Confirmation required. Check your email for a one-time code.$(RESET)") + print(stderr, "Enter OTP: ") + otp = readline() + + if isempty(strip(otp)) + println(stderr, "$(RED)Error: Operation cancelled$(RESET)") + exit(1) + end + + # Retry the request with sudo headers + return api_request(endpoint, public_key, secret_key, method=method, data=data, sudo_otp=strip(otp), sudo_challenge=challenge_id) +end + +# API request that handles 428 sudo challenges for destructive operations +function api_request_with_sudo(endpoint::String, public_key::String, secret_key::String; method="DELETE", data=nothing) + url = API_BASE * endpoint + + # Prepare body + body = data !== nothing ? JSON.json(data) : "" + + # Generate timestamp and signature + timestamp = Int64(floor(time())) + signature = compute_signature(secret_key, timestamp, method, endpoint, body) + + headers = [ + "Authorization" => "Bearer $public_key", + "X-Timestamp" => string(timestamp), + "X-Signature" => signature, + "Content-Type" => "application/json" + ] + + try + if method == "GET" + response = HTTP.get(url, headers, readtimeout=300, status_exception=false) + elseif method == "POST" + response = HTTP.post(url, headers, body, readtimeout=300, status_exception=false) + elseif method == "DELETE" + response = HTTP.delete(url, headers, readtimeout=300, status_exception=false) + else + error("Unsupported method: $method") + end + + response_body = String(response.body) + + # Handle 428 - sudo OTP required + if response.status == 428 + return handle_sudo_challenge(endpoint, public_key, secret_key, method, data, response_body) + end + + # Handle other errors + if response.status >= 400 + if response.status == 401 && occursin("timestamp", lowercase(response_body)) + println(stderr, "$(RED)Error: Request timestamp expired (must be within 5 minutes of server time)$(RESET)") + println(stderr, "$(YELLOW)Your computer's clock may have drifted.$(RESET)") + println(stderr, "Check your system time and sync with NTP if needed:") + println(stderr, " Linux: sudo ntpdate -s time.nist.gov") + println(stderr, " macOS: sudo sntp -sS time.apple.com") + println(stderr, " Windows: w32tm /resync") + else + println(stderr, "$(RED)Error: HTTP $(response.status) - $(response_body)$(RESET)") + end + exit(1) + end + + return JSON.parse(response_body) + catch e + println(stderr, "$(RED)Error: Request failed: $e$(RESET)") + exit(1) + end +end + +function api_request_patch(endpoint::String, public_key::String, secret_key::String; data=nothing) + url = API_BASE * endpoint + + # Prepare body + body = data !== nothing ? JSON.json(data) : "" + + # Generate timestamp and signature + timestamp = Int64(floor(time())) + signature = compute_signature(secret_key, timestamp, "PATCH", endpoint, body) + + headers = [ + "Authorization" => "Bearer $public_key", + "X-Timestamp" => string(timestamp), + "X-Signature" => signature, + "Content-Type" => "application/json" + ] + + try + response = HTTP.request("PATCH", url, headers, body, readtimeout=300) + return JSON.parse(String(response.body)) + catch e + if isa(e, HTTP.ExceptionRequest.StatusError) + error_body = String(e.response.body) + if e.status == 401 && occursin("timestamp", lowercase(error_body)) + println(stderr, "$(RED)Error: Request timestamp expired (must be within 5 minutes of server time)$(RESET)") + println(stderr, "$(YELLOW)Your computer's clock may have drifted.$(RESET)") + println(stderr, "Check your system time and sync with NTP if needed:") + println(stderr, " Linux: sudo ntpdate -s time.nist.gov") + println(stderr, " macOS: sudo sntp -sS time.apple.com") + println(stderr, " Windows: w32tm /resync") + else + println(stderr, "$(RED)Error: HTTP $(e.status) - $(error_body)$(RESET)") + end + else + println(stderr, "$(RED)Error: Request failed: $e$(RESET)") + end + exit(1) + end +end + +function api_request_text(endpoint::String, public_key::String, secret_key::String, body::String)::Bool + url = API_BASE * endpoint + timestamp = Int64(floor(time())) + signature = compute_signature(secret_key, timestamp, "PUT", endpoint, body) + + headers = [ + "Authorization" => "Bearer $public_key", + "X-Timestamp" => string(timestamp), + "X-Signature" => signature, + "Content-Type" => "text/plain" + ] + + try + response = HTTP.put(url, headers, body, readtimeout=300) + return response.status >= 200 && response.status < 300 + catch e + return false + end +end + +const MAX_ENV_CONTENT_SIZE = 65536 + +function read_env_file(path::String)::String + if !isfile(path) + println(stderr, "$(RED)Error: Env file not found: $path$(RESET)") + exit(1) + end + return read(path, String) +end + +function build_env_content(envs::Vector{String}, env_file::Union{String,Nothing})::String + lines = copy(envs) + if env_file !== nothing + content = read_env_file(env_file) + for line in split(content, '\n') + trimmed = strip(line) + if !isempty(trimmed) && !startswith(trimmed, "#") + push!(lines, trimmed) + end + end + end + return join(lines, "\n") +end + +function service_env_status(service_id::String, public_key::String, secret_key::String) + return api_request("/services/$service_id/env", public_key, secret_key) +end + +function service_env_set(service_id::String, env_content::String, public_key::String, secret_key::String)::Bool + if length(env_content) > MAX_ENV_CONTENT_SIZE + println(stderr, "$(RED)Error: Env content exceeds maximum size of 64KB$(RESET)") + return false + end + return api_request_text("/services/$service_id/env", public_key, secret_key, env_content) +end + +function service_env_export(service_id::String, public_key::String, secret_key::String) + return api_request("/services/$service_id/env/export", public_key, secret_key, method="POST", data=Dict()) +end + +function service_env_delete(service_id::String, public_key::String, secret_key::String)::Bool + try + api_request("/services/$service_id/env", public_key, secret_key, method="DELETE") + return true + catch + return false + end +end + +function cmd_service_env(args) + (public_key, secret_key) = get_api_keys(args["api-key"]; account_index=something(get(args, "account", nothing), -1)) + + action = get(args, "env-action", nothing) + target = get(args, "env-target", nothing) + + if action == "status" + if target === nothing + println(stderr, "$(RED)Error: service env status requires service ID$(RESET)") + exit(1) + end + result = service_env_status(target, public_key, secret_key) + has_vault = get(result, "has_vault", false) + if has_vault + println("$(GREEN)Vault: configured$(RESET)") + env_count = get(result, "env_count", nothing) + if env_count !== nothing + println("Variables: $env_count") + end + updated_at = get(result, "updated_at", nothing) + if updated_at !== nothing + println("Updated: $updated_at") + end + else + println("$(YELLOW)Vault: not configured$(RESET)") + end + elseif action == "set" + if target === nothing + println(stderr, "$(RED)Error: service env set requires service ID$(RESET)") + exit(1) + end + envs = something(args["vault-env"], String[]) + env_file = get(args, "env-file", nothing) + if isempty(envs) && env_file === nothing + println(stderr, "$(RED)Error: service env set requires -e or --env-file$(RESET)") + exit(1) + end + env_content = build_env_content(envs, env_file) + if service_env_set(target, env_content, public_key, secret_key) + println("$(GREEN)Vault updated for service $target$(RESET)") + else + println(stderr, "$(RED)Error: Failed to update vault$(RESET)") + exit(1) + end + elseif action == "export" + if target === nothing + println(stderr, "$(RED)Error: service env export requires service ID$(RESET)") + exit(1) + end + result = service_env_export(target, public_key, secret_key) + content = get(result, "content", nothing) + if content !== nothing + print(content) + end + elseif action == "delete" + if target === nothing + println(stderr, "$(RED)Error: service env delete requires service ID$(RESET)") + exit(1) + end + if service_env_delete(target, public_key, secret_key) + println("$(GREEN)Vault deleted for service $target$(RESET)") + else + println(stderr, "$(RED)Error: Failed to delete vault$(RESET)") + exit(1) + end + else + println(stderr, "$(RED)Error: Unknown env action: $action$(RESET)") + println(stderr, "Usage: un.jl service env ") + exit(1) + end +end + +function cmd_execute(args) + (public_key, secret_key) = get_api_keys(args["api-key"]; account_index=something(get(args, "account", nothing), -1)) + + filename = args["source_file"] + if !isfile(filename) + println(stderr, "$(RED)Error: File not found: $filename$(RESET)") + exit(1) + end + + language = detect_language(filename) + if language == "unknown" + println(stderr, "$(RED)Error: Cannot detect language for $filename$(RESET)") + exit(1) + end + + code = read(filename, String) + + # Build request payload + payload = Dict("language" => language, "code" => code) + + # Add environment variables + if args["env"] !== nothing + env_vars = Dict{String,String}() + for e in args["env"] + if occursin('=', e) + k, v = split(e, '=', limit=2) + env_vars[k] = v + end + end + if !isempty(env_vars) + payload["env"] = env_vars + end + end + + # Add input files + if args["files"] !== nothing + input_files = [] + for filepath in args["files"] + if !isfile(filepath) + println(stderr, "$(RED)Error: Input file not found: $filepath$(RESET)") + exit(1) + end + content = base64encode(read(filepath)) + push!(input_files, Dict( + "filename" => basename(filepath), + "content_base64" => content + )) + end + if !isempty(input_files) + payload["input_files"] = input_files + end + end + + # Add options + if args["artifacts"] + payload["return_artifacts"] = true + end + if args["network"] !== nothing + payload["network"] = args["network"] + end + + # Execute + result = api_request("/execute", public_key, secret_key, method="POST", data=payload) + + # Print output + if haskey(result, "stdout") && !isempty(result["stdout"]) + print(BLUE, result["stdout"], RESET) + end + if haskey(result, "stderr") && !isempty(result["stderr"]) + print(RED, result["stderr"], RESET) + end + + # Save artifacts + if args["artifacts"] && haskey(result, "artifacts") + out_dir = something(args["output-dir"], ".") + mkpath(out_dir) + for artifact in result["artifacts"] + filename = get(artifact, "filename", "artifact") + content = base64decode(artifact["content_base64"]) + path = joinpath(out_dir, filename) + write(path, content) + chmod(path, 0o755) + println(stderr, "$(GREEN)Saved: $path$(RESET)") + end + end + + exit_code = get(result, "exit_code", 0) + exit(exit_code) +end + +function cmd_session(args) + (public_key, secret_key) = get_api_keys(args["api-key"]; account_index=something(get(args, "account", nothing), -1)) + + if args["list"] + result = api_request("/sessions", public_key, secret_key) + sessions = get(result, "sessions", []) + if isempty(sessions) + println("No active sessions") + else + @printf("%-40s %-10s %-10s %s\n", "ID", "Shell", "Status", "Created") + for s in sessions + @printf("%-40s %-10s %-10s %s\n", + get(s, "id", "N/A"), + get(s, "shell", "N/A"), + get(s, "status", "N/A"), + get(s, "created_at", "N/A")) + end + end + return + end + + if args["kill"] !== nothing + api_request("/sessions/$(args["kill"])", public_key, secret_key, method="DELETE") + println("$(GREEN)Session terminated: $(args["kill"])$(RESET)") + return + end + + # Create new session + payload = Dict("shell" => "bash") + + if args["network"] !== nothing + payload["network"] = args["network"] + end + + # Add input files + if args["files"] !== nothing + input_files = [] + for filepath in args["files"] + if !isfile(filepath) + println(stderr, "$(RED)Error: Input file not found: $filepath$(RESET)") + exit(1) + end + content = base64encode(read(filepath)) + push!(input_files, Dict( + "filename" => basename(filepath), + "content_base64" => content + )) + end + if !isempty(input_files) + payload["input_files"] = input_files + end + end + + println("$(YELLOW)Creating session...$(RESET)") + result = api_request("/sessions", public_key, secret_key, method="POST", data=payload) + println("$(GREEN)Session created: $(get(result, "id", "N/A"))$(RESET)") + println("$(YELLOW)(Interactive sessions require WebSocket - use un2 for full support)$(RESET)") +end + +function cmd_service(args) + (public_key, secret_key) = get_api_keys(args["api-key"]; account_index=something(get(args, "account", nothing), -1)) + + # Handle env subcommand + if get(args, "env-action", nothing) !== nothing + cmd_service_env(args) + return + end + + if args["list"] + result = api_request("/services", public_key, secret_key) + services = get(result, "services", []) + if isempty(services) + println("No services") + else + @printf("%-20s %-15s %-10s %-15s %s\n", "ID", "Name", "Status", "Ports", "Domains") + for s in services + ports = join(get(s, "ports", []), ',') + domains = join(get(s, "domains", []), ',') + @printf("%-20s %-15s %-10s %-15s %s\n", + get(s, "id", "N/A"), + get(s, "name", "N/A"), + get(s, "status", "N/A"), + ports, domains) + end + end + return + end + + if args["info"] !== nothing + result = api_request("/services/$(args["info"])", public_key, secret_key) + println(JSON.json(result, 2)) + return + end + + if args["logs"] !== nothing + result = api_request("/services/$(args["logs"])/logs", public_key, secret_key) + println(get(result, "logs", "")) + return + end + + if args["sleep"] !== nothing + api_request("/services/$(args["sleep"])/freeze", public_key, secret_key, method="POST") + println("$(GREEN)Service frozen: $(args["sleep"])$(RESET)") + return + end + + if args["wake"] !== nothing + api_request("/services/$(args["wake"])/unfreeze", public_key, secret_key, method="POST") + println("$(GREEN)Service unfreezing: $(args["wake"])$(RESET)") + return + end + + if args["destroy"] !== nothing + api_request_with_sudo("/services/$(args["destroy"])", public_key, secret_key, method="DELETE") + println("$(GREEN)Service destroyed: $(args["destroy"])$(RESET)") + return + end + + if args["resize"] !== nothing + vcpu = args["vcpu"] + if vcpu === nothing || vcpu <= 0 + println(stderr, "$(RED)Error: --resize requires --vcpu N (1-8)$(RESET)") + exit(1) + end + api_request_patch("/services/$(args["resize"])", public_key, secret_key, data=Dict("vcpu" => vcpu)) + ram = vcpu * 2 + println("$(GREEN)Service resized to $(vcpu) vCPU, $(ram) GB RAM$(RESET)") + return + end + + if args["unfreeze-on-demand"] !== nothing + enabled = args["unfreeze-on-demand-value"] + if enabled === nothing + println(stderr, "$(RED)Error: --unfreeze-on-demand requires true or false$(RESET)") + exit(1) + end + api_request_patch("/services/$(args["unfreeze-on-demand"])", public_key, secret_key, data=Dict("unfreeze_on_demand" => enabled)) + println("$(GREEN)Service unfreeze_on_demand set to $(enabled): $(args["unfreeze-on-demand"])$(RESET)") + return + end + + if args["dump-bootstrap"] !== nothing + println(stderr, "Fetching bootstrap script from $(args["dump-bootstrap"])...") + payload = Dict("command" => "cat /tmp/bootstrap.sh") + result = api_request("/services/$(args["dump-bootstrap"])/execute", public_key, secret_key, method="POST", data=payload) + + if haskey(result, "stdout") && !isempty(result["stdout"]) + bootstrap = result["stdout"] + if args["dump-file"] !== nothing + # Write to file + try + write(args["dump-file"], bootstrap) + chmod(args["dump-file"], 0o755) + println("Bootstrap saved to $(args["dump-file"])") + catch e + println(stderr, "$(RED)Error: Could not write to $(args["dump-file"]): $e$(RESET)") + exit(1) + end + else + # Print to stdout + print(bootstrap) + end + else + println(stderr, "$(RED)Error: Failed to fetch bootstrap (service not running or no bootstrap file)$(RESET)") + exit(1) + end + return + end + + # Create new service + if args["name"] !== nothing + payload = Dict("name" => args["name"]) + + if args["ports"] !== nothing + ports = [parse(Int, strip(p)) for p in split(args["ports"], ',')] + payload["ports"] = ports + end + + if args["domains"] !== nothing + domains = [strip(d) for d in split(args["domains"], ',')] + payload["domains"] = domains + end + + if args["type"] !== nothing + payload["service_type"] = args["type"] + end + + if args["bootstrap"] !== nothing + payload["bootstrap"] = args["bootstrap"] + end + + if args["bootstrap-file"] !== nothing + bootstrap_file = args["bootstrap-file"] + if isfile(bootstrap_file) + payload["bootstrap_content"] = read(bootstrap_file, String) + else + println(stderr, "$(RED)Error: Bootstrap file not found: $bootstrap_file$(RESET)") + exit(1) + end + end + + if args["network"] !== nothing + payload["network"] = args["network"] + end + + if args["vcpu"] !== nothing + payload["vcpu"] = args["vcpu"] + end + + # Add input files + if args["files"] !== nothing + input_files = [] + for filepath in args["files"] + if !isfile(filepath) + println(stderr, "$(RED)Error: Input file not found: $filepath$(RESET)") + exit(1) + end + content = base64encode(read(filepath)) + push!(input_files, Dict( + "filename" => basename(filepath), + "content_base64" => content + )) + end + if !isempty(input_files) + payload["input_files"] = input_files + end + end + + result = api_request("/services", public_key, secret_key, method="POST", data=payload) + service_id = get(result, "id", nothing) + println("$(GREEN)Service created: $(something(service_id, "N/A"))$(RESET)") + println("Name: $(get(result, "name", "N/A"))") + if haskey(result, "url") + println("URL: $(result["url"])") + end + + # Auto-set vault if env vars were provided + vault_envs = something(args["vault-env"], String[]) + vault_env_file = get(args, "env-file", nothing) + if service_id !== nothing && (!isempty(vault_envs) || vault_env_file !== nothing) + env_content = build_env_content(vault_envs, vault_env_file) + if !isempty(env_content) + if service_env_set(service_id, env_content, public_key, secret_key) + println("$(GREEN)Vault configured with environment variables$(RESET)") + else + println("$(YELLOW)Warning: Failed to set vault$(RESET)") + end + end + end + return + end + + println(stderr, "$(RED)Error: Use --name to create, or --list, --info, --logs, --freeze, --unfreeze, --destroy$(RESET)") + exit(1) +end + +function validate_key(api_key::String) + url = PORTAL_BASE * "/keys/validate" + headers = [ + "Authorization" => "Bearer $api_key", + "Content-Type" => "application/json" + ] + + try + response = HTTP.post(url, headers, "{}", readtimeout=300) + data = JSON.parse(String(response.body)) + + # Check if valid + if get(data, "valid", false) + # Print valid key info + println("$(GREEN)Valid$(RESET)\n") + println(@sprintf("%-20s %s", "Public Key:", get(data, "public_key", "N/A"))) + println(@sprintf("%-20s %s", "Tier:", get(data, "tier", "N/A"))) + println(@sprintf("%-20s %s", "Status:", get(data, "status", "N/A"))) + println(@sprintf("%-20s %s", "Expires:", get(data, "valid_through_datetime", "N/A"))) + println(@sprintf("%-20s %s", "Time Remaining:", get(data, "valid_for_human", "N/A"))) + println(@sprintf("%-20s %s/min", "Rate Limit:", get(data, "rate_per_minute", "N/A"))) + println(@sprintf("%-20s %s", "Burst:", get(data, "burst", "N/A"))) + println(@sprintf("%-20s %s", "Concurrency:", get(data, "concurrency", "N/A"))) + return 0 + else + # Handle invalid response + reason = get(data, "reason", "unknown") + if reason == "expired" + println("$(RED)Expired$(RESET)\n") + println(@sprintf("%-20s %s", "Public Key:", get(data, "public_key", "N/A"))) + println(@sprintf("%-20s %s", "Tier:", get(data, "tier", "N/A"))) + expired_at = get(data, "expired_at_datetime", "N/A") + expired_ago = get(data, "expired_ago", "") + if !isempty(expired_ago) + println(@sprintf("%-20s %s (%s)", "Expired:", expired_at, expired_ago)) + else + println(@sprintf("%-20s %s", "Expired:", expired_at)) + end + renew_url = get(data, "renew_url", "https://unsandbox.com/pricing") + println("\n$(YELLOW)To renew:$(RESET) Visit $renew_url") + elseif reason == "invalid_key" + println("$(RED)Invalid$(RESET): key not found") + elseif reason == "suspended" + println("$(RED)Suspended$(RESET): key has been suspended") + else + println("$(RED)Invalid$(RESET): $reason") + end + return 1 + end + catch e + if isa(e, HTTP.ExceptionRequest.StatusError) + # Parse error response from body + try + data = JSON.parse(String(e.response.body)) + reason = get(data, "reason", "unknown") + + if reason == "expired" + println("$(RED)Expired$(RESET)\n") + println(@sprintf("%-20s %s", "Public Key:", get(data, "public_key", "N/A"))) + println(@sprintf("%-20s %s", "Tier:", get(data, "tier", "N/A"))) + expired_at = get(data, "expired_at_datetime", "N/A") + expired_ago = get(data, "expired_ago", "") + if !isempty(expired_ago) + println(@sprintf("%-20s %s (%s)", "Expired:", expired_at, expired_ago)) + else + println(@sprintf("%-20s %s", "Expired:", expired_at)) + end + renew_url = get(data, "renew_url", "https://unsandbox.com/pricing") + println("\n$(YELLOW)To renew:$(RESET) Visit $renew_url") + elseif reason == "invalid_key" + println("$(RED)Invalid$(RESET): key not found") + elseif reason == "suspended" + println("$(RED)Suspended$(RESET): key has been suspended") + else + println("$(RED)Invalid$(RESET): $reason") + end + catch + println(stderr, "$(RED)Error: HTTP $(e.status)$(RESET)") + end + return 1 + else + println(stderr, "$(RED)Error: Request failed: $e$(RESET)") + return 1 + end + end +end + +function get_languages_cache_path()::String + home = get(ENV, "HOME", ".") + return joinpath(home, ".unsandbox", "languages.json") +end + +function load_languages_cache()::Union{Vector{String}, Nothing} + cache_path = get_languages_cache_path() + + if !isfile(cache_path) + return nothing + end + + try + content = read(cache_path, String) + data = JSON.parse(content) + timestamp = get(data, "timestamp", 0) + now = Int64(floor(time())) + + if now - timestamp < LANGUAGES_CACHE_TTL + langs = get(data, "languages", []) + return [string(l) for l in langs] + else + return nothing + end + catch + return nothing + end +end + +function save_languages_cache(languages::Vector) + cache_path = get_languages_cache_path() + cache_dir = dirname(cache_path) + + # Ensure directory exists + mkpath(cache_dir) + + timestamp = Int64(floor(time())) + data = Dict("languages" => languages, "timestamp" => timestamp) + + try + open(cache_path, "w") do f + write(f, JSON.json(data)) + end + catch + # Ignore write errors + end +end + +function cmd_languages(args) + # Try to load from cache first + langs = load_languages_cache() + + if langs === nothing + # Cache miss or expired, fetch from API + (public_key, secret_key) = get_api_keys(args["api-key"]; account_index=something(get(args, "account", nothing), -1)) + result = api_request("/languages", public_key, secret_key) + langs = get(result, "languages", []) + save_languages_cache(langs) + end + + if args["json"] + println(JSON.json(langs)) + else + for lang in langs + println(lang) + end + end +end + +function cmd_key(args) + (public_key, secret_key) = get_api_keys(args["api-key"]; account_index=something(get(args, "account", nothing), -1)) + # For portal validation, we still use public_key as bearer token + api_key = public_key + + # Handle --extend flag + if args["extend"] + # Validate key to get public key + url = PORTAL_BASE * "/keys/validate" + headers = [ + "Authorization" => "Bearer $api_key", + "Content-Type" => "application/json" + ] + + try + response = HTTP.post(url, headers, "{}", readtimeout=300) + data = JSON.parse(String(response.body)) + + public_key = get(data, "public_key", nothing) + if public_key === nothing + println(stderr, "$(RED)Error: Invalid key or could not retrieve public key$(RESET)") + exit(1) + end + + # Build extend URL + extend_url = "$(PORTAL_BASE)/keys/extend?pk=$(public_key)" + + println("Opening extension page in browser...") + println("If browser doesn't open, visit: $extend_url") + + # Try to open browser + if Sys.isapple() + run(`open $extend_url`) + elseif Sys.islinux() + try + run(`xdg-open $extend_url`) + catch + try + run(`sensible-browser $extend_url`) + catch + # Already printed the URL + end + end + elseif Sys.iswindows() + run(`cmd /c start $extend_url`) + end + + exit(0) + catch e + println(stderr, "$(RED)Error: Failed to validate key: $e$(RESET)") + exit(1) + end + end + + # Default: validate and display key info + exit(validate_key(api_key)) +end + +function main() + s = ArgParseSettings(description="Unsandbox CLI - Execute code in secure sandboxes") + + @add_arg_table! s begin + "source_file" + help = "Source file to execute" + required = false + "--api-key", "-k" + help = "API key (or set UNSANDBOX_API_KEY)" + "--account" + help = "Account index in ~/.unsandbox/accounts.csv (bypasses env vars)" + arg_type = Int + "--network", "-n" + help = "Network mode" + arg_type = String + range_tester = x -> x in ["zerotrust", "semitrusted"] + "--env", "-e" + help = "Set environment variable (KEY=VALUE)" + action = :append_arg + "--files", "-f" + help = "Add input file" + action = :append_arg + "--artifacts", "-a" + help = "Return artifacts" + action = :store_true + "--output-dir", "-o" + help = "Output directory for artifacts" + "session" + help = "Manage interactive sessions" + action = :command + "service" + help = "Manage persistent services" + action = :command + "languages" + help = "List available programming languages" + action = :command + "key" + help = "Check API key validity and expiration" + action = :command + "image" + help = "Manage images" + action = :command + "snapshot" + help = "Manage snapshots" + action = :command + end + + @add_arg_table! s["snapshot"] begin + "--list", "-l" + help = "List all snapshots" + action = :store_true + "--info" + help = "Get snapshot details" + "--delete" + help = "Delete a snapshot" + "--lock" + help = "Lock snapshot to prevent deletion" + "--unlock" + help = "Unlock snapshot" + "--restore" + help = "Restore from snapshot" + "--clone" + help = "Clone snapshot to session/service (requires --type)" + "--type" + help = "Clone type: session or service" + "--name" + help = "Name for cloned resource" + "--shell" + help = "Shell for cloned session" + "--ports" + help = "Comma-separated ports for cloned service" + "--api-key", "-k" + help = "API key" + "--account" + help = "Account index in ~/.unsandbox/accounts.csv (bypasses env vars)" + arg_type = Int + end + + @add_arg_table! s["session"] begin + "--list", "-l" + help = "List active sessions" + action = :store_true + "--kill" + help = "Terminate session" + "--files", "-f" + help = "Add input file" + action = :append_arg + "--network", "-n" + help = "Network mode" + arg_type = String + range_tester = x -> x in ["zerotrust", "semitrusted"] + "--api-key", "-k" + help = "API key" + "--account" + help = "Account index in ~/.unsandbox/accounts.csv (bypasses env vars)" + arg_type = Int + end + + @add_arg_table! s["service"] begin + "--name" + help = "Service name" + "--ports" + help = "Comma-separated ports" + "--domains" + help = "Comma-separated custom domains" + "--type" + help = "Service type for SRV records (minecraft, mumble, teamspeak, source, tcp, udp)" + "--bootstrap" + help = "Bootstrap command or URI" + "--bootstrap-file" + help = "Upload local file as bootstrap script" + "--files", "-f" + help = "Add input file" + action = :append_arg + "--vault-env", "-e" + help = "Environment variable for vault (KEY=VALUE)" + action = :append_arg + "--env-file" + help = "Load vault variables from file" + "--network", "-n" + help = "Network mode" + arg_type = String + range_tester = x -> x in ["zerotrust", "semitrusted"] + "--vcpu", "-v" + help = "vCPU count (1-8)" + arg_type = Int + range_tester = x -> x >= 1 && x <= 8 + "--list", "-l" + help = "List services" + action = :store_true + "--info" + help = "Get service details" + "--logs" + help = "Get all logs" + "--freeze" + help = "Freeze service" + "--unfreeze" + help = "Unfreeze service" + "--destroy" + help = "Destroy service" + "--resize" + help = "Resize service (requires --vcpu N)" + "--unfreeze-on-demand" + help = "Service ID to set unfreeze_on_demand for" + "--unfreeze-on-demand-value" + help = "Enable/disable unfreeze_on_demand (true or false)" + arg_type = Bool + "--dump-bootstrap" + help = "Dump bootstrap script from service" + "--dump-file" + help = "File to save bootstrap (with --dump-bootstrap)" + "--env-action" + help = "Env action (status, set, export, delete)" + "--env-target" + help = "Service ID for env commands" + "--api-key", "-k" + help = "API key" + "--account" + help = "Account index in ~/.unsandbox/accounts.csv (bypasses env vars)" + arg_type = Int + "env" + help = "Manage service environment vault" + action = :command + end + + @add_arg_table! s["service"]["env"] begin + "action" + help = "Env action: status, set, export, delete" + required = true + "service_id" + help = "Service ID" + required = false + "-e" + help = "Environment variable (KEY=VALUE)" + action = :append_arg + dest_name = "vault-env" + "--env-file" + help = "Load vault variables from file" + "--api-key", "-k" + help = "API key" + "--account" + help = "Account index in ~/.unsandbox/accounts.csv (bypasses env vars)" + arg_type = Int + end + + @add_arg_table! s["key"] begin + "--extend" + help = "Open browser to extend/renew key" + action = :store_true + "--api-key", "-k" + help = "API key" + "--account" + help = "Account index in ~/.unsandbox/accounts.csv (bypasses env vars)" + arg_type = Int + end + + @add_arg_table! s["languages"] begin + "--json" + help = "Output as JSON array" + action = :store_true + "--api-key", "-k" + help = "API key" + "--account" + help = "Account index in ~/.unsandbox/accounts.csv (bypasses env vars)" + arg_type = Int + end + + @add_arg_table! s["image"] begin + "--list", "-l" + help = "List all images" + action = :store_true + "--info" + help = "Get image details" + "--delete" + help = "Delete an image" + "--lock" + help = "Lock image to prevent deletion" + "--unlock" + help = "Unlock image" + "--publish" + help = "Publish image from service/snapshot (requires --source-type)" + "--source-type" + help = "Source type: service or snapshot" + "--visibility" + help = "Image ID to set visibility for" + "--visibility-mode" + help = "Visibility mode: private, unlisted, or public" + "--spawn" + help = "Spawn new service from image" + "--clone" + help = "Clone an image" + "--name" + help = "Name for spawned service or cloned image" + "--ports" + help = "Comma-separated ports for spawned service" + "--api-key", "-k" + help = "API key" + "--account" + help = "Account index in ~/.unsandbox/accounts.csv (bypasses env vars)" + arg_type = Int + end + + args = parse_args(ARGS, s) + + if args["%COMMAND%"] == "session" + cmd_session(args["session"]) + elseif args["%COMMAND%"] == "service" + service_args = args["service"] + # Check if env subcommand was used + if get(service_args, "%COMMAND%", nothing) == "env" + env_args = service_args["env"] + # Copy env args to service args + service_args["env-action"] = get(env_args, "action", nothing) + service_args["env-target"] = get(env_args, "service_id", nothing) + service_args["vault-env"] = get(env_args, "vault-env", nothing) + service_args["env-file"] = get(env_args, "env-file", nothing) + service_args["api-key"] = get(env_args, "api-key", nothing) + service_args["account"] = get(env_args, "account", nothing) + end + cmd_service(service_args) + elseif args["%COMMAND%"] == "languages" + cmd_languages(args["languages"]) + elseif args["%COMMAND%"] == "key" + cmd_key(args["key"]) + elseif args["%COMMAND%"] == "image" + cmd_image(args["image"]) + elseif args["%COMMAND%"] == "snapshot" + cmd_snapshot(args["snapshot"]) + elseif args["source_file"] !== nothing + cmd_execute(args) + else + println(stderr, "$(RED)Error: Provide source_file or use 'session'/'service'/'snapshot'/'languages'/'key'/'image' subcommand$(RESET)") + exit(1) + end +end + +function cmd_image(args) + (public_key, secret_key) = get_api_keys(args["api-key"]; account_index=something(get(args, "account", nothing), -1)) + + if args["list"] + result = api_request("/images", public_key, secret_key) + println(JSON.json(result, 2)) + return + end + + if args["info"] !== nothing + result = api_request("/images/$(args["info"])", public_key, secret_key) + println(JSON.json(result, 2)) + return + end + + if args["delete"] !== nothing + api_request_with_sudo("/images/$(args["delete"])", public_key, secret_key, method="DELETE") + println("$(GREEN)Image deleted: $(args["delete"])$(RESET)") + return + end + + if args["lock"] !== nothing + api_request("/images/$(args["lock"])/lock", public_key, secret_key, method="POST") + println("$(GREEN)Image locked: $(args["lock"])$(RESET)") + return + end + + if args["unlock"] !== nothing + api_request_with_sudo("/images/$(args["unlock"])/unlock", public_key, secret_key, method="POST", data=Dict()) + println("$(GREEN)Image unlocked: $(args["unlock"])$(RESET)") + return + end + + if args["publish"] !== nothing + source_type = args["source-type"] + if source_type === nothing + println(stderr, "$(RED)Error: --source-type required (service or snapshot)$(RESET)") + exit(1) + end + payload = Dict("source_type" => source_type, "source_id" => args["publish"]) + if args["name"] !== nothing + payload["name"] = args["name"] + end + result = api_request("/images/publish", public_key, secret_key, method="POST", data=payload) + println("$(GREEN)Image published$(RESET)") + println(JSON.json(result, 2)) + return + end + + if args["visibility"] !== nothing + mode = args["visibility-mode"] + if mode === nothing + println(stderr, "$(RED)Error: --visibility requires MODE (private, unlisted, or public)$(RESET)") + exit(1) + end + payload = Dict("visibility" => mode) + api_request("/images/$(args["visibility"])/visibility", public_key, secret_key, method="POST", data=payload) + println("$(GREEN)Image visibility set to $(mode): $(args["visibility"])$(RESET)") + return + end + + if args["spawn"] !== nothing + payload = Dict() + if args["name"] !== nothing + payload["name"] = args["name"] + end + if args["ports"] !== nothing + ports = [parse(Int, strip(p)) for p in split(args["ports"], ',')] + payload["ports"] = ports + end + result = api_request("/images/$(args["spawn"])/spawn", public_key, secret_key, method="POST", data=payload) + println("$(GREEN)Service spawned from image$(RESET)") + println(JSON.json(result, 2)) + return + end + + if args["clone"] !== nothing + payload = Dict() + if args["name"] !== nothing + payload["name"] = args["name"] + end + result = api_request("/images/$(args["clone"])/clone", public_key, secret_key, method="POST", data=payload) + println("$(GREEN)Image cloned$(RESET)") + println(JSON.json(result, 2)) + return + end + + println(stderr, "$(RED)Error: Use --list, --info ID, --delete ID, --lock ID, --unlock ID, --publish ID, --visibility ID, --spawn ID, or --clone ID$(RESET)") + exit(1) +end + +function cmd_snapshot(args) + (public_key, secret_key) = get_api_keys(args["api-key"]; account_index=something(get(args, "account", nothing), -1)) + + if args["list"] + result = api_request("/snapshots", public_key, secret_key) + snapshots = get(result, "snapshots", []) + if isempty(snapshots) + println("No snapshots found") + else + @printf("%-40s %-20s %-12s %-30s %s\n", "ID", "Name", "Type", "Source ID", "Size") + for s in snapshots + @printf("%-40s %-20s %-12s %-30s %s\n", + get(s, "id", "N/A"), + get(s, "name", "-"), + get(s, "source_type", "N/A"), + get(s, "source_id", "N/A"), + get(s, "size", "N/A")) + end + end + return + end + + if args["info"] !== nothing + result = api_request("/snapshots/$(args["info"])", public_key, secret_key) + println(JSON.json(result, 2)) + return + end + + if args["delete"] !== nothing + api_request_with_sudo("/snapshots/$(args["delete"])", public_key, secret_key, method="DELETE") + println("$(GREEN)Snapshot deleted: $(args["delete"])$(RESET)") + return + end + + if args["lock"] !== nothing + api_request("/snapshots/$(args["lock"])/lock", public_key, secret_key, method="POST") + println("$(GREEN)Snapshot locked: $(args["lock"])$(RESET)") + return + end + + if args["unlock"] !== nothing + api_request_with_sudo("/snapshots/$(args["unlock"])/unlock", public_key, secret_key, method="POST", data=Dict()) + println("$(GREEN)Snapshot unlocked: $(args["unlock"])$(RESET)") + return + end + + if args["restore"] !== nothing + api_request("/snapshots/$(args["restore"])/restore", public_key, secret_key, method="POST", data=Dict()) + println("$(GREEN)Snapshot restored: $(args["restore"])$(RESET)") + return + end + + if args["clone"] !== nothing + clone_type = args["type"] + if clone_type === nothing + println(stderr, "$(RED)Error: --type required for --clone (session or service)$(RESET)") + exit(1) + end + payload = Dict("type" => clone_type) + if args["name"] !== nothing + payload["name"] = args["name"] + end + if args["shell"] !== nothing + payload["shell"] = args["shell"] + end + if args["ports"] !== nothing + ports = [parse(Int, strip(p)) for p in split(args["ports"], ',')] + payload["ports"] = ports + end + result = api_request("/snapshots/$(args["clone"])/clone", public_key, secret_key, method="POST", data=payload) + println("$(GREEN)Cloned from snapshot$(RESET)") + println(JSON.json(result, 2)) + return + end + + println(stderr, "$(RED)Error: Use --list, --info ID, --delete ID, --lock ID, --unlock ID, --restore ID, or --clone ID$(RESET)") + exit(1) +end + +# ============================================================================= +# Library API Functions (for import/use as a module) +# ============================================================================= + +const VERSION = "1.0.0" + +""" + execute(language::String, code::String; kwargs...) -> Dict + +Execute code synchronously and return the result. + +# Arguments +- `language`: Programming language (e.g., "python", "javascript") +- `code`: Source code to execute + +# Keyword Arguments +- `env::Dict{String,String}`: Environment variables +- `network::String`: Network mode ("zerotrust" or "semitrusted") +- `public_key::String`: API public key (optional) +- `secret_key::String`: API secret key (optional) + +# Returns +Dict with `stdout`, `stderr`, `exit_code`, `success` + +# Example +```julia +result = execute("python", "print('Hello, World!')") +println(result["stdout"]) +``` +""" +function execute(language::String, code::String; + env::Union{Dict{String,String}, Nothing}=nothing, + network::String="zerotrust", + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + payload = Dict("language" => language, "code" => code) + if env !== nothing + payload["env"] = env + end + if network != "zerotrust" + payload["network"] = network + end + + return api_request("/execute", pk, sk, method="POST", data=payload) +end + +""" + execute_async(language::String, code::String; kwargs...) -> String + +Execute code asynchronously and return job ID. + +# Returns +Job ID string for polling with `wait_job` or `get_job`. +""" +function execute_async(language::String, code::String; + env::Union{Dict{String,String}, Nothing}=nothing, + network::String="zerotrust", + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + payload = Dict("language" => language, "code" => code, "async" => true) + if env !== nothing + payload["env"] = env + end + if network != "zerotrust" + payload["network"] = network + end + + result = api_request("/execute", pk, sk, method="POST", data=payload) + return get(result, "job_id", "") +end + +""" + wait_job(job_id::String; kwargs...) -> Dict + +Wait for an async job to complete and return results. +""" +function wait_job(job_id::String; + poll_interval::Int=1, + max_wait::Int=300, + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + start_time = time() + while true + result = get_job(job_id, public_key=pk, secret_key=sk) + status = get(result, "status", "") + if status in ["completed", "failed", "timeout", "cancelled"] + return result + end + if time() - start_time >= max_wait + error("Job $job_id did not complete within $max_wait seconds") + end + sleep(poll_interval) + end +end + +""" + get_job(job_id::String; kwargs...) -> Dict + +Get the status of an async job. +""" +function get_job(job_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + return api_request("/jobs/$job_id", pk, sk) +end + +""" + cancel_job(job_id::String; kwargs...) -> Bool + +Cancel a running job. +""" +function cancel_job(job_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/jobs/$job_id", pk, sk, method="DELETE") + return true +end + +""" + list_jobs(; kwargs...) -> Vector{Dict} + +List all jobs for the account. +""" +function list_jobs(; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + result = api_request("/jobs", pk, sk) + return get(result, "jobs", []) +end + +""" + get_languages(; kwargs...) -> Vector{String} + +Get list of supported programming languages. +""" +function get_languages(; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + result = api_request("/languages", pk, sk) + return get(result, "languages", []) +end + +""" + session_list(; kwargs...) -> Vector{Dict} + +List all active sessions. +""" +function session_list(; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + result = api_request("/sessions", pk, sk) + return get(result, "sessions", []) +end + +""" + session_get(session_id::String; kwargs...) -> Dict + +Get details of a session. +""" +function session_get(session_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + return api_request("/sessions/$session_id", pk, sk) +end + +""" + session_create(; kwargs...) -> Dict + +Create a new interactive session. +""" +function session_create(; + shell::String="bash", + network::String="zerotrust", + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + payload = Dict("shell" => shell) + if network != "zerotrust" + payload["network"] = network + end + + return api_request("/sessions", pk, sk, method="POST", data=payload) +end + +""" + session_destroy(session_id::String; kwargs...) -> Bool + +Destroy a session. +""" +function session_destroy(session_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/sessions/$session_id", pk, sk, method="DELETE") + return true +end + +""" + session_freeze(session_id::String; kwargs...) -> Bool + +Freeze (pause) a session. +""" +function session_freeze(session_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/sessions/$session_id/freeze", pk, sk, method="POST") + return true +end + +""" + session_unfreeze(session_id::String; kwargs...) -> Bool + +Unfreeze (resume) a session. +""" +function session_unfreeze(session_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/sessions/$session_id/unfreeze", pk, sk, method="POST") + return true +end + +""" + session_boost(session_id::String, vcpu::Int; kwargs...) -> Bool + +Boost session resources. +""" +function session_boost(session_id::String, vcpu::Int; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request_patch("/sessions/$session_id", pk, sk, data=Dict("vcpu" => vcpu)) + return true +end + +""" + session_unboost(session_id::String; kwargs...) -> Bool + +Remove session boost. +""" +function session_unboost(session_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request_patch("/sessions/$session_id", pk, sk, data=Dict("vcpu" => 1)) + return true +end + +""" + session_execute(session_id::String, command::String; kwargs...) -> Dict + +Execute a command in a session. +""" +function session_execute(session_id::String, command::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + return api_request("/sessions/$session_id/execute", pk, sk, method="POST", data=Dict("command" => command)) +end + +""" + service_list(; kwargs...) -> Vector{Dict} + +List all services. +""" +function service_list(; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + result = api_request("/services", pk, sk) + return get(result, "services", []) +end + +""" + service_get(service_id::String; kwargs...) -> Dict + +Get details of a service. +""" +function service_get(service_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + return api_request("/services/$service_id", pk, sk) +end + +""" + service_create(name::String; kwargs...) -> Dict + +Create a new service. +""" +function service_create(name::String; + ports::Union{Vector{Int}, Nothing}=nothing, + domains::Union{Vector{String}, Nothing}=nothing, + bootstrap::Union{String, Nothing}=nothing, + network::String="semitrusted", + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + payload = Dict("name" => name) + if ports !== nothing + payload["ports"] = ports + end + if domains !== nothing + payload["domains"] = domains + end + if bootstrap !== nothing + payload["bootstrap"] = bootstrap + end + if network != "semitrusted" + payload["network"] = network + end + + return api_request("/services", pk, sk, method="POST", data=payload) +end + +""" + service_destroy(service_id::String; kwargs...) -> Bool + +Destroy a service. +""" +function service_destroy(service_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request_with_sudo("/services/$service_id", pk, sk, method="DELETE") + return true +end + +""" + service_freeze(service_id::String; kwargs...) -> Bool + +Freeze (pause) a service. +""" +function service_freeze(service_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/services/$service_id/freeze", pk, sk, method="POST") + return true +end + +""" + service_unfreeze(service_id::String; kwargs...) -> Bool + +Unfreeze (resume) a service. +""" +function service_unfreeze(service_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/services/$service_id/unfreeze", pk, sk, method="POST") + return true +end + +""" + service_lock(service_id::String; kwargs...) -> Bool + +Lock a service to prevent deletion. +""" +function service_lock(service_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/services/$service_id/lock", pk, sk, method="POST") + return true +end + +""" + service_unlock(service_id::String; kwargs...) -> Bool + +Unlock a service. +""" +function service_unlock(service_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request_with_sudo("/services/$service_id/unlock", pk, sk, method="POST", data=Dict()) + return true +end + +""" + service_set_unfreeze_on_demand(service_id::String, enabled::Bool; kwargs...) -> Bool + +Set unfreeze-on-demand for a service. +""" +function service_set_unfreeze_on_demand(service_id::String, enabled::Bool; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request_patch("/services/$service_id", pk, sk, data=Dict("unfreeze_on_demand" => enabled)) + return true +end + +""" + service_redeploy(service_id::String; kwargs...) -> Bool + +Redeploy a service (re-run bootstrap). +""" +function service_redeploy(service_id::String; + bootstrap::Union{String, Nothing}=nothing, + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + payload = bootstrap !== nothing ? Dict("bootstrap" => bootstrap) : Dict() + api_request("/services/$service_id/redeploy", pk, sk, method="POST", data=payload) + return true +end + +""" + service_logs(service_id::String; kwargs...) -> String + +Get service logs. +""" +function service_logs(service_id::String; + all_logs::Bool=false, + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + endpoint = all_logs ? "/services/$service_id/logs?all=true" : "/services/$service_id/logs" + result = api_request(endpoint, pk, sk) + return get(result, "logs", "") +end + +""" + service_execute(service_id::String, command::String; kwargs...) -> Dict + +Execute a command in a service. +""" +function service_execute(service_id::String, command::String; + timeout_ms::Int=30000, + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + return api_request("/services/$service_id/execute", pk, sk, method="POST", + data=Dict("command" => command, "timeout_ms" => timeout_ms)) +end + +""" + service_resize(service_id::String, vcpu::Int; kwargs...) -> Bool + +Resize a service. +""" +function service_resize(service_id::String, vcpu::Int; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request_patch("/services/$service_id", pk, sk, data=Dict("vcpu" => vcpu)) + return true +end + +""" + service_env_get(service_id::String; kwargs...) -> String + +Get service environment variables. +""" +function service_env_get(service_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + result = api_request("/services/$service_id/env", pk, sk) + return get(result, "env", "") +end + +""" + service_env_set(service_id::String, env_content::String; kwargs...) -> Bool + +Set service environment variables. +""" +function service_env_set(service_id::String, env_content::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/services/$service_id/env", pk, sk, method="POST", data=Dict("env" => env_content)) + return true +end + +""" + service_env_delete(service_id::String; kwargs...) -> Bool + +Delete service environment variables. +""" +function service_env_delete(service_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/services/$service_id/env", pk, sk, method="DELETE") + return true +end + +""" + service_env_export(service_id::String; kwargs...) -> String + +Export service environment variables as shell format. +""" +function service_env_export(service_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + result = api_request("/services/$service_id/env/export", pk, sk) + return get(result, "export", "") +end + +""" + snapshot_list(; kwargs...) -> Vector{Dict} + +List all snapshots. +""" +function snapshot_list(; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + result = api_request("/snapshots", pk, sk) + return get(result, "snapshots", []) +end + +""" + snapshot_get(snapshot_id::String; kwargs...) -> Dict + +Get details of a snapshot. +""" +function snapshot_get(snapshot_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + return api_request("/snapshots/$snapshot_id", pk, sk) +end + +""" + snapshot_session(session_id::String; kwargs...) -> String + +Create a snapshot from a session. Returns snapshot ID. +""" +function snapshot_session(session_id::String; + name::Union{String, Nothing}=nothing, + hot::Bool=false, + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + payload = Dict() + if name !== nothing + payload["name"] = name + end + if hot + payload["hot"] = true + end + + result = api_request("/sessions/$session_id/snapshot", pk, sk, method="POST", data=payload) + return get(result, "id", "") +end + +""" + snapshot_service(service_id::String; kwargs...) -> String + +Create a snapshot from a service. Returns snapshot ID. +""" +function snapshot_service(service_id::String; + name::Union{String, Nothing}=nothing, + hot::Bool=false, + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + payload = Dict() + if name !== nothing + payload["name"] = name + end + if hot + payload["hot"] = true + end + + result = api_request("/services/$service_id/snapshot", pk, sk, method="POST", data=payload) + return get(result, "id", "") +end + +""" + snapshot_restore(snapshot_id::String; kwargs...) -> Bool + +Restore from a snapshot. +""" +function snapshot_restore(snapshot_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/snapshots/$snapshot_id/restore", pk, sk, method="POST", data=Dict()) + return true +end + +""" + snapshot_delete(snapshot_id::String; kwargs...) -> Bool + +Delete a snapshot. +""" +function snapshot_delete(snapshot_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request_with_sudo("/snapshots/$snapshot_id", pk, sk, method="DELETE") + return true +end + +""" + snapshot_lock(snapshot_id::String; kwargs...) -> Bool + +Lock a snapshot to prevent deletion. +""" +function snapshot_lock(snapshot_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/snapshots/$snapshot_id/lock", pk, sk, method="POST") + return true +end + +""" + snapshot_unlock(snapshot_id::String; kwargs...) -> Bool + +Unlock a snapshot. +""" +function snapshot_unlock(snapshot_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request_with_sudo("/snapshots/$snapshot_id/unlock", pk, sk, method="POST", data=Dict()) + return true +end + +""" + snapshot_clone(snapshot_id::String, clone_type::String; kwargs...) -> String + +Clone a snapshot to a new session or service. Returns the new resource ID. +""" +function snapshot_clone(snapshot_id::String, clone_type::String; + name::Union{String, Nothing}=nothing, + ports::Union{Vector{Int}, Nothing}=nothing, + shell::Union{String, Nothing}=nothing, + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + payload = Dict("type" => clone_type) + if name !== nothing + payload["name"] = name + end + if ports !== nothing + payload["ports"] = ports + end + if shell !== nothing + payload["shell"] = shell + end + + result = api_request("/snapshots/$snapshot_id/clone", pk, sk, method="POST", data=payload) + return get(result, "id", "") +end + +""" + image_list(; kwargs...) -> Vector{Dict} + +List all images. +""" +function image_list(; + filter::Union{String, Nothing}=nothing, + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + endpoint = filter !== nothing ? "/images?filter=$filter" : "/images" + result = api_request(endpoint, pk, sk) + return get(result, "images", []) +end + +""" + image_get(image_id::String; kwargs...) -> Dict + +Get details of an image. +""" +function image_get(image_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + return api_request("/images/$image_id", pk, sk) +end + +""" + image_publish(source_type::String, source_id::String; kwargs...) -> String + +Publish an image from a service or snapshot. Returns image ID. +""" +function image_publish(source_type::String, source_id::String; + name::Union{String, Nothing}=nothing, + description::Union{String, Nothing}=nothing, + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + payload = Dict("source_type" => source_type, "source_id" => source_id) + if name !== nothing + payload["name"] = name + end + if description !== nothing + payload["description"] = description + end + + result = api_request("/images/publish", pk, sk, method="POST", data=payload) + return get(result, "id", "") +end + +""" + image_delete(image_id::String; kwargs...) -> Bool + +Delete an image. +""" +function image_delete(image_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request_with_sudo("/images/$image_id", pk, sk, method="DELETE") + return true +end + +""" + image_lock(image_id::String; kwargs...) -> Bool + +Lock an image to prevent deletion. +""" +function image_lock(image_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/images/$image_id/lock", pk, sk, method="POST") + return true +end + +""" + image_unlock(image_id::String; kwargs...) -> Bool + +Unlock an image. +""" +function image_unlock(image_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request_with_sudo("/images/$image_id/unlock", pk, sk, method="POST", data=Dict()) + return true +end + +""" + image_set_visibility(image_id::String, visibility::String; kwargs...) -> Bool + +Set image visibility (private, unlisted, or public). +""" +function image_set_visibility(image_id::String, visibility::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/images/$image_id/visibility", pk, sk, method="POST", data=Dict("visibility" => visibility)) + return true +end + +""" + image_grant_access(image_id::String, trusted_api_key::String; kwargs...) -> Bool + +Grant access to an image. +""" +function image_grant_access(image_id::String, trusted_api_key::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/images/$image_id/access", pk, sk, method="POST", data=Dict("trusted_api_key" => trusted_api_key)) + return true +end + +""" + image_revoke_access(image_id::String, trusted_api_key::String; kwargs...) -> Bool + +Revoke access to an image. +""" +function image_revoke_access(image_id::String, trusted_api_key::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/images/$image_id/access/$trusted_api_key", pk, sk, method="DELETE") + return true +end + +""" + image_list_trusted(image_id::String; kwargs...) -> Vector{String} + +List trusted API keys for an image. +""" +function image_list_trusted(image_id::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + result = api_request("/images/$image_id/access", pk, sk) + return get(result, "trusted_keys", []) +end + +""" + image_transfer(image_id::String, to_api_key::String; kwargs...) -> Bool + +Transfer ownership of an image. +""" +function image_transfer(image_id::String, to_api_key::String; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + api_request("/images/$image_id/transfer", pk, sk, method="POST", data=Dict("to_api_key" => to_api_key)) + return true +end + +""" + image_spawn(image_id::String; kwargs...) -> String + +Spawn a new service from an image. Returns service ID. +""" +function image_spawn(image_id::String; + name::Union{String, Nothing}=nothing, + ports::Union{Vector{Int}, Nothing}=nothing, + bootstrap::Union{String, Nothing}=nothing, + network::Union{String, Nothing}=nothing, + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + payload = Dict() + if name !== nothing + payload["name"] = name + end + if ports !== nothing + payload["ports"] = ports + end + if bootstrap !== nothing + payload["bootstrap"] = bootstrap + end + if network !== nothing + payload["network"] = network + end + + result = api_request("/images/$image_id/spawn", pk, sk, method="POST", data=payload) + return get(result, "id", "") +end + +""" + image_clone(image_id::String; kwargs...) -> String + +Clone an image. Returns new image ID. +""" +function image_clone(image_id::String; + name::Union{String, Nothing}=nothing, + description::Union{String, Nothing}=nothing, + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + payload = Dict() + if name !== nothing + payload["name"] = name + end + if description !== nothing + payload["description"] = description + end + + result = api_request("/images/$image_id/clone", pk, sk, method="POST", data=payload) + return get(result, "id", "") +end + +""" + logs_fetch(source::String; kwargs...) -> String + +Fetch PaaS logs. +""" +function logs_fetch(source::String; + lines::Int=100, + since::String="1h", + grep::Union{String, Nothing}=nothing, + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + endpoint = "/logs?source=$source&lines=$lines&since=$since" + if grep !== nothing + endpoint *= "&grep=$grep" + end + + result = api_request(endpoint, pk, sk) + return get(result, "logs", "") +end + +""" + validate_keys(; kwargs...) -> Dict + +Validate API keys and return account info. +""" +function validate_keys(; + public_key::Union{String,Nothing}=nothing, + secret_key::Union{String,Nothing}=nothing) + (pk, sk) = if public_key !== nothing && secret_key !== nothing + (public_key, secret_key) + else + get_api_keys(nothing) + end + + url = PORTAL_BASE * "/keys/validate" + timestamp = Int64(floor(time())) + body = "{}" + signature = compute_signature(sk, timestamp, "POST", "/keys/validate", body) + + headers = [ + "Authorization" => "Bearer $pk", + "X-Timestamp" => string(timestamp), + "X-Signature" => signature, + "Content-Type" => "application/json" + ] + + try + response = HTTP.post(url, headers, body, readtimeout=30) + return JSON.parse(String(response.body)) + catch e + return Dict("valid" => false, "error" => string(e)) + end +end + +""" + health_check() -> Bool + +Check if the API is healthy. +""" +function health_check() + try + response = HTTP.get("$API_BASE/health", readtimeout=10) + return response.status == 200 + catch + return false + end +end + +""" + version() -> String + +Get SDK version. +""" +function version() + return VERSION +end + +# Thread-local error storage +const _last_error = Ref{String}("") + +""" + last_error() -> String + +Get the last error message. +""" +function last_error() + return _last_error[] +end + +""" + set_last_error(msg::String) + +Set the last error message (internal use). +""" +function set_last_error(msg::String) + _last_error[] = msg +end + +""" + hmac_sign(secret_key::String, message::String) -> String + +Compute HMAC-SHA256 signature. +""" +function hmac_sign(secret_key::String, message::String) + return bytes2hex(SHA.hmac_sha256(Vector{UInt8}(secret_key), Vector{UInt8}(message))) +end + +main()