diff --git a/.gitignore b/.gitignore index 2d4f1e1..75363f3 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ /un_zig /Un.class +__pycache__/ + # Build directories _build/ deps/ diff --git a/CLAUDE.md b/CLAUDE.md index 2f5ace8..91839d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,39 +24,56 @@ On 2026-01-11, raw `lxc delete` destroyed 8 production services causing complete UN CLI Inception - The UN CLI written in every language it can execute. 42+ implementations, one unified interface. -### SDK Architecture (In Growth) +### SDK Architecture -**Current State**: Root-level implementations (un.py, un.c, un.go, etc.) serving as both CLI + embeddable libraries. - -**Target State**: Migrate to `clients/` directory structure: +**Directory Structure**: ``` clients/ -├── python/ # clients/python/un.py - sync/async client + CLI -├── javascript/ # clients/javascript/un.js - SDK + CLI -├── go/ # clients/go/un.go - SDK + CLI -├── java/ # clients/java/Un.java - SDK + CLI -├── ruby/ # clients/ruby/un.rb - SDK + CLI -├── php/ # clients/php/un.php - SDK + CLI -├── rust/ # clients/rust/un.rs - SDK + CLI -├── {42+ more}/ +├── python/ +│ ├── sync/src/un.py # Synchronous (requests) - 2,698 lines +│ └── async/src/un_async.py # Asynchronous (aiohttp) - 2,333 lines +├── javascript/ +│ ├── sync/src/un.js # Synchronous (https) - 2,307 lines +│ └── async/src/un_async.js # Asynchronous (fetch) - 2,131 lines +├── go/ +│ ├── sync/src/un.go # Synchronous (net/http) - 2,652 lines +│ └── async/src/un_async.go # Asynchronous (goroutines) - 3,011 lines +├── java/ +│ ├── sync/src/Un.java # Synchronous (HttpURLConnection) - 3,051 lines +│ └── async/src/UnsandboxAsync.java # Asynchronous (CompletableFuture) - 2,685 lines +├── ruby/ +│ ├── sync/src/un.rb # Synchronous (net/http) - 2,423 lines +│ └── async/src/un_async.rb # Asynchronous (Future) - 2,441 lines +├── rust/ +│ ├── sync/src/lib.rs # Synchronous (reqwest blocking) - 3,665 lines +│ └── async/src/lib.rs # Asynchronous (reqwest + tokio) - 3,375 lines +├── php/ +│ ├── sync/src/un.php # Synchronous (cURL) - 2,818 lines +│ └── async/src/UnsandboxAsync.php # Asynchronous (Guzzle promises) - 2,457 lines +├── CLI_SPEC.md # Full CLI specification (all SDKs must match) +└── README.md # SDK documentation ``` -**Each client implementation serves THREE purposes**: -1. **Standalone CLI program** - Argparse/getopt with full command support (execute, session, service) -2. **Importable client library** - Can import and use as an SDK in other code -3. **Embeddable library** - Can be bundled into other language projects +**Total: 38,125 lines across 14 SDK files (7 languages × 2 variants)** -**Example (Python)**: -```python -# As CLI: python clients/python/un.py test/fib.py -# As library: from clients.python.un import UnsandboxClient -# As embedded: copy un.py into your project, import locally +**Each SDK is BOTH a library AND a CLI tool** (see `clients/CLI_SPEC.md`): +```bash +# Library usage +python -c "from un import execute_code; print(execute_code('python', 'print(1)'))" + +# CLI usage (identical across all languages) +python un.py script.py # Execute code file +python un.py -s bash 'echo hello' # Inline code +python un.py session --tmux # Interactive session +python un.py service --list # Manage services ``` -**Migration Path**: -- Phase 1 (Current): Grow clients/ directory in parallel with root un.* files -- Phase 2: Root files eventually deprecated in favor of clients/ -- Phase 3: Root files maintained for backwards compatibility only +**Every SDK implements**: +- **43+ API functions** (execute, jobs, sessions, services, snapshots, utilities) +- **Full CLI** (execute, session, service, service env, snapshot, key subcommands) +- **4-tier credential resolution** (args > env > ~/.unsandbox/accounts.csv > ./accounts.csv) +- **HMAC-SHA256 request signing** +- **1-hour languages cache** (~/.unsandbox/languages.json) ## Authentication diff --git a/__pycache__/un.cpython-312.pyc b/__pycache__/un.cpython-312.pyc deleted file mode 100644 index b0c71f0..0000000 Binary files a/__pycache__/un.cpython-312.pyc and /dev/null differ diff --git a/__pycache__/un.cpython-313.pyc b/__pycache__/un.cpython-313.pyc deleted file mode 100644 index 19bcc38..0000000 Binary files a/__pycache__/un.cpython-313.pyc and /dev/null differ diff --git a/clients/c/src/un.c b/clients/c/src/un.c index a00be1b..e52ac40 100644 --- a/clients/c/src/un.c +++ b/clients/c/src/un.c @@ -4673,6 +4673,584 @@ static int clone_snapshot(const UnsandboxCredentials *creds, const char *snapsho // End Service Management Support // ============================================================================ +// ============================================================================ +// LXD Container Images API +// ============================================================================ + +// Publish an image from a service or snapshot +static char* image_publish(const UnsandboxCredentials *creds, const char *source_type, + const char *source_id, const char *name, const char *description) { + if (!creds || !creds->public_key || !creds->secret_key || !source_type || !source_id) { + return NULL; + } + + CURL *curl = curl_easy_init(); + if (!curl) return NULL; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[256]; + snprintf(url, sizeof(url), "%s/images", API_BASE); + + // Build JSON body + char body[2048]; + char *p = body; + p += sprintf(p, "{\"source_type\":\"%s\",\"source_id\":\"%s\"", source_type, source_id); + if (name && strlen(name) > 0) { + char *esc = escape_json_string(name); + p += sprintf(p, ",\"name\":\"%s\"", esc); + free(esc); + } + if (description && strlen(description) > 0) { + char *esc = escape_json_string(description); + p += sprintf(p, ",\"description\":\"%s\"", esc); + free(esc); + } + p += sprintf(p, "}"); + + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Content-Type: application/json"); + headers = add_hmac_auth_headers(headers, creds, "POST", "/images", body); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + free(response.data); + return NULL; + } + + return response.data; +} + +// List images (filter_type can be NULL, "owned", "shared", or "public") +static char* list_images(const UnsandboxCredentials *creds, const char *filter_type) { + if (!creds || !creds->public_key || !creds->secret_key) { + return NULL; + } + + CURL *curl = curl_easy_init(); + if (!curl) return NULL; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[256]; + char path[128]; + if (filter_type && strlen(filter_type) > 0) { + snprintf(url, sizeof(url), "%s/images/%s", API_BASE, filter_type); + snprintf(path, sizeof(path), "/images/%s", filter_type); + } else { + snprintf(url, sizeof(url), "%s/images", API_BASE); + snprintf(path, sizeof(path), "/images"); + } + + struct curl_slist *headers = NULL; + headers = add_hmac_auth_headers(headers, creds, "GET", path, NULL); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + free(response.data); + return NULL; + } + + return response.data; +} + +// Get image details +static char* get_image(const UnsandboxCredentials *creds, const char *image_id) { + if (!creds || !creds->public_key || !creds->secret_key || !image_id) { + return NULL; + } + + CURL *curl = curl_easy_init(); + if (!curl) return NULL; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[256]; + char path[128]; + snprintf(url, sizeof(url), "%s/images/%s", API_BASE, image_id); + snprintf(path, sizeof(path), "/images/%s", image_id); + + struct curl_slist *headers = NULL; + headers = add_hmac_auth_headers(headers, creds, "GET", path, NULL); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + free(response.data); + return NULL; + } + + return response.data; +} + +// Delete an image +static int delete_image(const UnsandboxCredentials *creds, const char *image_id) { + if (!creds || !creds->public_key || !creds->secret_key || !image_id) { + return 1; + } + + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[256]; + char path[128]; + snprintf(url, sizeof(url), "%s/images/%s", API_BASE, image_id); + snprintf(path, sizeof(path), "/images/%s", image_id); + + struct curl_slist *headers = NULL; + headers = add_hmac_auth_headers(headers, creds, "DELETE", path, NULL); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "DELETE"); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + free(response.data); + return (res == CURLE_OK) ? 0 : 1; +} + +// Lock an image +static int lock_image(const UnsandboxCredentials *creds, const char *image_id) { + if (!creds || !creds->public_key || !creds->secret_key || !image_id) { + return 1; + } + + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[256]; + char path[128]; + snprintf(url, sizeof(url), "%s/images/%s/lock", API_BASE, image_id); + snprintf(path, sizeof(path), "/images/%s/lock", image_id); + + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Content-Type: application/json"); + headers = add_hmac_auth_headers(headers, creds, "POST", path, "{}"); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, "{}"); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + free(response.data); + return (res == CURLE_OK) ? 0 : 1; +} + +// Unlock an image +static int unlock_image(const UnsandboxCredentials *creds, const char *image_id) { + if (!creds || !creds->public_key || !creds->secret_key || !image_id) { + return 1; + } + + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[256]; + char path[128]; + snprintf(url, sizeof(url), "%s/images/%s/unlock", API_BASE, image_id); + snprintf(path, sizeof(path), "/images/%s/unlock", image_id); + + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Content-Type: application/json"); + headers = add_hmac_auth_headers(headers, creds, "POST", path, "{}"); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, "{}"); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + free(response.data); + return (res == CURLE_OK) ? 0 : 1; +} + +// Set image visibility (private, unlisted, public) +static int set_image_visibility(const UnsandboxCredentials *creds, const char *image_id, const char *visibility) { + if (!creds || !creds->public_key || !creds->secret_key || !image_id || !visibility) { + return 1; + } + + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[256]; + char path[128]; + snprintf(url, sizeof(url), "%s/images/%s/visibility", API_BASE, image_id); + snprintf(path, sizeof(path), "/images/%s/visibility", image_id); + + char body[128]; + snprintf(body, sizeof(body), "{\"visibility\":\"%s\"}", visibility); + + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Content-Type: application/json"); + headers = add_hmac_auth_headers(headers, creds, "POST", path, body); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + free(response.data); + return (res == CURLE_OK) ? 0 : 1; +} + +// Grant image access to another API key +static int grant_image_access(const UnsandboxCredentials *creds, const char *image_id, const char *trusted_api_key) { + if (!creds || !creds->public_key || !creds->secret_key || !image_id || !trusted_api_key) { + return 1; + } + + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[256]; + char path[128]; + snprintf(url, sizeof(url), "%s/images/%s/grant", API_BASE, image_id); + snprintf(path, sizeof(path), "/images/%s/grant", image_id); + + char body[256]; + snprintf(body, sizeof(body), "{\"trusted_api_key\":\"%s\"}", trusted_api_key); + + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Content-Type: application/json"); + headers = add_hmac_auth_headers(headers, creds, "POST", path, body); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + free(response.data); + return (res == CURLE_OK) ? 0 : 1; +} + +// Revoke image access from another API key +static int revoke_image_access(const UnsandboxCredentials *creds, const char *image_id, const char *trusted_api_key) { + if (!creds || !creds->public_key || !creds->secret_key || !image_id || !trusted_api_key) { + return 1; + } + + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[256]; + char path[128]; + snprintf(url, sizeof(url), "%s/images/%s/revoke", API_BASE, image_id); + snprintf(path, sizeof(path), "/images/%s/revoke", image_id); + + char body[256]; + snprintf(body, sizeof(body), "{\"trusted_api_key\":\"%s\"}", trusted_api_key); + + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Content-Type: application/json"); + headers = add_hmac_auth_headers(headers, creds, "POST", path, body); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + free(response.data); + return (res == CURLE_OK) ? 0 : 1; +} + +// List API keys with access to an image +static char* list_image_trusted(const UnsandboxCredentials *creds, const char *image_id) { + if (!creds || !creds->public_key || !creds->secret_key || !image_id) { + return NULL; + } + + CURL *curl = curl_easy_init(); + if (!curl) return NULL; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[256]; + char path[128]; + snprintf(url, sizeof(url), "%s/images/%s/trusted", API_BASE, image_id); + snprintf(path, sizeof(path), "/images/%s/trusted", image_id); + + struct curl_slist *headers = NULL; + headers = add_hmac_auth_headers(headers, creds, "GET", path, NULL); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + free(response.data); + return NULL; + } + + return response.data; +} + +// Transfer image ownership to another API key +static int transfer_image(const UnsandboxCredentials *creds, const char *image_id, const char *to_api_key) { + if (!creds || !creds->public_key || !creds->secret_key || !image_id || !to_api_key) { + return 1; + } + + CURL *curl = curl_easy_init(); + if (!curl) return 1; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[256]; + char path[128]; + snprintf(url, sizeof(url), "%s/images/%s/transfer", API_BASE, image_id); + snprintf(path, sizeof(path), "/images/%s/transfer", image_id); + + char body[256]; + snprintf(body, sizeof(body), "{\"to_api_key\":\"%s\"}", to_api_key); + + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Content-Type: application/json"); + headers = add_hmac_auth_headers(headers, creds, "POST", path, body); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + free(response.data); + return (res == CURLE_OK) ? 0 : 1; +} + +// Spawn a new service from an image +static char* spawn_from_image(const UnsandboxCredentials *creds, const char *image_id, + const char *name, const char *ports, const char *bootstrap, + const char *network_mode) { + if (!creds || !creds->public_key || !creds->secret_key || !image_id) { + return NULL; + } + + CURL *curl = curl_easy_init(); + if (!curl) return NULL; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[256]; + char path[128]; + snprintf(url, sizeof(url), "%s/images/%s/spawn", API_BASE, image_id); + snprintf(path, sizeof(path), "/images/%s/spawn", image_id); + + // Build JSON body + char body[4096]; + char *p = body; + p += sprintf(p, "{"); + int need_comma = 0; + + if (name && strlen(name) > 0) { + char *esc = escape_json_string(name); + p += sprintf(p, "\"name\":\"%s\"", esc); + free(esc); + need_comma = 1; + } + if (ports && strlen(ports) > 0) { + if (need_comma) p += sprintf(p, ","); + p += sprintf(p, "\"ports\":\"%s\"", ports); + need_comma = 1; + } + if (bootstrap && strlen(bootstrap) > 0) { + if (need_comma) p += sprintf(p, ","); + char *esc = escape_json_string(bootstrap); + p += sprintf(p, "\"bootstrap\":\"%s\"", esc); + free(esc); + need_comma = 1; + } + if (network_mode && strlen(network_mode) > 0) { + if (need_comma) p += sprintf(p, ","); + p += sprintf(p, "\"network_mode\":\"%s\"", network_mode); + } + p += sprintf(p, "}"); + + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Content-Type: application/json"); + headers = add_hmac_auth_headers(headers, creds, "POST", path, body); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + free(response.data); + return NULL; + } + + return response.data; +} + +// Clone an image to create a copy owned by the current user +static char* clone_image(const UnsandboxCredentials *creds, const char *image_id, + const char *name, const char *description) { + if (!creds || !creds->public_key || !creds->secret_key || !image_id) { + return NULL; + } + + CURL *curl = curl_easy_init(); + if (!curl) return NULL; + + struct ResponseBuffer response = {0}; + response.data = malloc(1); + response.size = 0; + + char url[256]; + char path[128]; + snprintf(url, sizeof(url), "%s/images/%s/clone", API_BASE, image_id); + snprintf(path, sizeof(path), "/images/%s/clone", image_id); + + // Build JSON body + char body[2048]; + char *p = body; + p += sprintf(p, "{"); + int need_comma = 0; + + if (name && strlen(name) > 0) { + char *esc = escape_json_string(name); + p += sprintf(p, "\"name\":\"%s\"", esc); + free(esc); + need_comma = 1; + } + if (description && strlen(description) > 0) { + if (need_comma) p += sprintf(p, ","); + char *esc = escape_json_string(description); + p += sprintf(p, "\"description\":\"%s\"", esc); + free(esc); + } + p += sprintf(p, "}"); + + struct curl_slist *headers = NULL; + headers = curl_slist_append(headers, "Content-Type: application/json"); + headers = add_hmac_auth_headers(headers, creds, "POST", path, body); + + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); + + CURLcode res = curl_easy_perform(curl); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + + if (res != CURLE_OK) { + free(response.data); + return NULL; + } + + return response.data; +} + +// ============================================================================ +// End LXD Container Images API +// ============================================================================ + // ============================================================================ // Key Validation Support // ============================================================================ diff --git a/clients/java/sync/src/Un.java b/clients/java/sync/src/Un.java index a75d97e..122347b 100644 --- a/clients/java/sync/src/Un.java +++ b/clients/java/sync/src/Un.java @@ -1718,6 +1718,369 @@ public class Un { return makeRequest("POST", "/snapshots/" + snapshotId + "/clone", creds[0], creds[1], data); } + // ======================================================================== + // Images API Methods (LXD Container Images) + // ======================================================================== + + /** + * Publish a session or service as a reusable LXD container image. + * + * @param sourceType Source type: "session" or "service" + * @param sourceId ID of the session or service to publish + * @param name Name for the image + * @param description Optional description for the image + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map containing image_id + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map imagePublish( + String sourceType, + String sourceId, + String name, + String description, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + Map data = new LinkedHashMap<>(); + data.put("source_type", sourceType); + data.put("source_id", sourceId); + data.put("name", name); + if (description != null && !description.isEmpty()) { + data.put("description", description); + } + return makeRequest("POST", "/images", creds[0], creds[1], data); + } + + /** + * List container images with optional filtering. + * + * @param filterType Optional filter: "own", "shared", "public", or null for all + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return List of image maps containing id, name, description, visibility, etc. + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + @SuppressWarnings("unchecked") + public static List> listImages( + String filterType, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + String path = "/images"; + if (filterType != null && !filterType.isEmpty()) { + path = "/images/" + filterType; + } + Map response = makeRequest("GET", path, creds[0], creds[1], null); + Object images = response.get("images"); + if (images instanceof List) { + List> result = new ArrayList<>(); + for (Object item : (List) images) { + if (item instanceof Map) { + result.add((Map) item); + } + } + return result; + } + return new ArrayList<>(); + } + + /** + * Get details of a specific image. + * + * @param imageId Image ID to retrieve + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Image details map + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map getImage( + String imageId, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("GET", "/images/" + imageId, creds[0], creds[1], null); + } + + /** + * Delete an image. + * + * @param imageId Image ID to delete + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map with deletion confirmation + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map deleteImage( + String imageId, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("DELETE", "/images/" + imageId, creds[0], creds[1], null); + } + + /** + * Lock an image (prevent deletion and modifications). + * + * @param imageId Image ID to lock + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map with lock confirmation + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map lockImage( + String imageId, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("POST", "/images/" + imageId + "/lock", creds[0], creds[1], new LinkedHashMap<>()); + } + + /** + * Unlock an image (allow deletion and modifications). + * + * @param imageId Image ID to unlock + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map with unlock confirmation + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map unlockImage( + String imageId, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + return makeRequest("POST", "/images/" + imageId + "/unlock", creds[0], creds[1], new LinkedHashMap<>()); + } + + /** + * Set the visibility of an image. + * + * @param imageId Image ID to modify + * @param visibility Visibility level: "private", "shared", or "public" + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map with visibility change confirmation + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map setImageVisibility( + String imageId, + String visibility, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + Map data = new LinkedHashMap<>(); + data.put("visibility", visibility); + return makeRequest("POST", "/images/" + imageId + "/visibility", creds[0], creds[1], data); + } + + /** + * Grant access to an image for another API key (for shared images). + * + * @param imageId Image ID to grant access to + * @param trustedApiKey Public API key to grant access to + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map with grant confirmation + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map grantImageAccess( + String imageId, + String trustedApiKey, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + Map data = new LinkedHashMap<>(); + data.put("trusted_api_key", trustedApiKey); + return makeRequest("POST", "/images/" + imageId + "/grant", creds[0], creds[1], data); + } + + /** + * Revoke access to an image from another API key. + * + * @param imageId Image ID to revoke access from + * @param trustedApiKey Public API key to revoke access from + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map with revoke confirmation + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map revokeImageAccess( + String imageId, + String trustedApiKey, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + Map data = new LinkedHashMap<>(); + data.put("trusted_api_key", trustedApiKey); + return makeRequest("POST", "/images/" + imageId + "/revoke", creds[0], creds[1], data); + } + + /** + * List API keys that have been granted access to an image. + * + * @param imageId Image ID to list trusted keys for + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return List of trusted API key maps + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + @SuppressWarnings("unchecked") + public static List> listImageTrusted( + String imageId, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + Map response = makeRequest("GET", "/images/" + imageId + "/trusted", creds[0], creds[1], null); + Object trusted = response.get("trusted"); + if (trusted instanceof List) { + List> result = new ArrayList<>(); + for (Object item : (List) trusted) { + if (item instanceof Map) { + result.add((Map) item); + } + } + return result; + } + return new ArrayList<>(); + } + + /** + * Transfer ownership of an image to another API key. + * + * @param imageId Image ID to transfer + * @param toApiKey Public API key of the new owner + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map with transfer confirmation + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map transferImage( + String imageId, + String toApiKey, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + Map data = new LinkedHashMap<>(); + data.put("to_api_key", toApiKey); + return makeRequest("POST", "/images/" + imageId + "/transfer", creds[0], creds[1], data); + } + + /** + * Spawn a new service from an image. + * + * @param imageId Image ID to spawn from + * @param name Name for the new service + * @param ports Comma-separated list of ports to expose (e.g., "80,443") + * @param bootstrap Optional bootstrap script or URL + * @param networkMode Network mode: "zerotrust" or "semitrusted" + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map containing service_id + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map spawnFromImage( + String imageId, + String name, + String ports, + String bootstrap, + String networkMode, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + Map data = new LinkedHashMap<>(); + data.put("name", name); + if (ports != null && !ports.isEmpty()) { + List portList = new ArrayList<>(); + for (String p : ports.split(",")) { + try { + portList.add(Integer.parseInt(p.trim())); + } catch (NumberFormatException e) { + // Skip invalid port + } + } + data.put("ports", portList); + } + if (bootstrap != null && !bootstrap.isEmpty()) { + if (bootstrap.startsWith("http://") || bootstrap.startsWith("https://")) { + data.put("bootstrap_url", bootstrap); + } else { + data.put("bootstrap", bootstrap); + } + } + if (networkMode != null && !networkMode.isEmpty()) { + data.put("network_mode", networkMode); + } + return makeRequest("POST", "/images/" + imageId + "/spawn", creds[0], creds[1], data); + } + + /** + * Clone an image to create a new image with a different name. + * + * @param imageId Image ID to clone + * @param name Name for the cloned image + * @param description Optional description for the cloned image + * @param publicKey Optional API key + * @param secretKey Optional API secret + * @return Response map containing new image_id + * @throws IOException on network errors + * @throws CredentialsException if credentials cannot be found + * @throws ApiException if API returns an error + */ + public static Map cloneImage( + String imageId, + String name, + String description, + String publicKey, + String secretKey + ) throws IOException { + String[] creds = resolveCredentials(publicKey, secretKey); + Map data = new LinkedHashMap<>(); + if (name != null && !name.isEmpty()) { + data.put("name", name); + } + if (description != null && !description.isEmpty()) { + data.put("description", description); + } + return makeRequest("POST", "/images/" + imageId + "/clone", creds[0], creds[1], data); + } + // ======================================================================== // Key Validation API // ======================================================================== diff --git a/clients/javascript/sync/src/un.js b/clients/javascript/sync/src/un.js index e87ccaa..003a308 100644 --- a/clients/javascript/sync/src/un.js +++ b/clients/javascript/sync/src/un.js @@ -19,6 +19,11 @@ * // Snapshot management * sessionSnapshot, serviceSnapshot, listSnapshots, restoreSnapshot, * deleteSnapshot, lockSnapshot, unlockSnapshot, cloneSnapshot, + * // Images API (LXD container images) + * imagePublish, listImages, getImage, deleteImage, + * lockImage, unlockImage, setImageVisibility, + * grantImageAccess, revokeImageAccess, listImageTrusted, + * transferImage, spawnFromImage, cloneImage, * // Key validation * validateKeys, * } = require('./un.js'); @@ -1367,6 +1372,20 @@ module.exports = { lockSnapshot, unlockSnapshot, cloneSnapshot, + // Images API (LXD container images) + imagePublish, + listImages, + getImage, + deleteImage, + lockImage, + unlockImage, + setImageVisibility, + grantImageAccess, + revokeImageAccess, + listImageTrusted, + transferImage, + spawnFromImage, + cloneImage, // Key validation validateKeys, // Image generation diff --git a/clients/php/sync/src/un.php b/clients/php/sync/src/un.php index b97061d..a47945e 100644 --- a/clients/php/sync/src/un.php +++ b/clients/php/sync/src/un.php @@ -510,6 +510,310 @@ class Unsandbox { return $this->makeRequest('POST', "/snapshots/{$snapshotId}/clone", $publicKey, $secretKey, $data); } + // ========================================================================= + // Images Methods (LXD Container Images) + // ========================================================================= + + /** + * Publish a new image from a session or service container. + * + * @param string $sourceType Source type: "session" or "service" + * @param string $sourceId Session ID or Service ID to publish from + * @param string|null $name Optional name for the image + * @param string|null $description Optional description for the image + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Response array with image_id and image details + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function imagePublish( + string $sourceType, + string $sourceId, + ?string $name = null, + ?string $description = null, + ?string $publicKey = null, + ?string $secretKey = null + ): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + + $data = [ + 'source_type' => $sourceType, + 'source_id' => $sourceId, + ]; + if ($name !== null) { + $data['name'] = $name; + } + if ($description !== null) { + $data['description'] = $description; + } + + return $this->makeRequest('POST', '/images', $publicKey, $secretKey, $data); + } + + /** + * List all images owned by the authenticated account. + * + * @param string|null $filterType Optional filter: "owned", "shared", "public", or null for all + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array List of image arrays + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function listImages(?string $filterType = null, ?string $publicKey = null, ?string $secretKey = null): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + + $path = $filterType !== null ? "/images/{$filterType}" : '/images'; + $response = $this->makeRequest('GET', $path, $publicKey, $secretKey); + return $response['images'] ?? []; + } + + /** + * Get details of a specific image. + * + * @param string $imageId Image ID + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Image details + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function getImage(string $imageId, ?string $publicKey = null, ?string $secretKey = null): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('GET', "/images/{$imageId}", $publicKey, $secretKey); + } + + /** + * Delete an image. + * + * @param string $imageId Image ID to delete + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Response array with deletion confirmation + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function deleteImage(string $imageId, ?string $publicKey = null, ?string $secretKey = null): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('DELETE', "/images/{$imageId}", $publicKey, $secretKey); + } + + /** + * Lock an image to prevent deletion. + * + * @param string $imageId Image ID to lock + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Response array with lock confirmation + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function lockImage(string $imageId, ?string $publicKey = null, ?string $secretKey = null): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/images/{$imageId}/lock", $publicKey, $secretKey, []); + } + + /** + * Unlock an image to allow deletion. + * + * @param string $imageId Image ID to unlock + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Response array with unlock confirmation + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function unlockImage(string $imageId, ?string $publicKey = null, ?string $secretKey = null): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/images/{$imageId}/unlock", $publicKey, $secretKey, []); + } + + /** + * Set visibility of an image. + * + * @param string $imageId Image ID + * @param string $visibility Visibility level: "private" or "public" + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Response array with visibility update confirmation + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function setImageVisibility( + string $imageId, + string $visibility, + ?string $publicKey = null, + ?string $secretKey = null + ): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/images/{$imageId}/visibility", $publicKey, $secretKey, [ + 'visibility' => $visibility, + ]); + } + + /** + * Grant access to an image for another API key. + * + * @param string $imageId Image ID + * @param string $trustedApiKey The API key to grant access to + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Response array with grant confirmation + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function grantImageAccess( + string $imageId, + string $trustedApiKey, + ?string $publicKey = null, + ?string $secretKey = null + ): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/images/{$imageId}/grant", $publicKey, $secretKey, [ + 'trusted_api_key' => $trustedApiKey, + ]); + } + + /** + * Revoke access to an image from another API key. + * + * @param string $imageId Image ID + * @param string $trustedApiKey The API key to revoke access from + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Response array with revoke confirmation + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function revokeImageAccess( + string $imageId, + string $trustedApiKey, + ?string $publicKey = null, + ?string $secretKey = null + ): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/images/{$imageId}/revoke", $publicKey, $secretKey, [ + 'trusted_api_key' => $trustedApiKey, + ]); + } + + /** + * List API keys that have been granted access to an image. + * + * @param string $imageId Image ID + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array List of trusted API key information + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function listImageTrusted(string $imageId, ?string $publicKey = null, ?string $secretKey = null): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + $response = $this->makeRequest('GET', "/images/{$imageId}/trusted", $publicKey, $secretKey); + return $response['trusted'] ?? []; + } + + /** + * Transfer ownership of an image to another API key. + * + * @param string $imageId Image ID to transfer + * @param string $toApiKey The API key to transfer ownership to + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Response array with transfer confirmation + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function transferImage( + string $imageId, + string $toApiKey, + ?string $publicKey = null, + ?string $secretKey = null + ): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + return $this->makeRequest('POST', "/images/{$imageId}/transfer", $publicKey, $secretKey, [ + 'to_api_key' => $toApiKey, + ]); + } + + /** + * Spawn a new service from an image. + * + * @param string $imageId Image ID to spawn from + * @param string|null $name Optional service name + * @param array|string|null $ports Optional port(s) to expose (array of ints or comma-separated string) + * @param string|null $bootstrap Optional bootstrap command or URL + * @param string $networkMode Network mode: "zerotrust" or "semitrusted" + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Response array with spawned service info + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function spawnFromImage( + string $imageId, + ?string $name = null, + $ports = null, + ?string $bootstrap = null, + string $networkMode = 'zerotrust', + ?string $publicKey = null, + ?string $secretKey = null + ): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + + $data = [ + 'network_mode' => $networkMode, + ]; + if ($name !== null) { + $data['name'] = $name; + } + if ($ports !== null) { + // Convert ports to array if string + if (is_string($ports)) { + $ports = array_map('intval', explode(',', $ports)); + } + $data['ports'] = $ports; + } + if ($bootstrap !== null) { + $data['bootstrap'] = $bootstrap; + } + + return $this->makeRequest('POST', "/images/{$imageId}/spawn", $publicKey, $secretKey, $data); + } + + /** + * Clone an image to create a new image with a different name/description. + * + * @param string $imageId Image ID to clone + * @param string|null $name Optional name for the cloned image + * @param string|null $description Optional description for the cloned image + * @param string|null $publicKey Optional API key + * @param string|null $secretKey Optional API secret + * @return array Response array with cloned image info + * @throws CredentialsException Missing credentials + * @throws ApiException API request failed + */ + public function cloneImage( + string $imageId, + ?string $name = null, + ?string $description = null, + ?string $publicKey = null, + ?string $secretKey = null + ): array { + [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); + + $data = []; + if ($name !== null) { + $data['name'] = $name; + } + if ($description !== null) { + $data['description'] = $description; + } + + return $this->makeRequest('POST', "/images/{$imageId}/clone", $publicKey, $secretKey, $data); + } + // ========================================================================= // Session Methods // ========================================================================= diff --git a/clients/ruby/async/src/un_async.rb b/clients/ruby/async/src/un_async.rb index 2ab725d..d531996 100644 --- a/clients/ruby/async/src/un_async.rb +++ b/clients/ruby/async/src/un_async.rb @@ -1549,4 +1549,893 @@ module UnAsync nil end end + + # ============================================================================ + # CLI Implementation + # ============================================================================ + + # Exit codes + EXIT_SUCCESS = 0 + EXIT_ERROR = 1 + EXIT_INVALID_ARGS = 2 + EXIT_AUTH_ERROR = 3 + EXIT_API_ERROR = 4 + EXIT_TIMEOUT = 5 + + class << self + # Main CLI entry point + def cli_main + # Global options + options = { + shell: nil, + env: [], + files: [], + file_paths: [], + public_key: nil, + secret_key: nil, + network: 'zerotrust', + vcpu: 1, + yes: false, + artifacts: false, + output: nil + } + + # Check for subcommands first + if ARGV.empty? + cli_show_help + exit(EXIT_INVALID_ARGS) + end + + case ARGV[0] + when 'session' + ARGV.shift + cli_session(options) + when 'service' + ARGV.shift + cli_service(options) + when 'snapshot' + ARGV.shift + cli_snapshot(options) + when 'key' + ARGV.shift + cli_key(options) + when '-h', '--help', 'help' + cli_show_help + exit(EXIT_SUCCESS) + else + cli_execute(options) + end + rescue CredentialsError => e + $stderr.puts "Error: #{e.message}" + exit(EXIT_AUTH_ERROR) + rescue APIError => e + $stderr.puts "Error: #{e.message}" + exit(EXIT_API_ERROR) + rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e + $stderr.puts "Error: #{e.message}" + exit(EXIT_INVALID_ARGS) + rescue Interrupt + $stderr.puts "\nInterrupted" + exit(EXIT_ERROR) + end + + private + + # Show main help + def cli_show_help + puts <<~HELP + unsandbox.com Ruby SDK (Async) - Secure Code Execution + + Usage: + ruby un_async.rb [options] Execute code file + ruby un_async.rb [options] -s LANG 'code' Execute inline code + ruby un_async.rb session [options] Manage sessions + ruby un_async.rb service [options] Manage services + ruby un_async.rb snapshot [options] Manage snapshots + ruby un_async.rb key Check API key + + Global Options: + -s, --shell LANG Language for inline code + -e, --env KEY=VAL Set environment variable (can repeat) + -f, --file FILE Add input file to /tmp/ + -F, --file-path FILE Add input file with path preserved + -a, --artifacts Return compiled artifacts + -o, --output DIR Output directory for artifacts + -p, --public-key KEY API public key + -k, --secret-key KEY API secret key + -n, --network MODE Network mode: zerotrust or semitrusted + -v, --vcpu N vCPU count (1-8) + -y, --yes Skip confirmation prompts + -h, --help Show this help + + Examples: + ruby un_async.rb script.py + ruby un_async.rb -s python 'print("hello")' + ruby un_async.rb -n semitrusted crawler.py + ruby un_async.rb session --list + ruby un_async.rb service --name web --ports 80 --bootstrap "python -m http.server 80" + HELP + end + + # Parse global options from ARGV + def parse_global_options(options) + OptionParser.new do |opts| + opts.on('-s', '--shell LANG', 'Language for inline code') do |v| + options[:shell] = v + end + opts.on('-e', '--env KEY=VAL', 'Set environment variable') do |v| + options[:env] << v + end + opts.on('-f', '--file FILE', 'Add input file to /tmp/') do |v| + options[:files] << v + end + opts.on('-F', '--file-path FILE', 'Add input file with path preserved') do |v| + options[:file_paths] << v + end + opts.on('-a', '--artifacts', 'Return compiled artifacts') do + options[:artifacts] = true + end + opts.on('-o', '--output DIR', 'Output directory for artifacts') do |v| + options[:output] = v + end + opts.on('-p', '--public-key KEY', 'API public key') do |v| + options[:public_key] = v + end + opts.on('-k', '--secret-key KEY', 'API secret key') do |v| + options[:secret_key] = v + end + opts.on('-n', '--network MODE', 'Network mode') do |v| + options[:network] = v + end + opts.on('-v', '--vcpu N', Integer, 'vCPU count (1-8)') do |v| + options[:vcpu] = v + end + opts.on('-y', '--yes', 'Skip confirmation prompts') do + options[:yes] = true + end + opts.on('-h', '--help', 'Show help') do + yield if block_given? + exit(EXIT_SUCCESS) + end + end + end + + # Execute code command + def cli_execute(options) + parser = parse_global_options(options) do + cli_show_help + end + parser.parse!(ARGV) + + if ARGV.empty? + $stderr.puts 'Error: No source file or code provided' + exit(EXIT_INVALID_ARGS) + end + + code = nil + language = nil + + if options[:shell] + # Inline code mode: -s LANG 'code' + language = options[:shell] + code = ARGV.join(' ') + else + # File mode: script.py + filename = ARGV[0] + unless File.exist?(filename) + $stderr.puts "Error: File not found: #{filename}" + exit(EXIT_INVALID_ARGS) + end + code = File.read(filename) + language = detect_language(filename) + unless language + $stderr.puts "Error: Cannot detect language for: #{filename}" + $stderr.puts 'Use -s/--shell to specify language' + exit(EXIT_INVALID_ARGS) + end + end + + # Execute the code (async call, await result) + result = execute_code( + language, + code, + public_key: options[:public_key], + secret_key: options[:secret_key] + ).value + + # Output results + cli_print_execute_result(result) + end + + # Print execution result + def cli_print_execute_result(result) + puts result['stdout'] if result['stdout'] && !result['stdout'].empty? + $stderr.puts result['stderr'] if result['stderr'] && !result['stderr'].empty? + puts '---' + puts "Exit code: #{result['exit_code'] || 0}" + if result['execution_time_ms'] + puts "Execution time: #{result['execution_time_ms']}ms" + end + end + + # Session subcommand + def cli_session(options) + session_opts = { + list: false, + attach: nil, + kill: nil, + freeze: nil, + unfreeze: nil, + boost: nil, + unboost: nil, + snapshot: nil, + snapshot_name: nil, + hot: false, + tmux: false, + screen: false, + shell: 'bash', + audit: false + } + + parser = parse_global_options(options) do + cli_session_help + end + + parser.on('-l', '--list', 'List active sessions') do + session_opts[:list] = true + end + parser.on('--attach ID', 'Reconnect to existing session') do |v| + session_opts[:attach] = v + end + parser.on('--kill ID', 'Terminate a session') do |v| + session_opts[:kill] = v + end + parser.on('--freeze ID', 'Pause session') do |v| + session_opts[:freeze] = v + end + parser.on('--unfreeze ID', 'Resume session') do |v| + session_opts[:unfreeze] = v + end + parser.on('--boost ID', 'Add vCPUs/RAM') do |v| + session_opts[:boost] = v + end + parser.on('--unboost ID', 'Remove boost') do |v| + session_opts[:unboost] = v + end + parser.on('--snapshot ID', 'Create snapshot') do |v| + session_opts[:snapshot] = v + end + parser.on('--snapshot-name NAME', 'Name for snapshot') do |v| + session_opts[:snapshot_name] = v + end + parser.on('--hot', 'Live snapshot (no freeze)') do + session_opts[:hot] = true + end + parser.on('--tmux', 'Enable persistence with tmux') do + session_opts[:tmux] = true + end + parser.on('--screen', 'Enable persistence with screen') do + session_opts[:screen] = true + end + parser.on('--shell SHELL', 'Shell/REPL to use') do |v| + session_opts[:shell] = v + end + parser.on('--audit', 'Record session') do + session_opts[:audit] = true + end + + parser.parse!(ARGV) + + creds = { public_key: options[:public_key], secret_key: options[:secret_key] } + + if session_opts[:list] + sessions = list_sessions(**creds).value + cli_print_sessions_table(sessions) + elsif session_opts[:kill] + delete_session(session_opts[:kill], **creds).value + puts "Session #{session_opts[:kill]} terminated" + elsif session_opts[:freeze] + freeze_session(session_opts[:freeze], **creds).value + puts "Session #{session_opts[:freeze]} frozen" + elsif session_opts[:unfreeze] + unfreeze_session(session_opts[:unfreeze], **creds).value + puts "Session #{session_opts[:unfreeze]} unfrozen" + elsif session_opts[:boost] + boost_session(session_opts[:boost], **creds).value + puts "Session #{session_opts[:boost]} boosted" + elsif session_opts[:unboost] + unboost_session(session_opts[:unboost], **creds).value + puts "Session #{session_opts[:unboost]} unboosted" + elsif session_opts[:snapshot] + snapshot_id = session_snapshot( + session_opts[:snapshot], + name: session_opts[:snapshot_name], + ephemeral: session_opts[:hot], + **creds + ).value + puts "Snapshot created: #{snapshot_id}" + elsif session_opts[:attach] + # Attach to existing session - show info + session = get_session(session_opts[:attach], **creds).value + puts "Session: #{session['id']}" + puts "Status: #{session['status']}" + puts "WebSocket URL: #{session['websocket_url']}" if session['websocket_url'] + puts "\nNote: Use a WebSocket client to connect interactively" + else + # Create new session + multiplexer = nil + multiplexer = 'tmux' if session_opts[:tmux] + multiplexer = 'screen' if session_opts[:screen] + + result = create_session( + session_opts[:shell], + network_mode: options[:network], + vcpu: options[:vcpu], + multiplexer: multiplexer, + **creds + ).value + puts "Session created: #{result['session_id']}" + puts "Container: #{result['container_name']}" if result['container_name'] + puts "WebSocket URL: #{result['websocket_url']}" if result['websocket_url'] + puts "\nNote: Use a WebSocket client to connect interactively" + end + end + + # Print sessions table + def cli_print_sessions_table(sessions) + if sessions.empty? + puts 'No active sessions' + return + end + + # Header + puts format('%-38s %-20s %-10s %-20s', 'ID', 'NAME', 'STATUS', 'CREATED') + sessions.each do |s| + puts format('%-38s %-20s %-10s %-20s', + s['id'] || s['session_id'] || '-', + s['name'] || '-', + s['status'] || s['state'] || '-', + s['created_at'] || '-') + end + end + + # Session help + def cli_session_help + puts <<~HELP + Session Management + + Usage: + ruby un_async.rb session [options] + + Options: + --shell SHELL Shell/REPL to use (default: bash) + -l, --list List active sessions + --attach ID Reconnect to existing session + --kill ID Terminate a session + --freeze ID Pause session + --unfreeze ID Resume session + --boost ID Add vCPUs/RAM + --unboost ID Remove boost + --tmux Enable persistence with tmux + --screen Enable persistence with screen + --snapshot ID Create snapshot + --snapshot-name NAME Name for snapshot + --hot Live snapshot (no freeze) + --audit Record session + + Examples: + ruby un_async.rb session # New bash session + ruby un_async.rb session --shell python3 # Python REPL + ruby un_async.rb session --tmux # Persistent session + ruby un_async.rb session --list # List sessions + ruby un_async.rb session --kill abc123 # Kill session + HELP + end + + # Service subcommand + def cli_service(options) + service_opts = { + list: false, + name: nil, + ports: nil, + domains: nil, + type: nil, + bootstrap: nil, + bootstrap_file: nil, + env_file: nil, + info: nil, + logs: nil, + tail: nil, + freeze: nil, + unfreeze: nil, + destroy: nil, + lock: nil, + unlock: nil, + resize: nil, + redeploy: nil, + execute: nil, + execute_cmd: nil, + snapshot: nil, + snapshot_name: nil + } + + parser = parse_global_options(options) do + cli_service_help + end + + parser.on('-l', '--list', 'List all services') do + service_opts[:list] = true + end + parser.on('--name NAME', 'Service name (creates new)') do |v| + service_opts[:name] = v + end + parser.on('--ports PORTS', 'Comma-separated ports') do |v| + service_opts[:ports] = v.split(',').map(&:to_i) + end + parser.on('--domains DOMAINS', 'Custom domains') do |v| + service_opts[:domains] = v.split(',') + end + parser.on('--type TYPE', 'Service type (minecraft, tcp, udp)') do |v| + service_opts[:type] = v + end + parser.on('--bootstrap CMD', 'Bootstrap command') do |v| + service_opts[:bootstrap] = v + end + parser.on('--bootstrap-file FILE', 'Bootstrap from file') do |v| + service_opts[:bootstrap_file] = v + end + parser.on('--env-file FILE', 'Load env from .env file') do |v| + service_opts[:env_file] = v + end + parser.on('--info ID', 'Get service details') do |v| + service_opts[:info] = v + end + parser.on('--logs ID', 'Get all logs') do |v| + service_opts[:logs] = v + end + parser.on('--tail ID', 'Get last 9000 lines') do |v| + service_opts[:tail] = v + end + parser.on('--freeze ID', 'Pause service') do |v| + service_opts[:freeze] = v + end + parser.on('--unfreeze ID', 'Resume service') do |v| + service_opts[:unfreeze] = v + end + parser.on('--destroy ID', 'Delete service') do |v| + service_opts[:destroy] = v + end + parser.on('--lock ID', 'Prevent deletion') do |v| + service_opts[:lock] = v + end + parser.on('--unlock ID', 'Allow deletion') do |v| + service_opts[:unlock] = v + end + parser.on('--resize ID', 'Resize (with --vcpu)') do |v| + service_opts[:resize] = v + end + parser.on('--redeploy ID', 'Re-run bootstrap') do |v| + service_opts[:redeploy] = v + end + parser.on('--execute ID', 'Run command in service') do |v| + service_opts[:execute] = v + end + parser.on('--snapshot ID', 'Create snapshot') do |v| + service_opts[:snapshot] = v + end + parser.on('--snapshot-name NAME', 'Name for snapshot') do |v| + service_opts[:snapshot_name] = v + end + + parser.parse!(ARGV) + + # Check for env subcommand + if ARGV[0] == 'env' + ARGV.shift + cli_service_env(options, service_opts) + return + end + + # Get command argument for execute + service_opts[:execute_cmd] = ARGV.join(' ') if service_opts[:execute] && !ARGV.empty? + + creds = { public_key: options[:public_key], secret_key: options[:secret_key] } + + if service_opts[:list] + services = list_services(**creds).value + cli_print_services_table(services) + elsif service_opts[:info] + service = get_service(service_opts[:info], **creds).value + cli_print_service_info(service) + elsif service_opts[:logs] + result = get_service_logs(service_opts[:logs], all: true, **creds).value + puts result['log'] || result['logs'] || '' + elsif service_opts[:tail] + result = get_service_logs(service_opts[:tail], all: false, **creds).value + puts result['log'] || result['logs'] || '' + elsif service_opts[:freeze] + freeze_service(service_opts[:freeze], **creds).value + puts "Service #{service_opts[:freeze]} frozen" + elsif service_opts[:unfreeze] + unfreeze_service(service_opts[:unfreeze], **creds).value + puts "Service #{service_opts[:unfreeze]} unfrozen" + elsif service_opts[:destroy] + delete_service(service_opts[:destroy], **creds).value + puts "Service #{service_opts[:destroy]} destroyed" + elsif service_opts[:lock] + lock_service(service_opts[:lock], **creds).value + puts "Service #{service_opts[:lock]} locked" + elsif service_opts[:unlock] + unlock_service(service_opts[:unlock], **creds).value + puts "Service #{service_opts[:unlock]} unlocked" + elsif service_opts[:resize] + update_service(service_opts[:resize], vcpu: options[:vcpu], **creds).value + puts "Service #{service_opts[:resize]} resized to #{options[:vcpu]} vCPUs" + elsif service_opts[:redeploy] + bootstrap = nil + if service_opts[:bootstrap_file] + bootstrap = File.read(service_opts[:bootstrap_file]) + elsif service_opts[:bootstrap] + bootstrap = service_opts[:bootstrap] + end + redeploy_service(service_opts[:redeploy], bootstrap: bootstrap, **creds).value + puts "Service #{service_opts[:redeploy]} redeployed" + elsif service_opts[:execute] + cmd = service_opts[:execute_cmd] + if cmd.nil? || cmd.empty? + $stderr.puts 'Error: No command provided for --execute' + exit(EXIT_INVALID_ARGS) + end + result = execute_in_service(service_opts[:execute], cmd, **creds).value + cli_print_execute_result(result) + elsif service_opts[:snapshot] + snapshot_id = service_snapshot( + service_opts[:snapshot], + name: service_opts[:snapshot_name], + **creds + ).value + puts "Snapshot created: #{snapshot_id}" + elsif service_opts[:name] + # Create new service + bootstrap = service_opts[:bootstrap] + if service_opts[:bootstrap_file] + bootstrap = File.read(service_opts[:bootstrap_file]) + end + + unless bootstrap + $stderr.puts 'Error: --bootstrap or --bootstrap-file required' + exit(EXIT_INVALID_ARGS) + end + unless service_opts[:ports] + $stderr.puts 'Error: --ports required' + exit(EXIT_INVALID_ARGS) + end + + result = create_service( + service_opts[:name], + service_opts[:ports], + bootstrap, + network_mode: options[:network], + vcpu: options[:vcpu], + custom_domains: service_opts[:domains], + service_type: service_opts[:type], + **creds + ).value + puts "Service created: #{result['service_id']}" + puts "URL: #{result['url']}" if result['url'] + else + cli_service_help + exit(EXIT_INVALID_ARGS) + end + end + + # Print services table + def cli_print_services_table(services) + if services.empty? + puts 'No services' + return + end + + puts format('%-38s %-20s %-10s %-20s', 'ID', 'NAME', 'STATUS', 'CREATED') + services.each do |s| + puts format('%-38s %-20s %-10s %-20s', + s['id'] || s['service_id'] || '-', + s['name'] || '-', + s['status'] || s['state'] || '-', + s['created_at'] || '-') + end + end + + # Print service info + def cli_print_service_info(service) + puts "ID: #{service['id'] || service['service_id']}" + puts "Name: #{service['name']}" + puts "Status: #{service['status'] || service['state']}" + puts "URL: #{service['url']}" if service['url'] + puts "Ports: #{service['ports']&.join(', ')}" if service['ports'] + puts "vCPU: #{service['vcpu']}" if service['vcpu'] + puts "Network: #{service['network_mode']}" if service['network_mode'] + puts "Created: #{service['created_at']}" if service['created_at'] + puts "Locked: #{service['locked']}" if service.key?('locked') + end + + # Service help + def cli_service_help + puts <<~HELP + Service Management + + Usage: + ruby un_async.rb service [options] + ruby un_async.rb service env + + Options: + --name NAME Service name (creates new) + --ports PORTS Comma-separated ports + --domains DOMAINS Custom domains + --type TYPE Service type (minecraft, tcp, udp) + --bootstrap CMD Bootstrap command + --bootstrap-file FILE Bootstrap from file + --env-file FILE Load env from .env file + -l, --list List all services + --info ID Get service details + --logs ID Get all logs + --tail ID Get last 9000 lines + --freeze ID Pause service + --unfreeze ID Resume service + --destroy ID Delete service + --lock ID Prevent deletion + --unlock ID Allow deletion + --resize ID Resize (with --vcpu) + --redeploy ID Re-run bootstrap + --execute ID 'cmd' Run command in service + --snapshot ID Create snapshot + + Env Subcommands: + ruby un_async.rb service env status ID Show vault status + ruby un_async.rb service env set ID Set from --env-file or stdin + ruby un_async.rb service env export ID Export to stdout + ruby un_async.rb service env delete ID Delete vault + + Examples: + ruby un_async.rb service --name web --ports 80 --bootstrap "python -m http.server 80" + ruby un_async.rb service --list + ruby un_async.rb service --logs abc123 + ruby un_async.rb service --execute abc123 'ls -la' + ruby un_async.rb service env status abc123 + HELP + end + + # Service env subcommand + def cli_service_env(options, service_opts) + if ARGV.empty? + $stderr.puts 'Error: env subcommand requires: status, set, export, or delete' + exit(EXIT_INVALID_ARGS) + end + + cmd = ARGV.shift + service_id = ARGV.shift + + unless service_id + $stderr.puts 'Error: Service ID required' + exit(EXIT_INVALID_ARGS) + end + + creds = { public_key: options[:public_key], secret_key: options[:secret_key] } + + case cmd + when 'status' + result = get_service_env(service_id, **creds).value + puts "Has vault: #{result['has_vault'] || false}" + puts "Variables: #{result['count'] || 0}" + puts "Updated: #{result['updated_at']}" if result['updated_at'] + when 'set' + env_content = nil + if service_opts[:env_file] + env_content = File.read(service_opts[:env_file]) + elsif !$stdin.tty? + env_content = $stdin.read + else + $stderr.puts 'Error: Provide --env-file or pipe content to stdin' + exit(EXIT_INVALID_ARGS) + end + set_service_env(service_id, env_content, **creds).value + puts "Environment vault updated for #{service_id}" + when 'export' + result = export_service_env(service_id, **creds).value + puts result['env'] || '' + when 'delete' + delete_service_env(service_id, **creds).value + puts "Environment vault deleted for #{service_id}" + else + $stderr.puts "Error: Unknown env command: #{cmd}" + exit(EXIT_INVALID_ARGS) + end + end + + # Snapshot subcommand + def cli_snapshot(options) + snapshot_opts = { + list: false, + info: nil, + delete: nil, + lock: nil, + unlock: nil, + clone: nil, + clone_type: 'session', + clone_name: nil, + clone_shell: nil, + clone_ports: nil + } + + parser = parse_global_options(options) do + cli_snapshot_help + end + + parser.on('-l', '--list', 'List all snapshots') do + snapshot_opts[:list] = true + end + parser.on('--info ID', 'Get snapshot details') do |v| + snapshot_opts[:info] = v + end + parser.on('--delete ID', 'Delete snapshot') do |v| + snapshot_opts[:delete] = v + end + parser.on('--lock ID', 'Prevent deletion') do |v| + snapshot_opts[:lock] = v + end + parser.on('--unlock ID', 'Allow deletion') do |v| + snapshot_opts[:unlock] = v + end + parser.on('--clone ID', 'Clone snapshot') do |v| + snapshot_opts[:clone] = v + end + parser.on('--type TYPE', 'Clone type: session or service') do |v| + snapshot_opts[:clone_type] = v + end + parser.on('--name NAME', 'Name for cloned resource') do |v| + snapshot_opts[:clone_name] = v + end + parser.on('--shell SHELL', 'Shell for cloned session') do |v| + snapshot_opts[:clone_shell] = v + end + parser.on('--ports PORTS', 'Ports for cloned service') do |v| + snapshot_opts[:clone_ports] = v.split(',').map(&:to_i) + end + + parser.parse!(ARGV) + + creds = { public_key: options[:public_key], secret_key: options[:secret_key] } + + if snapshot_opts[:list] + snapshots = list_snapshots(**creds).value + cli_print_snapshots_table(snapshots) + elsif snapshot_opts[:info] + # Get snapshot details via restore endpoint or list + snapshots = list_snapshots(**creds).value + snapshot = snapshots.find { |s| s['snapshot_id'] == snapshot_opts[:info] || s['id'] == snapshot_opts[:info] } + if snapshot + cli_print_snapshot_info(snapshot) + else + $stderr.puts "Error: Snapshot not found: #{snapshot_opts[:info]}" + exit(EXIT_ERROR) + end + elsif snapshot_opts[:delete] + delete_snapshot(snapshot_opts[:delete], **creds).value + puts "Snapshot #{snapshot_opts[:delete]} deleted" + elsif snapshot_opts[:lock] + lock_snapshot(snapshot_opts[:lock], **creds).value + puts "Snapshot #{snapshot_opts[:lock]} locked" + elsif snapshot_opts[:unlock] + unlock_snapshot(snapshot_opts[:unlock], **creds).value + puts "Snapshot #{snapshot_opts[:unlock]} unlocked" + elsif snapshot_opts[:clone] + result = clone_snapshot( + snapshot_opts[:clone], + type: snapshot_opts[:clone_type], + name: snapshot_opts[:clone_name], + shell: snapshot_opts[:clone_shell], + ports: snapshot_opts[:clone_ports], + **creds + ).value + if result['session_id'] + puts "Session created: #{result['session_id']}" + elsif result['service_id'] + puts "Service created: #{result['service_id']}" + else + puts 'Clone completed' + puts JSON.pretty_generate(result) + end + else + cli_snapshot_help + exit(EXIT_INVALID_ARGS) + end + end + + # Print snapshots table + def cli_print_snapshots_table(snapshots) + if snapshots.empty? + puts 'No snapshots' + return + end + + puts format('%-38s %-20s %-10s %-20s', 'ID', 'NAME', 'TYPE', 'CREATED') + snapshots.each do |s| + puts format('%-38s %-20s %-10s %-20s', + s['snapshot_id'] || s['id'] || '-', + s['name'] || '-', + s['type'] || s['source_type'] || '-', + s['created_at'] || '-') + end + end + + # Print snapshot info + def cli_print_snapshot_info(snapshot) + puts "ID: #{snapshot['snapshot_id'] || snapshot['id']}" + puts "Name: #{snapshot['name']}" if snapshot['name'] + puts "Type: #{snapshot['type'] || snapshot['source_type']}" + puts "Source ID: #{snapshot['source_id']}" if snapshot['source_id'] + puts "Size: #{snapshot['size']}" if snapshot['size'] + puts "Locked: #{snapshot['locked']}" if snapshot.key?('locked') + puts "Created: #{snapshot['created_at']}" if snapshot['created_at'] + end + + # Snapshot help + def cli_snapshot_help + puts <<~HELP + Snapshot Management + + Usage: + ruby un_async.rb snapshot [options] + + Options: + -l, --list List all snapshots + --info ID Get snapshot details + --delete ID Delete snapshot + --lock ID Prevent deletion + --unlock ID Allow deletion + --clone ID Clone snapshot + --type TYPE Clone type: session or service + --name NAME Name for cloned resource + --shell SHELL Shell for cloned session + --ports PORTS Ports for cloned service + + Examples: + ruby un_async.rb snapshot --list + ruby un_async.rb snapshot --clone abc123 --type service --name myapp --ports 80 + HELP + end + + # Key command + def cli_key(options) + parser = parse_global_options(options) do + puts 'Usage: ruby un_async.rb key [-p PUBLIC_KEY] [-k SECRET_KEY]' + puts + puts 'Check API key validity and show account info' + end + parser.parse!(ARGV) + + creds = { public_key: options[:public_key], secret_key: options[:secret_key] } + + begin + result = validate_keys(**creds).value + puts "Valid: #{result['valid']}" + puts "Account: #{result['account'] || result['account_id']}" if result['account'] || result['account_id'] + puts "Email: #{result['email']}" if result['email'] + puts "Plan: #{result['plan']}" if result['plan'] + puts "Credits: #{result['credits']}" if result['credits'] + rescue APIError => e + if e.status_code == 401 || e.status_code == 403 + puts 'Valid: false' + puts "Error: #{e.message}" + exit(EXIT_AUTH_ERROR) + end + raise + end + end + end +end + +# CLI entry point +if __FILE__ == $0 + UnAsync.cli_main end diff --git a/clients/ruby/sync/src/un.rb b/clients/ruby/sync/src/un.rb index f46fbf7..7bef484 100644 --- a/clients/ruby/sync/src/un.rb +++ b/clients/ruby/sync/src/un.rb @@ -436,6 +436,267 @@ module Un make_request('POST', "/snapshots/#{snapshot_id}/clone", pk, sk, data) end + # ============================================================================ + # Image Functions (LXD Container Images) + # ============================================================================ + + # Publish an LXD container image from a session or service + # + # @param source_type [String] Source type ("session" or "service") + # @param source_id [String] ID of the session or service to publish + # @param name [String, nil] Optional name for the image + # @param description [String, nil] Optional description for the image + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Response hash with image_id and other metadata + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example Publish from session + # result = Un.image_publish("session", session_id, name: "my-image") + # puts result["image_id"] + # + # @example Publish from service + # result = Un.image_publish("service", service_id, description: "Production snapshot") + # puts result["image_id"] + def image_publish(source_type, source_id, name: nil, description: nil, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + data = { source_type: source_type, source_id: source_id } + data[:name] = name if name + data[:description] = description if description + make_request('POST', '/images', pk, sk, data) + end + + # List all images for the authenticated account + # + # @param filter_type [String, nil] Optional filter: "own", "shared", or "public" + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Array] List of image hashes + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example List all images + # images = Un.list_images + # images.each { |img| puts "#{img['image_id']}: #{img['name']}" } + # + # @example List only owned images + # owned = Un.list_images(filter_type: "own") + # + # @example List shared images + # shared = Un.list_images(filter_type: "shared") + def list_images(filter_type: nil, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + path = filter_type ? "/images/#{filter_type}" : '/images' + response = make_request('GET', path, pk, sk) + response['images'] || [] + end + + # Get image details by ID + # + # @param image_id [String] Image ID to retrieve + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Image details hash + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # image = Un.get_image(image_id) + # puts "#{image['name']}: #{image['description']}" + def get_image(image_id, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + make_request('GET', "/images/#{image_id}", pk, sk) + end + + # Delete an image + # + # @param image_id [String] Image ID to delete + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Response hash with deletion confirmation + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # Un.delete_image(image_id) + def delete_image(image_id, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + make_request('DELETE', "/images/#{image_id}", pk, sk) + end + + # Lock an image to prevent deletion + # + # @param image_id [String] Image ID to lock + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Response hash with lock confirmation + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # Un.lock_image(image_id) + def lock_image(image_id, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + make_request('POST', "/images/#{image_id}/lock", pk, sk, {}) + end + + # Unlock an image to allow deletion + # + # @param image_id [String] Image ID to unlock + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Response hash with unlock confirmation + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # Un.unlock_image(image_id) + def unlock_image(image_id, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + make_request('POST', "/images/#{image_id}/unlock", pk, sk, {}) + end + + # Set image visibility (private, public, or shared) + # + # @param image_id [String] Image ID to update + # @param visibility [String] Visibility level: "private", "public", or "shared" + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Response hash with visibility confirmation + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example Make image public + # Un.set_image_visibility(image_id, "public") + # + # @example Make image private + # Un.set_image_visibility(image_id, "private") + def set_image_visibility(image_id, visibility, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + make_request('POST', "/images/#{image_id}/visibility", pk, sk, { visibility: visibility }) + end + + # Grant access to an image for another API key + # + # @param image_id [String] Image ID to share + # @param trusted_api_key [String] Public API key to grant access to + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Response hash with grant confirmation + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # Un.grant_image_access(image_id, "unsb-pk-xxxxx-xxxxx-xxxxx-xxxxx") + def grant_image_access(image_id, trusted_api_key, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + make_request('POST', "/images/#{image_id}/grant", pk, sk, { trusted_api_key: trusted_api_key }) + end + + # Revoke access to an image from another API key + # + # @param image_id [String] Image ID to revoke access from + # @param trusted_api_key [String] Public API key to revoke access from + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Response hash with revoke confirmation + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # Un.revoke_image_access(image_id, "unsb-pk-xxxxx-xxxxx-xxxxx-xxxxx") + def revoke_image_access(image_id, trusted_api_key, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + make_request('POST', "/images/#{image_id}/revoke", pk, sk, { trusted_api_key: trusted_api_key }) + end + + # List API keys with access to an image + # + # @param image_id [String] Image ID to list trusted keys for + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Array] List of trusted API key hashes + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # trusted = Un.list_image_trusted(image_id) + # trusted.each { |t| puts t["api_key"] } + def list_image_trusted(image_id, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + response = make_request('GET', "/images/#{image_id}/trusted", pk, sk) + response['trusted'] || [] + end + + # Transfer image ownership to another API key + # + # @param image_id [String] Image ID to transfer + # @param to_api_key [String] Public API key to transfer ownership to + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Response hash with transfer confirmation + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # Un.transfer_image(image_id, "unsb-pk-xxxxx-xxxxx-xxxxx-xxxxx") + def transfer_image(image_id, to_api_key, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + make_request('POST', "/images/#{image_id}/transfer", pk, sk, { to_api_key: to_api_key }) + end + + # Spawn a new service from an image + # + # @param image_id [String] Image ID to spawn from + # @param name [String, nil] Optional name for the service + # @param ports [Array, nil] Optional ports to expose + # @param bootstrap [String, nil] Optional bootstrap script + # @param network_mode [String] Network mode ("zerotrust" or "semitrusted", default: "zerotrust") + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Response hash with service_id and other metadata + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example Spawn with defaults + # result = Un.spawn_from_image(image_id) + # puts result["service_id"] + # + # @example Spawn with custom config + # result = Un.spawn_from_image(image_id, name: "web", ports: [80, 443], network_mode: "semitrusted") + # puts result["service_id"] + def spawn_from_image(image_id, name: nil, ports: nil, bootstrap: nil, network_mode: 'zerotrust', public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + data = { network_mode: network_mode } + data[:name] = name if name + data[:ports] = ports if ports + data[:bootstrap] = bootstrap if bootstrap + make_request('POST', "/images/#{image_id}/spawn", pk, sk, data) + end + + # Clone an image to create a new image + # + # @param image_id [String] Image ID to clone + # @param name [String, nil] Optional name for the cloned image + # @param description [String, nil] Optional description for the cloned image + # @param public_key [String, nil] Optional API key + # @param secret_key [String, nil] Optional API secret + # @return [Hash] Response hash with new image_id + # @raise [CredentialsError] If no credentials found + # @raise [APIError] If API request fails + # + # @example + # result = Un.clone_image(image_id, name: "my-clone", description: "Cloned from production") + # puts result["image_id"] + def clone_image(image_id, name: nil, description: nil, public_key: nil, secret_key: nil) + pk, sk = resolve_credentials(public_key, secret_key) + data = {} + data[:name] = name if name + data[:description] = description if description + make_request('POST', "/images/#{image_id}/clone", pk, sk, data) + end + # ============================================================================ # Session Functions # ============================================================================ diff --git a/clients/rust/async/Cargo.toml b/clients/rust/async/Cargo.toml index 136f0fc..40f8ced 100644 --- a/clients/rust/async/Cargo.toml +++ b/clients/rust/async/Cargo.toml @@ -20,6 +20,10 @@ categories = ["api-bindings", "development-tools", "asynchronous"] name = "un_async" path = "src/lib.rs" +[[bin]] +name = "un-async" +path = "src/main.rs" + [dependencies] # HTTP client (async) reqwest = { version = "0.12", features = ["json"] } diff --git a/clients/rust/async/src/lib.rs b/clients/rust/async/src/lib.rs index 4622f0e..0a68f34 100644 --- a/clients/rust/async/src/lib.rs +++ b/clients/rust/async/src/lib.rs @@ -1891,6 +1891,1381 @@ pub async fn image(prompt: &str, creds: &Credentials, opts: Option make_request("POST", "/image", creds, Some(&payload)).await } +// ============================================================================= +// CLI Exit Codes +// ============================================================================= + +/// Exit code for success +pub const EXIT_SUCCESS: i32 = 0; +/// Exit code for general error +pub const EXIT_ERROR: i32 = 1; +/// Exit code for invalid arguments +pub const EXIT_INVALID_ARGS: i32 = 2; +/// Exit code for authentication error +pub const EXIT_AUTH_ERROR: i32 = 3; +/// Exit code for API error +pub const EXIT_API_ERROR: i32 = 4; +/// Exit code for timeout +pub const EXIT_TIMEOUT: i32 = 5; + +// ============================================================================= +// CLI Implementation +// ============================================================================= + +/// CLI options parsed from command line arguments +#[derive(Debug, Default)] +struct CliOptions { + // Global options + shell: Option, // -s, --shell + env_vars: Vec<(String, String)>, // -e, --env + files: Vec, // -f, --file + file_paths: Vec, // -F, --file-path + artifacts: bool, // -a, --artifacts + output_dir: Option, // -o, --output + public_key: Option, // -p, --public-key + secret_key: Option, // -k, --secret-key + network: Option, // -n, --network + vcpu: Option, // -v, --vcpu + yes: bool, // -y, --yes + help: bool, // -h, --help + + // Command and positional args + command: Option, + subcommand: Option, + positional: Vec, + + // Session options + session_list: bool, + session_attach: Option, + session_kill: Option, + session_freeze: Option, + session_unfreeze: Option, + session_boost: Option, + session_unboost: Option, + session_snapshot: Option, + session_tmux: bool, + session_screen: bool, + snapshot_name: Option, + snapshot_hot: bool, + audit: bool, + + // Service options + service_list: bool, + service_name: Option, + service_ports: Option, + service_domains: Option, + service_type: Option, + service_bootstrap: Option, + service_bootstrap_file: Option, + service_env_file: Option, + service_info: Option, + service_logs: Option, + service_tail: Option, + service_freeze: Option, + service_unfreeze: Option, + service_destroy: Option, + service_lock: Option, + service_unlock: Option, + service_resize: Option, + service_redeploy: Option, + service_execute: Option, + service_execute_cmd: Option, + service_snapshot: Option, + + // Snapshot options + snapshot_list: bool, + snapshot_info: Option, + snapshot_delete: Option, + snapshot_lock: Option, + snapshot_unlock: Option, + snapshot_clone: Option, + clone_type: Option, + clone_name: Option, + clone_shell: Option, + clone_ports: Option, +} + +fn print_help() { + println!("un - unsandbox.com CLI (Rust async) + +USAGE: + un [OPTIONS] Execute code file + un [OPTIONS] -s LANG 'code' Execute inline code + un session [OPTIONS] Interactive session + un service [OPTIONS] Manage services + un snapshot [OPTIONS] Manage snapshots + un key Check API key + +GLOBAL OPTIONS: + -s, --shell LANG Language for inline code execution + -e, --env KEY=VAL Set environment variable (can repeat) + -f, --file FILE Add input file to /tmp/ + -F, --file-path FILE Add input file with path preserved + -a, --artifacts Return compiled artifacts + -o, --output DIR Output directory for artifacts + -p, --public-key KEY API public key + -k, --secret-key KEY API secret key + -n, --network MODE Network mode: zerotrust or semitrusted + -v, --vcpu N vCPU count (1-8) + -y, --yes Skip confirmation prompts + -h, --help Show this help + +SESSION COMMANDS: + un session Start interactive bash session + un session --shell python3 Start Python REPL + un session --tmux Persistent session with tmux + un session --screen Persistent session with screen + un session --list List active sessions + un session --attach ID Reconnect to session + un session --kill ID Terminate session + un session --freeze ID Pause session + un session --unfreeze ID Resume session + un session --boost ID Add resources + un session --unboost ID Remove boost + un session --snapshot ID Create snapshot + +SERVICE COMMANDS: + un service --list List all services + un service --name NAME --ports P Create service + un service --info ID Get service details + un service --logs ID Get all logs + un service --tail ID Get last 9000 lines + un service --freeze ID Pause service + un service --unfreeze ID Resume service + un service --destroy ID Delete service + un service --lock ID Prevent deletion + un service --unlock ID Allow deletion + un service --execute ID 'cmd' Run command + un service --redeploy ID Re-run bootstrap + un service --snapshot ID Create snapshot + un service env status ID Show vault status + un service env set ID Set env vars + un service env export ID Export env vars + un service env delete ID Delete vault + +SNAPSHOT COMMANDS: + un snapshot --list List all snapshots + un snapshot --info ID Get snapshot details + un snapshot --delete ID Delete snapshot + un snapshot --lock ID Prevent deletion + un snapshot --unlock ID Allow deletion + un snapshot --clone ID Clone snapshot + +EXAMPLES: + un script.py Execute Python script + un -s bash 'echo hello' Run inline bash command + un -n semitrusted crawler.py Execute with network access + un session --tmux Start persistent session + un service --name web --ports 80 --bootstrap 'python -m http.server 80' +"); +} + +fn parse_args(args: &[String]) -> CliOptions { + let mut opts = CliOptions::default(); + let mut i = 1; // Skip program name + + while i < args.len() { + let arg = &args[i]; + + match arg.as_str() { + "-h" | "--help" => { + opts.help = true; + i += 1; + } + "-s" | "--shell" => { + if i + 1 < args.len() { + opts.shell = Some(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + "-e" | "--env" => { + if i + 1 < args.len() { + let kv = &args[i + 1]; + if let Some(pos) = kv.find('=') { + let key = kv[..pos].to_string(); + let val = kv[pos + 1..].to_string(); + opts.env_vars.push((key, val)); + } + i += 2; + } else { + i += 1; + } + } + "-f" | "--file" => { + if i + 1 < args.len() { + opts.files.push(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + "-F" | "--file-path" => { + if i + 1 < args.len() { + opts.file_paths.push(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + "-a" | "--artifacts" => { + opts.artifacts = true; + i += 1; + } + "-o" | "--output" => { + if i + 1 < args.len() { + opts.output_dir = Some(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + "-p" | "--public-key" => { + if i + 1 < args.len() { + opts.public_key = Some(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + "-k" | "--secret-key" => { + if i + 1 < args.len() { + opts.secret_key = Some(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + "-n" | "--network" => { + if i + 1 < args.len() { + opts.network = Some(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + "-v" | "--vcpu" => { + if i + 1 < args.len() { + opts.vcpu = args[i + 1].parse().ok(); + i += 2; + } else { + i += 1; + } + } + "-y" | "--yes" => { + opts.yes = true; + i += 1; + } + "-l" | "--list" => { + // Used by session, service, snapshot + opts.session_list = true; + opts.service_list = true; + opts.snapshot_list = true; + i += 1; + } + "--attach" => { + if i + 1 < args.len() { + opts.session_attach = Some(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + "--kill" => { + if i + 1 < args.len() { + opts.session_kill = Some(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + "--freeze" => { + if i + 1 < args.len() { + // Context-dependent: session or service + opts.session_freeze = Some(args[i + 1].clone()); + opts.service_freeze = Some(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + "--unfreeze" => { + if i + 1 < args.len() { + opts.session_unfreeze = Some(args[i + 1].clone()); + opts.service_unfreeze = Some(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + "--boost" => { + if i + 1 < args.len() { + opts.session_boost = Some(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + "--unboost" => { + if i + 1 < args.len() { + opts.session_unboost = Some(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + "--tmux" => { + opts.session_tmux = true; + i += 1; + } + "--screen" => { + opts.session_screen = true; + i += 1; + } + "--snapshot" => { + if i + 1 < args.len() { + opts.session_snapshot = Some(args[i + 1].clone()); + opts.service_snapshot = Some(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + "--snapshot-name" => { + if i + 1 < args.len() { + opts.snapshot_name = Some(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + "--hot" => { + opts.snapshot_hot = true; + i += 1; + } + "--audit" => { + opts.audit = true; + i += 1; + } + "--name" => { + if i + 1 < args.len() { + opts.service_name = Some(args[i + 1].clone()); + opts.clone_name = Some(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + "--ports" => { + if i + 1 < args.len() { + opts.service_ports = Some(args[i + 1].clone()); + opts.clone_ports = Some(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + "--domains" => { + if i + 1 < args.len() { + opts.service_domains = Some(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + "--type" => { + if i + 1 < args.len() { + opts.service_type = Some(args[i + 1].clone()); + opts.clone_type = Some(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + "--bootstrap" => { + if i + 1 < args.len() { + opts.service_bootstrap = Some(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + "--bootstrap-file" => { + if i + 1 < args.len() { + opts.service_bootstrap_file = Some(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + "--env-file" => { + if i + 1 < args.len() { + opts.service_env_file = Some(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + "--info" => { + if i + 1 < args.len() { + opts.service_info = Some(args[i + 1].clone()); + opts.snapshot_info = Some(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + "--logs" => { + if i + 1 < args.len() { + opts.service_logs = Some(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + "--tail" => { + if i + 1 < args.len() { + opts.service_tail = Some(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + "--destroy" => { + if i + 1 < args.len() { + opts.service_destroy = Some(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + "--lock" => { + if i + 1 < args.len() { + opts.service_lock = Some(args[i + 1].clone()); + opts.snapshot_lock = Some(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + "--unlock" => { + if i + 1 < args.len() { + opts.service_unlock = Some(args[i + 1].clone()); + opts.snapshot_unlock = Some(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + "--resize" => { + if i + 1 < args.len() { + opts.service_resize = Some(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + "--redeploy" => { + if i + 1 < args.len() { + opts.service_redeploy = Some(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + "--execute" => { + if i + 1 < args.len() { + opts.service_execute = Some(args[i + 1].clone()); + if i + 2 < args.len() && !args[i + 2].starts_with('-') { + opts.service_execute_cmd = Some(args[i + 2].clone()); + i += 3; + } else { + i += 2; + } + } else { + i += 1; + } + } + "--delete" => { + if i + 1 < args.len() { + opts.snapshot_delete = Some(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + "--clone" => { + if i + 1 < args.len() { + opts.snapshot_clone = Some(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + _ => { + // Positional argument or subcommand + if opts.command.is_none() && !arg.starts_with('-') { + match arg.as_str() { + "session" | "service" | "snapshot" | "key" | "env" => { + if opts.command.is_some() { + opts.subcommand = Some(arg.clone()); + } else { + opts.command = Some(arg.clone()); + } + } + _ => { + opts.positional.push(arg.clone()); + } + } + } else if !arg.starts_with('-') { + opts.positional.push(arg.clone()); + } + i += 1; + } + } + } + + opts +} + +fn get_credentials(opts: &CliOptions) -> Result { + resolve_credentials( + opts.public_key.as_deref(), + opts.secret_key.as_deref(), + ) +} + +fn format_session_list(sessions: &[Session]) { + if sessions.is_empty() { + println!("No active sessions."); + return; + } + println!("{:<40} {:<20} {:<10} {}", "ID", "NAME", "STATUS", "CREATED"); + for s in sessions { + println!( + "{:<40} {:<20} {:<10} {}", + s.session_id, s.container_name, s.status, s.created_at + ); + } +} + +fn format_service_list(services: &[Service]) { + if services.is_empty() { + println!("No active services."); + return; + } + println!("{:<40} {:<20} {:<10} {}", "ID", "NAME", "STATUS", "URL"); + for s in services { + println!( + "{:<40} {:<20} {:<10} {}", + s.service_id, s.name, s.status, s.url + ); + } +} + +fn format_snapshot_list(snapshots: &[Snapshot]) { + if snapshots.is_empty() { + println!("No snapshots."); + return; + } + println!("{:<40} {:<20} {:<10} {}", "ID", "NAME", "TYPE", "CREATED"); + for s in snapshots { + println!( + "{:<40} {:<20} {:<10} {}", + s.snapshot_id, s.name, s.source_type, s.created_at + ); + } +} + +async fn cmd_execute(opts: &CliOptions) -> i32 { + let creds = match get_credentials(opts) { + Ok(c) => c, + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_AUTH_ERROR; + } + }; + + // Determine language and code + let (language, code) = if let Some(ref shell) = opts.shell { + // Inline code: -s LANG 'code' + let code = opts.positional.first().cloned().unwrap_or_default(); + (shell.clone(), code) + } else if let Some(ref file) = opts.positional.first() { + // File execution + let lang = match detect_language(file) { + Some(l) => l.to_string(), + None => { + eprintln!("Error: Cannot detect language from file: {}", file); + return EXIT_INVALID_ARGS; + } + }; + let code = match fs::read_to_string(file).await { + Ok(c) => c, + Err(e) => { + eprintln!("Error: Cannot read file '{}': {}", file, e); + return EXIT_ERROR; + } + }; + (lang, code) + } else { + eprintln!("Error: No source file or inline code provided"); + print_help(); + return EXIT_INVALID_ARGS; + }; + + // Execute the code + match execute_code(&language, &code, &creds).await { + Ok(result) => { + print!("{}", result.output); + println!("---"); + println!("Exit code: {}", result.exit_code); + println!("Execution time: {}ms", result.execution_time_ms); + if result.exit_code != 0 { + EXIT_ERROR + } else { + EXIT_SUCCESS + } + } + Err(e) => { + eprintln!("Error: {}", e); + match e { + UnsandboxError::Timeout(_) => EXIT_TIMEOUT, + UnsandboxError::ApiError { .. } => EXIT_API_ERROR, + UnsandboxError::NoCredentials => EXIT_AUTH_ERROR, + _ => EXIT_ERROR, + } + } + } +} + +async fn cmd_session(opts: &CliOptions) -> i32 { + let creds = match get_credentials(opts) { + Ok(c) => c, + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_AUTH_ERROR; + } + }; + + // Handle session subcommands + if opts.session_list { + match list_sessions(&creds).await { + Ok(sessions) => { + format_session_list(&sessions); + return EXIT_SUCCESS; + } + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_API_ERROR; + } + } + } + + if let Some(ref id) = opts.session_attach { + match get_session(id, &creds).await { + Ok(session) => { + println!("Session: {}", session.session_id); + println!("Container: {}", session.container_name); + println!("Status: {}", session.status); + println!("Shell: {}", session.shell); + println!("\nNote: Interactive attach requires terminal support not available in this SDK."); + println!("Use 'un session --attach {}' with the C CLI for full functionality.", id); + return EXIT_SUCCESS; + } + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_API_ERROR; + } + } + } + + if let Some(ref id) = opts.session_kill { + match delete_session(id, &creds).await { + Ok(()) => { + println!("Session {} terminated.", id); + return EXIT_SUCCESS; + } + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_API_ERROR; + } + } + } + + if let Some(ref id) = opts.session_freeze { + match freeze_session(id, &creds).await { + Ok(session) => { + println!("Session {} frozen.", session.session_id); + return EXIT_SUCCESS; + } + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_API_ERROR; + } + } + } + + if let Some(ref id) = opts.session_unfreeze { + match unfreeze_session(id, &creds).await { + Ok(session) => { + println!("Session {} unfrozen.", session.session_id); + return EXIT_SUCCESS; + } + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_API_ERROR; + } + } + } + + if let Some(ref id) = opts.session_boost { + match boost_session(id, &creds).await { + Ok(session) => { + println!("Session {} boosted.", session.session_id); + return EXIT_SUCCESS; + } + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_API_ERROR; + } + } + } + + if let Some(ref id) = opts.session_unboost { + match unboost_session(id, &creds).await { + Ok(session) => { + println!("Session {} unboosted.", session.session_id); + return EXIT_SUCCESS; + } + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_API_ERROR; + } + } + } + + if let Some(ref id) = opts.session_snapshot { + match session_snapshot(id, &creds, opts.snapshot_name.as_deref(), opts.snapshot_hot).await { + Ok(snapshot) => { + println!("Snapshot created: {}", snapshot.snapshot_id); + if !snapshot.name.is_empty() { + println!("Name: {}", snapshot.name); + } + return EXIT_SUCCESS; + } + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_API_ERROR; + } + } + } + + // Create new session + let shell = opts.shell.clone().unwrap_or_else(|| "bash".to_string()); + let session_opts = SessionCreateOptions { + network_mode: opts.network.clone(), + shell: Some(shell.clone()), + vcpu: opts.vcpu, + tmux: if opts.session_tmux { Some(true) } else { None }, + screen: if opts.session_screen { Some(true) } else { None }, + }; + + match create_session(&shell, &creds, Some(session_opts)).await { + Ok(session) => { + println!("Session created: {}", session.session_id); + println!("Container: {}", session.container_name); + println!("Status: {}", session.status); + println!("\nNote: Interactive session requires terminal support not available in this SDK."); + println!("Use the C CLI for interactive sessions."); + EXIT_SUCCESS + } + Err(e) => { + eprintln!("Error: {}", e); + EXIT_API_ERROR + } + } +} + +async fn cmd_service(opts: &CliOptions) -> i32 { + let creds = match get_credentials(opts) { + Ok(c) => c, + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_AUTH_ERROR; + } + }; + + // Handle service env subcommand + if opts.command.as_deref() == Some("service") { + if let Some(ref subcmd) = opts.subcommand { + if subcmd == "env" { + return cmd_service_env(opts, &creds).await; + } + } + // Check if first positional is "env" + if opts.positional.first().map(|s| s.as_str()) == Some("env") { + return cmd_service_env(opts, &creds).await; + } + } + + // Handle service subcommands + if opts.service_list { + match list_services(&creds).await { + Ok(services) => { + format_service_list(&services); + return EXIT_SUCCESS; + } + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_API_ERROR; + } + } + } + + if let Some(ref id) = opts.service_info { + match get_service(id, &creds).await { + Ok(service) => { + println!("Service ID: {}", service.service_id); + println!("Name: {}", service.name); + println!("Status: {}", service.status); + println!("URL: {}", service.url); + println!("Ports: {:?}", service.ports); + println!("Domains: {:?}", service.domains); + println!("vCPU: {}", service.vcpu); + println!("Memory: {} MB", service.memory_mb); + println!("Locked: {}", service.locked); + println!("Created: {}", service.created_at); + return EXIT_SUCCESS; + } + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_API_ERROR; + } + } + } + + if let Some(ref id) = opts.service_logs { + match get_service_logs(id, true, &creds).await { + Ok(logs) => { + print!("{}", logs); + return EXIT_SUCCESS; + } + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_API_ERROR; + } + } + } + + if let Some(ref id) = opts.service_tail { + match get_service_logs(id, false, &creds).await { + Ok(logs) => { + print!("{}", logs); + return EXIT_SUCCESS; + } + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_API_ERROR; + } + } + } + + if let Some(ref id) = opts.service_freeze { + match freeze_service(id, &creds).await { + Ok(service) => { + println!("Service {} frozen.", service.service_id); + return EXIT_SUCCESS; + } + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_API_ERROR; + } + } + } + + if let Some(ref id) = opts.service_unfreeze { + match unfreeze_service(id, &creds).await { + Ok(service) => { + println!("Service {} unfrozen.", service.service_id); + return EXIT_SUCCESS; + } + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_API_ERROR; + } + } + } + + if let Some(ref id) = opts.service_destroy { + match delete_service(id, &creds).await { + Ok(()) => { + println!("Service {} destroyed.", id); + return EXIT_SUCCESS; + } + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_API_ERROR; + } + } + } + + if let Some(ref id) = opts.service_lock { + match lock_service(id, &creds).await { + Ok(service) => { + println!("Service {} locked.", service.service_id); + return EXIT_SUCCESS; + } + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_API_ERROR; + } + } + } + + if let Some(ref id) = opts.service_unlock { + match unlock_service(id, &creds).await { + Ok(service) => { + println!("Service {} unlocked.", service.service_id); + return EXIT_SUCCESS; + } + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_API_ERROR; + } + } + } + + if let Some(ref id) = opts.service_redeploy { + match redeploy_service(id, &creds).await { + Ok(service) => { + println!("Service {} redeployed.", service.service_id); + return EXIT_SUCCESS; + } + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_API_ERROR; + } + } + } + + if let Some(ref id) = opts.service_execute { + let cmd = opts.service_execute_cmd.clone().unwrap_or_default(); + if cmd.is_empty() { + eprintln!("Error: --execute requires a command"); + return EXIT_INVALID_ARGS; + } + match execute_in_service(id, &cmd, &creds).await { + Ok(result) => { + print!("{}", result.output); + return if result.exit_code == 0 { EXIT_SUCCESS } else { EXIT_ERROR }; + } + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_API_ERROR; + } + } + } + + if let Some(ref id) = opts.service_snapshot { + match service_snapshot(id, &creds, opts.snapshot_name.as_deref()).await { + Ok(snapshot) => { + println!("Snapshot created: {}", snapshot.snapshot_id); + if !snapshot.name.is_empty() { + println!("Name: {}", snapshot.name); + } + return EXIT_SUCCESS; + } + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_API_ERROR; + } + } + } + + // Create new service + if let Some(ref name) = opts.service_name { + let ports_str = opts.service_ports.clone().unwrap_or_default(); + let ports: Vec = ports_str + .split(',') + .filter_map(|s| s.trim().parse().ok()) + .collect(); + + if ports.is_empty() { + eprintln!("Error: --ports is required for service creation"); + return EXIT_INVALID_ARGS; + } + + let bootstrap = if let Some(ref cmd) = opts.service_bootstrap { + cmd.clone() + } else if let Some(ref file) = opts.service_bootstrap_file { + match fs::read_to_string(file).await { + Ok(content) => content, + Err(e) => { + eprintln!("Error: Cannot read bootstrap file '{}': {}", file, e); + return EXIT_ERROR; + } + } + } else { + String::new() + }; + + let service_opts = ServiceCreateOptions { + network_mode: opts.network.clone(), + vcpu: opts.vcpu, + domains: opts.service_domains.as_ref().map(|d| { + d.split(',').map(|s| s.trim().to_string()).collect() + }), + ..Default::default() + }; + + match create_service(name, &ports, &bootstrap, &creds, Some(service_opts)).await { + Ok(service) => { + println!("Service created: {}", service.service_id); + println!("Name: {}", service.name); + println!("URL: {}", service.url); + println!("Status: {}", service.status); + return EXIT_SUCCESS; + } + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_API_ERROR; + } + } + } + + // No action specified, show list + match list_services(&creds).await { + Ok(services) => { + format_service_list(&services); + EXIT_SUCCESS + } + Err(e) => { + eprintln!("Error: {}", e); + EXIT_API_ERROR + } + } +} + +async fn cmd_service_env(opts: &CliOptions, creds: &Credentials) -> i32 { + // Parse: un service env + // positional[0] = "env", positional[1] = action, positional[2] = service_id + let action = opts.positional.get(1).map(|s| s.as_str()).unwrap_or(""); + let service_id = opts.positional.get(2).cloned().unwrap_or_default(); + + if service_id.is_empty() && action != "status" { + eprintln!("Error: Service ID required"); + return EXIT_INVALID_ARGS; + } + + match action { + "status" => { + if service_id.is_empty() { + eprintln!("Error: Service ID required"); + return EXIT_INVALID_ARGS; + } + match get_service_env(&service_id, creds).await { + Ok(env) => { + if env.is_empty() { + println!("No environment variables set."); + } else { + println!("Environment variables ({} total):", env.len()); + for (k, _) in &env { + println!(" {}", k); + } + } + EXIT_SUCCESS + } + Err(e) => { + eprintln!("Error: {}", e); + EXIT_API_ERROR + } + } + } + "set" => { + // Read from --env-file or stdin + let content = if let Some(ref file) = opts.service_env_file { + match fs::read_to_string(file).await { + Ok(c) => c, + Err(e) => { + eprintln!("Error: Cannot read env file '{}': {}", file, e); + return EXIT_ERROR; + } + } + } else { + eprintln!("Error: --env-file required for 'set' command"); + return EXIT_INVALID_ARGS; + }; + + let mut env = HashMap::new(); + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some(pos) = line.find('=') { + let key = line[..pos].to_string(); + let val = line[pos + 1..].to_string(); + env.insert(key, val); + } + } + + match set_service_env(&service_id, &env, creds).await { + Ok(()) => { + println!("Environment variables set ({} variables).", env.len()); + EXIT_SUCCESS + } + Err(e) => { + eprintln!("Error: {}", e); + EXIT_API_ERROR + } + } + } + "export" => { + match export_service_env(&service_id, creds).await { + Ok(content) => { + print!("{}", content); + EXIT_SUCCESS + } + Err(e) => { + eprintln!("Error: {}", e); + EXIT_API_ERROR + } + } + } + "delete" => { + // Delete all env vars + match get_service_env(&service_id, creds).await { + Ok(env) => { + let keys: Vec<&str> = env.keys().map(|s| s.as_str()).collect(); + if keys.is_empty() { + println!("No environment variables to delete."); + return EXIT_SUCCESS; + } + match delete_service_env(&service_id, &keys, creds).await { + Ok(()) => { + println!("Environment vault deleted."); + EXIT_SUCCESS + } + Err(e) => { + eprintln!("Error: {}", e); + EXIT_API_ERROR + } + } + } + Err(e) => { + eprintln!("Error: {}", e); + EXIT_API_ERROR + } + } + } + _ => { + eprintln!("Error: Unknown env command '{}'. Use: status, set, export, delete", action); + EXIT_INVALID_ARGS + } + } +} + +async fn cmd_snapshot(opts: &CliOptions) -> i32 { + let creds = match get_credentials(opts) { + Ok(c) => c, + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_AUTH_ERROR; + } + }; + + if opts.snapshot_list { + match list_snapshots(&creds).await { + Ok(snapshots) => { + format_snapshot_list(&snapshots); + return EXIT_SUCCESS; + } + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_API_ERROR; + } + } + } + + if let Some(ref id) = opts.snapshot_info { + // Get snapshot details - reuse list and filter + match list_snapshots(&creds).await { + Ok(snapshots) => { + if let Some(snapshot) = snapshots.iter().find(|s| s.snapshot_id == *id) { + println!("Snapshot ID: {}", snapshot.snapshot_id); + println!("Name: {}", snapshot.name); + println!("Source Type: {}", snapshot.source_type); + println!("Source ID: {}", snapshot.source_id); + println!("Hot: {}", snapshot.hot); + println!("Size: {} bytes", snapshot.size_bytes); + println!("Created: {}", snapshot.created_at); + return EXIT_SUCCESS; + } else { + eprintln!("Error: Snapshot not found: {}", id); + return EXIT_API_ERROR; + } + } + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_API_ERROR; + } + } + } + + if let Some(ref id) = opts.snapshot_delete { + match delete_snapshot(id, &creds).await { + Ok(()) => { + println!("Snapshot {} deleted.", id); + return EXIT_SUCCESS; + } + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_API_ERROR; + } + } + } + + if let Some(ref id) = opts.snapshot_lock { + match lock_snapshot(id, &creds).await { + Ok(snapshot) => { + println!("Snapshot {} locked.", snapshot.snapshot_id); + return EXIT_SUCCESS; + } + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_API_ERROR; + } + } + } + + if let Some(ref id) = opts.snapshot_unlock { + match unlock_snapshot(id, &creds).await { + Ok(snapshot) => { + println!("Snapshot {} unlocked.", snapshot.snapshot_id); + return EXIT_SUCCESS; + } + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_API_ERROR; + } + } + } + + if let Some(ref id) = opts.snapshot_clone { + let name = opts.clone_name.clone().unwrap_or_else(|| format!("{}-clone", id)); + match clone_snapshot(id, &name, &creds).await { + Ok(snapshot) => { + println!("Snapshot cloned: {}", snapshot.snapshot_id); + println!("Name: {}", snapshot.name); + return EXIT_SUCCESS; + } + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_API_ERROR; + } + } + } + + // Default: list snapshots + match list_snapshots(&creds).await { + Ok(snapshots) => { + format_snapshot_list(&snapshots); + EXIT_SUCCESS + } + Err(e) => { + eprintln!("Error: {}", e); + EXIT_API_ERROR + } + } +} + +async fn cmd_key(opts: &CliOptions) -> i32 { + let creds = match get_credentials(opts) { + Ok(c) => c, + Err(e) => { + eprintln!("Error: {}", e); + return EXIT_AUTH_ERROR; + } + }; + + match validate_keys(&creds).await { + Ok(result) => { + if result.valid { + println!("API keys valid."); + println!("Account ID: {}", result.account_id); + if !result.email.is_empty() { + println!("Email: {}", result.email); + } + if !result.plan.is_empty() { + println!("Plan: {}", result.plan); + } + EXIT_SUCCESS + } else { + eprintln!("Error: Invalid API keys"); + if !result.error.is_empty() { + eprintln!("Details: {}", result.error); + } + EXIT_AUTH_ERROR + } + } + Err(e) => { + eprintln!("Error: {}", e); + EXIT_API_ERROR + } + } +} + +async fn cli_main_async() -> i32 { + let args: Vec = env::args().collect(); + let opts = parse_args(&args); + + if opts.help { + print_help(); + return EXIT_SUCCESS; + } + + // Dispatch based on command + match opts.command.as_deref() { + Some("session") => cmd_session(&opts).await, + Some("service") => cmd_service(&opts).await, + Some("snapshot") => cmd_snapshot(&opts).await, + Some("key") => cmd_key(&opts).await, + None => { + // Default: execute code + if opts.positional.is_empty() && opts.shell.is_none() { + print_help(); + EXIT_SUCCESS + } else { + cmd_execute(&opts).await + } + } + Some(cmd) => { + // Treat unknown command as file to execute + let mut new_opts = opts; + new_opts.positional.insert(0, cmd.to_string()); + new_opts.command = None; + cmd_execute(&new_opts).await + } + } +} + +/// CLI entry point. Call this from main() to run the CLI. +/// +/// This function creates a tokio runtime and runs the async CLI. +/// +/// # Examples +/// ```ignore +/// fn main() { +/// std::process::exit(un_async::cli_main()); +/// } +/// ``` +pub fn cli_main() -> i32 { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + rt.block_on(cli_main_async()) +} + // ============================================================================= // Tests // ============================================================================= @@ -1939,6 +3314,57 @@ mod tests { assert!(ts > 1700000000); } + #[test] + fn test_parse_args_help() { + let args = vec!["un".to_string(), "--help".to_string()]; + let opts = parse_args(&args); + assert!(opts.help); + } + + #[test] + fn test_parse_args_execute() { + let args = vec!["un".to_string(), "script.py".to_string()]; + let opts = parse_args(&args); + assert_eq!(opts.positional, vec!["script.py"]); + } + + #[test] + fn test_parse_args_inline() { + let args = vec![ + "un".to_string(), + "-s".to_string(), + "python".to_string(), + "print(1)".to_string(), + ]; + let opts = parse_args(&args); + assert_eq!(opts.shell, Some("python".to_string())); + assert_eq!(opts.positional, vec!["print(1)"]); + } + + #[test] + fn test_parse_args_session() { + let args = vec!["un".to_string(), "session".to_string(), "--list".to_string()]; + let opts = parse_args(&args); + assert_eq!(opts.command, Some("session".to_string())); + assert!(opts.session_list); + } + + #[test] + fn test_parse_args_service() { + let args = vec![ + "un".to_string(), + "service".to_string(), + "--name".to_string(), + "myapp".to_string(), + "--ports".to_string(), + "80".to_string(), + ]; + let opts = parse_args(&args); + assert_eq!(opts.command, Some("service".to_string())); + assert_eq!(opts.service_name, Some("myapp".to_string())); + assert_eq!(opts.service_ports, Some("80".to_string())); + } + #[tokio::test] async fn test_async_functions_compile() { // This test just verifies the async functions compile correctly diff --git a/clients/rust/async/src/main.rs b/clients/rust/async/src/main.rs new file mode 100644 index 0000000..591994f --- /dev/null +++ b/clients/rust/async/src/main.rs @@ -0,0 +1,16 @@ +// PUBLIC DOMAIN - NO LICENSE, NO WARRANTY +// +// unsandbox.com CLI (Rust Async) +// +// Binary entry point for the CLI. +// +// Usage: +// un script.py # Execute Python script +// un -s bash 'echo hello' # Execute inline code +// un session --list # List sessions +// un service --list # List services +// un key # Check API key + +fn main() { + std::process::exit(un_async::cli_main()); +} diff --git a/clients/rust/sync/Cargo.toml b/clients/rust/sync/Cargo.toml index 0c2d0de..9e918d9 100644 --- a/clients/rust/sync/Cargo.toml +++ b/clients/rust/sync/Cargo.toml @@ -20,6 +20,10 @@ categories = ["api-bindings", "development-tools"] name = "un" path = "src/lib.rs" +[[bin]] +name = "un" +path = "src/main.rs" + [dependencies] # HTTP client (blocking feature for sync) reqwest = { version = "0.12", features = ["blocking", "json"] } diff --git a/clients/rust/sync/src/lib.rs b/clients/rust/sync/src/lib.rs index f7bffbf..d224886 100644 --- a/clients/rust/sync/src/lib.rs +++ b/clients/rust/sync/src/lib.rs @@ -541,6 +541,16 @@ struct EnvExportResponse { content: String, } +#[derive(Debug, Deserialize)] +struct ImagesListResponse { + images: Vec, +} + +#[derive(Debug, Deserialize)] +struct TrustedKeysResponse { + trusted_keys: Vec, +} + // ============================================================================= // Language Detection // ============================================================================= @@ -1195,6 +1205,333 @@ pub fn clone_snapshot(snapshot_id: &str, name: &str, creds: &Credentials) -> Res make_request("POST", &path, creds, Some(&body)) } +// ============================================================================= +// Images API Functions (LXD Container Images) +// ============================================================================= + +/// Publish an LXD container image from a session or service. +/// +/// Creates a reusable container image from an existing session or service. +/// The image can later be used to spawn new services. +/// +/// # Arguments +/// * `source_type` - Source type: "session" or "service" +/// * `source_id` - ID of the session or service to publish from +/// * `name` - Name for the new image +/// * `description` - Optional description for the image +/// * `creds` - API credentials +/// +/// # Returns +/// LxdImage information for the published image +/// +/// # Examples +/// ```ignore +/// let creds = resolve_credentials(None, None)?; +/// let image = image_publish("service", "svc-abc123", "my-app-image", Some("Production app v1.0"), &creds)?; +/// println!("Published image: {}", image.image_id); +/// ``` +pub fn image_publish( + source_type: &str, + source_id: &str, + name: &str, + description: Option<&str>, + creds: &Credentials, +) -> Result { + let mut body = serde_json::json!({ + "source_type": source_type, + "source_id": source_id, + "name": name + }); + if let Some(desc) = description { + body["description"] = serde_json::json!(desc); + } + make_request("POST", "/images", creds, Some(&body)) +} + +/// List LXD container images. +/// +/// Returns images based on the filter type: +/// - None or "owned": Images owned by the authenticated user +/// - "shared": Images shared with the authenticated user +/// - "public": Publicly available images +/// - "all": All images accessible to the authenticated user +/// +/// # Arguments +/// * `filter_type` - Optional filter: "owned", "shared", "public", or "all" +/// * `creds` - API credentials +/// +/// # Returns +/// Vector of LxdImage information +/// +/// # Examples +/// ```ignore +/// let creds = resolve_credentials(None, None)?; +/// +/// // List owned images (default) +/// let my_images = list_images(None, &creds)?; +/// +/// // List shared images +/// let shared = list_images(Some("shared"), &creds)?; +/// +/// // List all accessible images +/// let all = list_images(Some("all"), &creds)?; +/// ``` +pub fn list_images(filter_type: Option<&str>, creds: &Credentials) -> Result> { + let path = match filter_type { + Some(ft) => format!("/images/{}", ft), + None => "/images".to_string(), + }; + let response: ImagesListResponse = make_request("GET", &path, creds, None::<&()>)?; + Ok(response.images) +} + +/// Get details of a specific LXD container image. +/// +/// # Arguments +/// * `image_id` - Image ID to retrieve +/// * `creds` - API credentials +/// +/// # Returns +/// LxdImage information +pub fn get_image(image_id: &str, creds: &Credentials) -> Result { + let path = format!("/images/{}", image_id); + make_request("GET", &path, creds, None::<&()>) +} + +/// Delete an LXD container image. +/// +/// The image must be unlocked to be deleted. +/// +/// # Arguments +/// * `image_id` - Image ID to delete +/// * `creds` - API credentials +pub fn delete_image(image_id: &str, creds: &Credentials) -> Result<()> { + let path = format!("/images/{}", image_id); + let _: serde_json::Value = make_request("DELETE", &path, creds, None::<&()>)?; + Ok(()) +} + +/// Lock an LXD container image to prevent modification or deletion. +/// +/// # Arguments +/// * `image_id` - Image ID to lock +/// * `creds` - API credentials +/// +/// # Returns +/// Updated LxdImage information +pub fn lock_image(image_id: &str, creds: &Credentials) -> Result { + let path = format!("/images/{}/lock", image_id); + let body = serde_json::json!({}); + make_request("POST", &path, creds, Some(&body)) +} + +/// Unlock an LXD container image to allow modification or deletion. +/// +/// # Arguments +/// * `image_id` - Image ID to unlock +/// * `creds` - API credentials +/// +/// # Returns +/// Updated LxdImage information +pub fn unlock_image(image_id: &str, creds: &Credentials) -> Result { + let path = format!("/images/{}/unlock", image_id); + let body = serde_json::json!({}); + make_request("POST", &path, creds, Some(&body)) +} + +/// Set the visibility of an LXD container image. +/// +/// # Arguments +/// * `image_id` - Image ID to update +/// * `visibility` - New visibility: "private", "shared", or "public" +/// * `creds` - API credentials +/// +/// # Returns +/// Updated LxdImage information +/// +/// # Examples +/// ```ignore +/// let creds = resolve_credentials(None, None)?; +/// +/// // Make image public +/// set_image_visibility("img-abc123", "public", &creds)?; +/// +/// // Make image private +/// set_image_visibility("img-abc123", "private", &creds)?; +/// ``` +pub fn set_image_visibility( + image_id: &str, + visibility: &str, + creds: &Credentials, +) -> Result { + let path = format!("/images/{}/visibility", image_id); + let body = serde_json::json!({ + "visibility": visibility + }); + make_request("POST", &path, creds, Some(&body)) +} + +/// Grant access to an LXD container image for another API key. +/// +/// The image visibility must be "shared" for this to take effect. +/// +/// # Arguments +/// * `image_id` - Image ID to grant access to +/// * `trusted_api_key` - API key (public key) to grant access +/// * `creds` - API credentials +/// +/// # Returns +/// Updated LxdImage information +pub fn grant_image_access( + image_id: &str, + trusted_api_key: &str, + creds: &Credentials, +) -> Result { + let path = format!("/images/{}/grant", image_id); + let body = serde_json::json!({ + "trusted_api_key": trusted_api_key + }); + make_request("POST", &path, creds, Some(&body)) +} + +/// Revoke access to an LXD container image from another API key. +/// +/// # Arguments +/// * `image_id` - Image ID to revoke access from +/// * `trusted_api_key` - API key (public key) to revoke access +/// * `creds` - API credentials +/// +/// # Returns +/// Updated LxdImage information +pub fn revoke_image_access( + image_id: &str, + trusted_api_key: &str, + creds: &Credentials, +) -> Result { + let path = format!("/images/{}/revoke", image_id); + let body = serde_json::json!({ + "trusted_api_key": trusted_api_key + }); + make_request("POST", &path, creds, Some(&body)) +} + +/// List API keys that have access to an LXD container image. +/// +/// # Arguments +/// * `image_id` - Image ID to list trusted keys for +/// * `creds` - API credentials +/// +/// # Returns +/// Vector of trusted API keys (public keys) +pub fn list_image_trusted(image_id: &str, creds: &Credentials) -> Result> { + let path = format!("/images/{}/trusted", image_id); + let response: TrustedKeysResponse = make_request("GET", &path, creds, None::<&()>)?; + Ok(response.trusted_keys) +} + +/// Transfer ownership of an LXD container image to another API key. +/// +/// After transfer, the original owner loses ownership but may retain +/// access if they are in the trusted keys list. +/// +/// # Arguments +/// * `image_id` - Image ID to transfer +/// * `to_api_key` - API key (public key) of the new owner +/// * `creds` - API credentials +/// +/// # Returns +/// Updated LxdImage information +pub fn transfer_image(image_id: &str, to_api_key: &str, creds: &Credentials) -> Result { + let path = format!("/images/{}/transfer", image_id); + let body = serde_json::json!({ + "to_api_key": to_api_key + }); + make_request("POST", &path, creds, Some(&body)) +} + +/// Spawn a new service from an LXD container image. +/// +/// Creates a new running service based on the specified image. +/// +/// # Arguments +/// * `image_id` - Image ID to spawn from +/// * `name` - Name for the new service +/// * `ports` - Optional list of ports to expose +/// * `bootstrap` - Optional bootstrap script to run after spawn +/// * `network_mode` - Optional network mode: "zerotrust" or "semitrusted" +/// * `creds` - API credentials +/// +/// # Returns +/// SpawnFromImageResult with the new service information +/// +/// # Examples +/// ```ignore +/// let creds = resolve_credentials(None, None)?; +/// +/// // Spawn with default options +/// let result = spawn_from_image("img-abc123", "my-service", None, None, None, &creds)?; +/// println!("Service URL: {}", result.url); +/// +/// // Spawn with custom ports and bootstrap +/// let result = spawn_from_image( +/// "img-abc123", +/// "web-server", +/// Some(&[80, 443]), +/// Some("systemctl start nginx"), +/// Some("semitrusted"), +/// &creds +/// )?; +/// ``` +pub fn spawn_from_image( + image_id: &str, + name: &str, + ports: Option<&[u16]>, + bootstrap: Option<&str>, + network_mode: Option<&str>, + creds: &Credentials, +) -> Result { + let path = format!("/images/{}/spawn", image_id); + let mut body = serde_json::json!({ + "name": name + }); + if let Some(p) = ports { + body["ports"] = serde_json::json!(p); + } + if let Some(b) = bootstrap { + body["bootstrap"] = serde_json::json!(b); + } + if let Some(nm) = network_mode { + body["network_mode"] = serde_json::json!(nm); + } + make_request("POST", &path, creds, Some(&body)) +} + +/// Clone an LXD container image to create a new image with a different name. +/// +/// # Arguments +/// * `image_id` - Image ID to clone +/// * `name` - Name for the new image +/// * `description` - Optional description for the new image +/// * `creds` - API credentials +/// +/// # Returns +/// New LxdImage information +pub fn clone_image( + image_id: &str, + name: &str, + description: Option<&str>, + creds: &Credentials, +) -> Result { + let path = format!("/images/{}/clone", image_id); + let mut body = serde_json::json!({ + "name": name + }); + if let Some(desc) = description { + body["description"] = serde_json::json!(desc); + } + make_request("POST", &path, creds, Some(&body)) +} + // ============================================================================= // Session API Functions // ============================================================================= diff --git a/clients/rust/sync/src/main.rs b/clients/rust/sync/src/main.rs new file mode 100644 index 0000000..8e58d86 --- /dev/null +++ b/clients/rust/sync/src/main.rs @@ -0,0 +1,16 @@ +// PUBLIC DOMAIN - NO LICENSE, NO WARRANTY +// +// unsandbox.com CLI (Rust Sync) +// +// Binary entry point for the CLI. +// +// Usage: +// un script.py # Execute Python script +// un -s bash 'echo hello' # Execute inline code +// un session --list # List sessions +// un service --list # List services +// un key # Check API key + +fn main() { + std::process::exit(un::cli_main()); +} diff --git a/clients/swift/sync/src/un.swift b/clients/swift/sync/src/un.swift new file mode 100644 index 0000000..ec4dba5 --- /dev/null +++ b/clients/swift/sync/src/un.swift @@ -0,0 +1,1893 @@ +/* +PUBLIC DOMAIN - NO LICENSE, NO WARRANTY + +unsandbox.com Swift SDK (Synchronous) + +Library Usage: + import Foundation + // Note: In a real project, compile this file and import as module + + // Execute code synchronously + let result = try executeCode(language: "python", code: "print('hello')") + + // Execute asynchronously (returns job_id) + let jobId = try executeAsync(language: "javascript", code: "console.log('hello')") + + // Wait for job completion + let result = try waitForJob(jobId) + + // List all jobs + let jobs = try listJobs() + + // Get supported languages + let languages = try getLanguages() + + // Detect language from filename + let lang = detectLanguage("script.py") // Returns "python" + + // Session operations + let sessions = try listSessions() + let session = try createSession(shell: "python3") + try deleteSession(sessionId) + + // Service operations + let services = try listServices() + let service = try createService(name: "myapp", ports: [80]) + try deleteService(serviceId) + + // Snapshot operations + let snapshotId = try sessionSnapshot(sessionId, name: "my-snapshot") + let snapshots = try listSnapshots() + try deleteSnapshot(snapshotId) + + // Key validation + let validation = try validateKeys() + + // Image generation + let result = try image(prompt: "A sunset over mountains") + +Authentication Priority (4-tier): + 1. Function arguments (publicKey, secretKey) + 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) + 3. Config file (~/.unsandbox/accounts.csv, line 0 by default) + 4. Local directory (./accounts.csv, line 0 by default) + + Format: public_key,secret_key (one per line) + Account selection: UNSANDBOX_ACCOUNT=N env var (0-based index) + +Request Authentication (HMAC-SHA256): + Authorization: Bearer (identifies account) + X-Timestamp: (replay prevention) + X-Signature: HMAC-SHA256(secret_key, msg) (proves secret + body integrity) + + Message format: "timestamp:METHOD:path:body" + - timestamp: seconds since epoch + - METHOD: GET, POST, DELETE, etc. (uppercase) + - path: e.g., "/execute", "/jobs/123" + - body: JSON payload (empty string for GET/DELETE) + +Languages Cache: + - Cached in ~/.unsandbox/languages.json + - TTL: 1 hour + - Updated on successful API calls +*/ + +import Foundation +#if canImport(CommonCrypto) +import CommonCrypto +#endif + +// MARK: - Constants + +let API_BASE = "https://api.unsandbox.com" +let PORTAL_BASE = "https://unsandbox.com" +let POLL_DELAYS_MS: [Int] = [300, 450, 700, 900, 650, 1600, 2000] +let LANGUAGES_CACHE_TTL: TimeInterval = 3600 // 1 hour + +// MARK: - Errors + +enum UnsandboxError: Error, CustomStringConvertible { + case credentialsNotFound(String) + case networkError(String) + case apiError(Int, String) + case invalidResponse(String) + case timeout(String) + case invalidArgument(String) + case fileNotFound(String) + + var description: String { + switch self { + case .credentialsNotFound(let msg): return "Credentials error: \(msg)" + case .networkError(let msg): return "Network error: \(msg)" + case .apiError(let code, let msg): return "API error (\(code)): \(msg)" + case .invalidResponse(let msg): return "Invalid response: \(msg)" + case .timeout(let msg): return "Timeout: \(msg)" + case .invalidArgument(let msg): return "Invalid argument: \(msg)" + case .fileNotFound(let msg): return "File not found: \(msg)" + } + } +} + +// MARK: - Credentials Resolution + +/// Get ~/.unsandbox directory path, creating if necessary +func getUnsandboxDir() -> URL { + let home = FileManager.default.homeDirectoryForCurrentUser + let unsandboxDir = home.appendingPathComponent(".unsandbox") + + if !FileManager.default.fileExists(atPath: unsandboxDir.path) { + try? FileManager.default.createDirectory(at: unsandboxDir, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + } + + return unsandboxDir +} + +/// Load credentials from CSV file (public_key,secret_key per line) +func loadCredentialsFromCSV(_ path: URL, accountIndex: Int = 0) -> (String, String)? { + guard FileManager.default.fileExists(atPath: path.path) else { return nil } + + do { + let content = try String(contentsOf: path, encoding: .utf8) + let lines = content.components(separatedBy: .newlines) + var index = 0 + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } + + if index == accountIndex { + let parts = trimmed.components(separatedBy: ",") + if parts.count >= 2 { + return (parts[0].trimmingCharacters(in: .whitespaces), + parts[1].trimmingCharacters(in: .whitespaces)) + } + } + index += 1 + } + } catch { + return nil + } + + return nil +} + +/// Resolve credentials from 4-tier priority system +func resolveCredentials(publicKey: String? = nil, secretKey: String? = nil, accountIndex: Int? = nil) throws -> (String, String) { + // Tier 1: Function arguments + if let pk = publicKey, let sk = secretKey, !pk.isEmpty, !sk.isEmpty { + return (pk, sk) + } + + // Tier 2: Environment variables + if let envPk = ProcessInfo.processInfo.environment["UNSANDBOX_PUBLIC_KEY"], + let envSk = ProcessInfo.processInfo.environment["UNSANDBOX_SECRET_KEY"], + !envPk.isEmpty, !envSk.isEmpty { + return (envPk, envSk) + } + + // Determine account index + let idx = accountIndex ?? Int(ProcessInfo.processInfo.environment["UNSANDBOX_ACCOUNT"] ?? "0") ?? 0 + + // Tier 3: ~/.unsandbox/accounts.csv + let unsandboxDir = getUnsandboxDir() + if let creds = loadCredentialsFromCSV(unsandboxDir.appendingPathComponent("accounts.csv"), accountIndex: idx) { + return creds + } + + // Tier 4: ./accounts.csv + let localPath = URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent("accounts.csv") + if let creds = loadCredentialsFromCSV(localPath, accountIndex: idx) { + return creds + } + + throw UnsandboxError.credentialsNotFound( + """ + No credentials found. Please provide via: + 1. Function arguments (publicKey, secretKey) + 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) + 3. ~/.unsandbox/accounts.csv + 4. ./accounts.csv + """ + ) +} + +// MARK: - HMAC-SHA256 Signing + +/// Sign a request using HMAC-SHA256 +func signRequest(secretKey: String, timestamp: Int, method: String, path: String, body: String?) -> String { + let bodyStr = body ?? "" + let message = "\(timestamp):\(method):\(path):\(bodyStr)" + + guard let keyData = secretKey.data(using: .utf8), + let messageData = message.data(using: .utf8) else { + return "" + } + + var hmac = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + keyData.withUnsafeBytes { keyPtr in + messageData.withUnsafeBytes { msgPtr in + CCHmac(CCHmacAlgorithm(kCCHmacAlgSHA256), + keyPtr.baseAddress, keyData.count, + msgPtr.baseAddress, messageData.count, + &hmac) + } + } + + return hmac.map { String(format: "%02x", $0) }.joined() +} + +// MARK: - HTTP Client + +/// Make an authenticated HTTP request to the API +func makeRequest(method: String, path: String, publicKey: String, secretKey: String, data: [String: Any]? = nil) throws -> [String: Any] { + let url = URL(string: "\(API_BASE)\(path)")! + var request = URLRequest(url: url) + request.httpMethod = method + request.timeoutInterval = 120 + + let timestamp = Int(Date().timeIntervalSince1970) + var bodyStr: String? = nil + + if let data = data { + let jsonData = try JSONSerialization.data(withJSONObject: data) + bodyStr = String(data: jsonData, encoding: .utf8) + request.httpBody = jsonData + } + + let signature = signRequest(secretKey: secretKey, timestamp: timestamp, method: method, path: path, body: method != "GET" && method != "DELETE" ? bodyStr : nil) + + request.setValue("Bearer \(publicKey)", forHTTPHeaderField: "Authorization") + request.setValue("\(timestamp)", forHTTPHeaderField: "X-Timestamp") + request.setValue(signature, forHTTPHeaderField: "X-Signature") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + var result: [String: Any]? + var requestError: Error? + + let semaphore = DispatchSemaphore(value: 0) + + let task = URLSession.shared.dataTask(with: request) { data, response, error in + defer { semaphore.signal() } + + if let error = error { + requestError = UnsandboxError.networkError(error.localizedDescription) + return + } + + guard let httpResponse = response as? HTTPURLResponse else { + requestError = UnsandboxError.invalidResponse("No HTTP response") + return + } + + guard let data = data else { + requestError = UnsandboxError.invalidResponse("No data received") + return + } + + if httpResponse.statusCode >= 400 { + let body = String(data: data, encoding: .utf8) ?? "Unknown error" + requestError = UnsandboxError.apiError(httpResponse.statusCode, body) + return + } + + do { + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { + result = json + } else { + requestError = UnsandboxError.invalidResponse("Response is not a JSON object") + } + } catch { + requestError = UnsandboxError.invalidResponse("Failed to parse JSON: \(error)") + } + } + + task.resume() + semaphore.wait() + + if let error = requestError { + throw error + } + + return result ?? [:] +} + +// MARK: - Languages Cache + +func getLanguagesCachePath() -> URL { + return getUnsandboxDir().appendingPathComponent("languages.json") +} + +func loadLanguagesCache() -> [String]? { + let cachePath = getLanguagesCachePath() + + guard FileManager.default.fileExists(atPath: cachePath.path) else { return nil } + + do { + let attrs = try FileManager.default.attributesOfItem(atPath: cachePath.path) + guard let mtime = attrs[.modificationDate] as? Date else { return nil } + + let age = Date().timeIntervalSince(mtime) + if age >= LANGUAGES_CACHE_TTL { return nil } + + let data = try Data(contentsOf: cachePath) + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let languages = json["languages"] as? [String] { + return languages + } + } catch { + return nil + } + + return nil +} + +func saveLanguagesCache(_ languages: [String]) { + let cachePath = getLanguagesCachePath() + let data: [String: Any] = [ + "languages": languages, + "timestamp": Int(Date().timeIntervalSince1970) + ] + + do { + let jsonData = try JSONSerialization.data(withJSONObject: data) + try jsonData.write(to: cachePath) + } catch { + // Cache failures are non-fatal + } +} + +// MARK: - Language Detection + +let LANGUAGE_MAP: [String: String] = [ + "py": "python", + "js": "javascript", + "ts": "typescript", + "rb": "ruby", + "php": "php", + "pl": "perl", + "sh": "bash", + "r": "r", + "lua": "lua", + "go": "go", + "rs": "rust", + "c": "c", + "cpp": "cpp", + "cc": "cpp", + "cxx": "cpp", + "java": "java", + "kt": "kotlin", + "m": "objc", + "cs": "csharp", + "fs": "fsharp", + "hs": "haskell", + "ml": "ocaml", + "clj": "clojure", + "scm": "scheme", + "ss": "scheme", + "erl": "erlang", + "ex": "elixir", + "exs": "elixir", + "jl": "julia", + "d": "d", + "nim": "nim", + "zig": "zig", + "v": "v", + "cr": "crystal", + "dart": "dart", + "groovy": "groovy", + "f90": "fortran", + "f95": "fortran", + "lisp": "commonlisp", + "lsp": "commonlisp", + "cob": "cobol", + "tcl": "tcl", + "raku": "raku", + "pro": "prolog", + "p": "prolog", + "4th": "forth", + "forth": "forth", + "fth": "forth", +] + +/// Detect programming language from filename extension +func detectLanguage(_ filename: String) -> String? { + guard !filename.isEmpty, filename.contains(".") else { return nil } + + let ext = (filename as NSString).pathExtension.lowercased() + return LANGUAGE_MAP[ext] +} + +// MARK: - Execution Functions + +/// Execute code synchronously (blocks until completion) +func executeCode(language: String, code: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + let response = try makeRequest(method: "POST", path: "/execute", publicKey: pk, secretKey: sk, data: [ + "language": language, + "code": code + ]) + + // If we got a job_id, poll until completion + if let jobId = response["job_id"] as? String, + let status = response["status"] as? String, + status == "pending" || status == "running" { + return try waitForJob(jobId, publicKey: pk, secretKey: sk) + } + + return response +} + +/// Execute code with additional options +func executeCodeWithOptions( + language: String, + code: String, + env: [String: String]? = nil, + files: [[String: String]]? = nil, + networkMode: String = "zerotrust", + vcpu: Int = 1, + artifacts: Bool = false, + publicKey: String? = nil, + secretKey: String? = nil +) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + + var data: [String: Any] = [ + "language": language, + "code": code, + "network_mode": networkMode + ] + + if let env = env, !env.isEmpty { + data["env"] = env + } + if let files = files, !files.isEmpty { + data["files"] = files + } + if vcpu > 1 { + data["vcpu"] = vcpu + } + if artifacts { + data["artifacts"] = true + } + + let response = try makeRequest(method: "POST", path: "/execute", publicKey: pk, secretKey: sk, data: data) + + if let jobId = response["job_id"] as? String, + let status = response["status"] as? String, + status == "pending" || status == "running" { + return try waitForJob(jobId, publicKey: pk, secretKey: sk) + } + + return response +} + +/// Execute code asynchronously (returns immediately with job_id) +func executeAsync(language: String, code: String, publicKey: String? = nil, secretKey: String? = nil) throws -> String { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + let response = try makeRequest(method: "POST", path: "/execute", publicKey: pk, secretKey: sk, data: [ + "language": language, + "code": code + ]) + return response["job_id"] as? String ?? "" +} + +/// Get current status/result of a job (single poll, no waiting) +func getJob(_ jobId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "GET", path: "/jobs/\(jobId)", publicKey: pk, secretKey: sk) +} + +/// Wait for job completion with exponential backoff polling +func waitForJob(_ jobId: String, publicKey: String? = nil, secretKey: String? = nil, timeout: TimeInterval? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + var pollCount = 0 + let startTime = Date() + + while true { + // Check timeout + if let timeout = timeout { + let elapsed = Date().timeIntervalSince(startTime) + if elapsed >= timeout { + throw UnsandboxError.timeout("Job \(jobId) did not complete within \(timeout) seconds") + } + } + + // Sleep before polling + let delayIdx = min(pollCount, POLL_DELAYS_MS.count - 1) + Thread.sleep(forTimeInterval: Double(POLL_DELAYS_MS[delayIdx]) / 1000.0) + pollCount += 1 + + let response = try getJob(jobId, publicKey: pk, secretKey: sk) + if let status = response["status"] as? String, + ["completed", "failed", "timeout", "cancelled"].contains(status) { + return response + } + } +} + +/// Cancel a running job +func cancelJob(_ jobId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "DELETE", path: "/jobs/\(jobId)", publicKey: pk, secretKey: sk) +} + +/// List all jobs for the authenticated account +func listJobs(publicKey: String? = nil, secretKey: String? = nil) throws -> [[String: Any]] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + let response = try makeRequest(method: "GET", path: "/jobs", publicKey: pk, secretKey: sk) + return response["jobs"] as? [[String: Any]] ?? [] +} + +/// Get list of supported programming languages +func getLanguages(publicKey: String? = nil, secretKey: String? = nil) throws -> [String] { + // Try cache first + if let cached = loadLanguagesCache() { + return cached + } + + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + let response = try makeRequest(method: "GET", path: "/languages", publicKey: pk, secretKey: sk) + let languages = response["languages"] as? [String] ?? [] + + // Cache the result + saveLanguagesCache(languages) + return languages +} + +// MARK: - Session Functions + +/// List all sessions for the authenticated account +func listSessions(publicKey: String? = nil, secretKey: String? = nil) throws -> [[String: Any]] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + let response = try makeRequest(method: "GET", path: "/sessions", publicKey: pk, secretKey: sk) + return response["sessions"] as? [[String: Any]] ?? [] +} + +/// Get details of a specific session +func getSession(_ sessionId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "GET", path: "/sessions/\(sessionId)", publicKey: pk, secretKey: sk) +} + +/// Create a new interactive session +func createSession( + language: String? = nil, + networkMode: String = "zerotrust", + ttl: Int = 3600, + shell: String? = nil, + multiplexer: String? = nil, + vcpu: Int = 1, + publicKey: String? = nil, + secretKey: String? = nil +) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + + var data: [String: Any] = [ + "network_mode": networkMode, + "ttl": ttl + ] + + if let language = language { + data["language"] = language + } + if let shell = shell { + data["shell"] = shell + } + if let multiplexer = multiplexer { + data["multiplexer"] = multiplexer + } + if vcpu > 1 { + data["vcpu"] = vcpu + } + + return try makeRequest(method: "POST", path: "/sessions", publicKey: pk, secretKey: sk, data: data) +} + +/// Delete/terminate a session +func deleteSession(_ sessionId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "DELETE", path: "/sessions/\(sessionId)", publicKey: pk, secretKey: sk) +} + +/// Freeze a session (pause execution, preserve state) +func freezeSession(_ sessionId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "POST", path: "/sessions/\(sessionId)/freeze", publicKey: pk, secretKey: sk, data: [:]) +} + +/// Unfreeze a session (resume execution) +func unfreezeSession(_ sessionId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "POST", path: "/sessions/\(sessionId)/unfreeze", publicKey: pk, secretKey: sk, data: [:]) +} + +/// Boost a session (increase resources) +func boostSession(_ sessionId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "POST", path: "/sessions/\(sessionId)/boost", publicKey: pk, secretKey: sk, data: [:]) +} + +/// Unboost a session (return to normal resources) +func unboostSession(_ sessionId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "POST", path: "/sessions/\(sessionId)/unboost", publicKey: pk, secretKey: sk, data: [:]) +} + +/// Execute a shell command in a session +func shellSession(_ sessionId: String, command: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "POST", path: "/sessions/\(sessionId)/shell", publicKey: pk, secretKey: sk, data: ["command": command]) +} + +// MARK: - Service Functions + +/// List all services for the authenticated account +func listServices(publicKey: String? = nil, secretKey: String? = nil) throws -> [[String: Any]] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + let response = try makeRequest(method: "GET", path: "/services", publicKey: pk, secretKey: sk) + return response["services"] as? [[String: Any]] ?? [] +} + +/// Create a new persistent service +func createService( + name: String, + ports: [Int], + bootstrap: String? = nil, + networkMode: String = "semitrusted", + customDomains: [String]? = nil, + vcpu: Int = 1, + serviceType: String? = nil, + publicKey: String? = nil, + secretKey: String? = nil +) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + + var data: [String: Any] = [ + "name": name, + "ports": ports, + "network_mode": networkMode + ] + + if let bootstrap = bootstrap { + if bootstrap.hasPrefix("http://") || bootstrap.hasPrefix("https://") { + data["bootstrap"] = bootstrap + } else { + data["bootstrap_content"] = bootstrap + } + } + if let customDomains = customDomains { + data["custom_domains"] = customDomains + } + if vcpu > 1 { + data["vcpu"] = vcpu + } + if let serviceType = serviceType { + data["service_type"] = serviceType + } + + return try makeRequest(method: "POST", path: "/services", publicKey: pk, secretKey: sk, data: data) +} + +/// Get details of a specific service +func getService(_ serviceId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "GET", path: "/services/\(serviceId)", publicKey: pk, secretKey: sk) +} + +/// Update a service (e.g., resize vCPU/memory) +func updateService(_ serviceId: String, vcpu: Int? = nil, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + var data: [String: Any] = [:] + if let vcpu = vcpu { + data["vcpu"] = vcpu + } + return try makeRequest(method: "PATCH", path: "/services/\(serviceId)", publicKey: pk, secretKey: sk, data: data) +} + +/// Delete/destroy a service +func deleteService(_ serviceId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "DELETE", path: "/services/\(serviceId)", publicKey: pk, secretKey: sk) +} + +/// Freeze a service (pause execution, preserve state) +func freezeService(_ serviceId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "POST", path: "/services/\(serviceId)/freeze", publicKey: pk, secretKey: sk, data: [:]) +} + +/// Unfreeze a service (resume execution) +func unfreezeService(_ serviceId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "POST", path: "/services/\(serviceId)/unfreeze", publicKey: pk, secretKey: sk, data: [:]) +} + +/// Lock a service to prevent accidental deletion +func lockService(_ serviceId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "POST", path: "/services/\(serviceId)/lock", publicKey: pk, secretKey: sk, data: [:]) +} + +/// Unlock a service to allow deletion +func unlockService(_ serviceId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "POST", path: "/services/\(serviceId)/unlock", publicKey: pk, secretKey: sk, data: [:]) +} + +/// Get bootstrap/runtime logs for a service +func getServiceLogs(_ serviceId: String, allLogs: Bool = false, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + var path = "/services/\(serviceId)/logs" + if allLogs { + path += "?all=true" + } + return try makeRequest(method: "GET", path: path, publicKey: pk, secretKey: sk) +} + +/// Get environment vault status for a service +func getServiceEnv(_ serviceId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "GET", path: "/services/\(serviceId)/env", publicKey: pk, secretKey: sk) +} + +/// Set environment variables for a service +func setServiceEnv(_ serviceId: String, envDict: [String: String], publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + let envContent = envDict.map { "\($0.key)=\($0.value)" }.joined(separator: "\n") + return try makeRequest(method: "POST", path: "/services/\(serviceId)/env", publicKey: pk, secretKey: sk, data: ["env": envContent]) +} + +/// Delete environment vault from a service +func deleteServiceEnv(_ serviceId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "DELETE", path: "/services/\(serviceId)/env", publicKey: pk, secretKey: sk) +} + +/// Export environment vault secrets for a service +func exportServiceEnv(_ serviceId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "POST", path: "/services/\(serviceId)/env/export", publicKey: pk, secretKey: sk, data: [:]) +} + +/// Redeploy a service (re-run bootstrap script) +func redeployService(_ serviceId: String, bootstrap: String? = nil, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + var data: [String: Any] = [:] + if let bootstrap = bootstrap { + if bootstrap.hasPrefix("http://") || bootstrap.hasPrefix("https://") { + data["bootstrap"] = bootstrap + } else { + data["bootstrap_content"] = bootstrap + } + } + return try makeRequest(method: "POST", path: "/services/\(serviceId)/redeploy", publicKey: pk, secretKey: sk, data: data) +} + +/// Execute a command in a running service container +func executeInService(_ serviceId: String, command: String, timeout: Int = 30000, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "POST", path: "/services/\(serviceId)/execute", publicKey: pk, secretKey: sk, data: ["command": command, "timeout": timeout]) +} + +// MARK: - Snapshot Functions + +/// Create a snapshot of a session +func sessionSnapshot(_ sessionId: String, name: String? = nil, ephemeral: Bool = false, publicKey: String? = nil, secretKey: String? = nil) throws -> String { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + var data: [String: Any] = ["session_id": sessionId, "ephemeral": ephemeral] + if let name = name { + data["name"] = name + } + let response = try makeRequest(method: "POST", path: "/snapshots", publicKey: pk, secretKey: sk, data: data) + return response["snapshot_id"] as? String ?? "" +} + +/// Create a snapshot of a service +func serviceSnapshot(_ serviceId: String, name: String? = nil, publicKey: String? = nil, secretKey: String? = nil) throws -> String { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + var data: [String: Any] = ["service_id": serviceId] + if let name = name { + data["name"] = name + } + let response = try makeRequest(method: "POST", path: "/snapshots", publicKey: pk, secretKey: sk, data: data) + return response["snapshot_id"] as? String ?? "" +} + +/// List all snapshots +func listSnapshots(publicKey: String? = nil, secretKey: String? = nil) throws -> [[String: Any]] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + let response = try makeRequest(method: "GET", path: "/snapshots", publicKey: pk, secretKey: sk) + return response["snapshots"] as? [[String: Any]] ?? [] +} + +/// Restore a snapshot +func restoreSnapshot(_ snapshotId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "POST", path: "/snapshots/\(snapshotId)/restore", publicKey: pk, secretKey: sk, data: [:]) +} + +/// Delete a snapshot +func deleteSnapshot(_ snapshotId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "DELETE", path: "/snapshots/\(snapshotId)", publicKey: pk, secretKey: sk) +} + +/// Lock a snapshot to prevent accidental deletion +func lockSnapshot(_ snapshotId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "POST", path: "/snapshots/\(snapshotId)/lock", publicKey: pk, secretKey: sk, data: [:]) +} + +/// Unlock a snapshot to allow deletion +func unlockSnapshot(_ snapshotId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "POST", path: "/snapshots/\(snapshotId)/unlock", publicKey: pk, secretKey: sk, data: [:]) +} + +/// Clone a snapshot to create a new session or service +func cloneSnapshot( + _ snapshotId: String, + cloneType: String = "session", + name: String? = nil, + shell: String? = nil, + ports: [Int]? = nil, + publicKey: String? = nil, + secretKey: String? = nil +) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + var data: [String: Any] = ["type": cloneType] + if let name = name { + data["name"] = name + } + if let shell = shell { + data["shell"] = shell + } + if let ports = ports { + data["ports"] = ports + } + return try makeRequest(method: "POST", path: "/snapshots/\(snapshotId)/clone", publicKey: pk, secretKey: sk, data: data) +} + +// MARK: - Image Functions + +/// Publish a service or snapshot as a portable LXD image +func imagePublish( + sourceType: String, + sourceId: String, + name: String? = nil, + description: String? = nil, + publicKey: String? = nil, + secretKey: String? = nil +) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + var data: [String: Any] = ["source_type": sourceType, "source_id": sourceId] + if let name = name { + data["name"] = name + } + if let description = description { + data["description"] = description + } + return try makeRequest(method: "POST", path: "/images", publicKey: pk, secretKey: sk, data: data) +} + +/// List images accessible to this API key +func listImages(filterType: String? = nil, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + var endpoint = "/images" + if let filterType = filterType { + endpoint = "/images/\(filterType)" + } + return try makeRequest(method: "GET", path: endpoint, publicKey: pk, secretKey: sk) +} + +/// Get details of a specific image +func getImage(_ imageId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "GET", path: "/images/\(imageId)", publicKey: pk, secretKey: sk) +} + +/// Delete an image +func deleteImage(_ imageId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "DELETE", path: "/images/\(imageId)", publicKey: pk, secretKey: sk) +} + +/// Lock an image to prevent accidental deletion +func lockImage(_ imageId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "POST", path: "/images/\(imageId)/lock", publicKey: pk, secretKey: sk, data: [:]) +} + +/// Unlock an image to allow deletion +func unlockImage(_ imageId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "POST", path: "/images/\(imageId)/unlock", publicKey: pk, secretKey: sk, data: [:]) +} + +/// Set image visibility +func setImageVisibility(_ imageId: String, visibility: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "POST", path: "/images/\(imageId)/visibility", publicKey: pk, secretKey: sk, data: ["visibility": visibility]) +} + +/// Grant access to an image for another API key +func grantImageAccess(_ imageId: String, trustedApiKey: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "POST", path: "/images/\(imageId)/grant", publicKey: pk, secretKey: sk, data: ["trusted_api_key": trustedApiKey]) +} + +/// Revoke access to an image from another API key +func revokeImageAccess(_ imageId: String, trustedApiKey: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "POST", path: "/images/\(imageId)/revoke", publicKey: pk, secretKey: sk, data: ["trusted_api_key": trustedApiKey]) +} + +/// List all API keys that have access to an image +func listImageTrusted(_ imageId: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "GET", path: "/images/\(imageId)/trusted", publicKey: pk, secretKey: sk) +} + +/// Transfer image ownership to another API key +func transferImage(_ imageId: String, toApiKey: String, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + return try makeRequest(method: "POST", path: "/images/\(imageId)/transfer", publicKey: pk, secretKey: sk, data: ["to_api_key": toApiKey]) +} + +/// Create a new service from an image +func spawnFromImage( + _ imageId: String, + name: String? = nil, + ports: [Int]? = nil, + bootstrap: String? = nil, + networkMode: String = "zerotrust", + publicKey: String? = nil, + secretKey: String? = nil +) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + var data: [String: Any] = ["network_mode": networkMode] + if let name = name { + data["name"] = name + } + if let ports = ports { + data["ports"] = ports + } + if let bootstrap = bootstrap { + data["bootstrap"] = bootstrap + } + return try makeRequest(method: "POST", path: "/images/\(imageId)/spawn", publicKey: pk, secretKey: sk, data: data) +} + +/// Clone an image to create a copy owned by you +func cloneImage(_ imageId: String, name: String? = nil, description: String? = nil, publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + var data: [String: Any] = [:] + if let name = name { + data["name"] = name + } + if let description = description { + data["description"] = description + } + return try makeRequest(method: "POST", path: "/images/\(imageId)/clone", publicKey: pk, secretKey: sk, data: data) +} + +// MARK: - Key Validation + +/// Validate API keys against the portal +func validateKeys(publicKey: String? = nil, secretKey: String? = nil) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + + let url = URL(string: "\(PORTAL_BASE)/keys/validate")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = 30 + + let timestamp = Int(Date().timeIntervalSince1970) + let signature = signRequest(secretKey: sk, timestamp: timestamp, method: "POST", path: "/keys/validate", body: "") + + request.setValue("Bearer \(pk)", forHTTPHeaderField: "Authorization") + request.setValue("\(timestamp)", forHTTPHeaderField: "X-Timestamp") + request.setValue(signature, forHTTPHeaderField: "X-Signature") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + var result: [String: Any]? + var requestError: Error? + + let semaphore = DispatchSemaphore(value: 0) + + let task = URLSession.shared.dataTask(with: request) { data, response, error in + defer { semaphore.signal() } + + if let error = error { + requestError = UnsandboxError.networkError(error.localizedDescription) + return + } + + guard let httpResponse = response as? HTTPURLResponse else { + requestError = UnsandboxError.invalidResponse("No HTTP response") + return + } + + guard let data = data else { + requestError = UnsandboxError.invalidResponse("No data received") + return + } + + if httpResponse.statusCode >= 400 { + let body = String(data: data, encoding: .utf8) ?? "Unknown error" + requestError = UnsandboxError.apiError(httpResponse.statusCode, body) + return + } + + do { + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { + result = json + } + } catch { + requestError = UnsandboxError.invalidResponse("Failed to parse JSON") + } + } + + task.resume() + semaphore.wait() + + if let error = requestError { + throw error + } + + return result ?? [:] +} + +// MARK: - Image Generation (AI) + +/// Generate images from text prompt using AI +func image( + prompt: String, + model: String? = nil, + size: String = "1024x1024", + quality: String = "standard", + n: Int = 1, + publicKey: String? = nil, + secretKey: String? = nil +) throws -> [String: Any] { + let (pk, sk) = try resolveCredentials(publicKey: publicKey, secretKey: secretKey) + var payload: [String: Any] = [ + "prompt": prompt, + "size": size, + "quality": quality, + "n": n + ] + if let model = model { + payload["model"] = model + } + return try makeRequest(method: "POST", path: "/image", publicKey: pk, secretKey: sk, data: payload) +} + +// MARK: - CLI Implementation + +/// Parse a .env file into a dictionary +func parseEnvFile(_ filePath: String) throws -> [String: String] { + var envDict: [String: String] = [:] + + let url = URL(fileURLWithPath: filePath) + guard FileManager.default.fileExists(atPath: url.path) else { + throw UnsandboxError.fileNotFound(filePath) + } + + let content = try String(contentsOf: url, encoding: .utf8) + let lines = content.components(separatedBy: .newlines) + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } + + if let eqIndex = trimmed.firstIndex(of: "=") { + let key = String(trimmed[.. String { + if items.isEmpty { + return "No \(resourceType)s found." + } + + var headers: [String] + var rows: [[String]] = [] + + switch resourceType { + case "session": + headers = ["ID", "STATUS", "SHELL", "CREATED"] + for item in items { + let id = (item["id"] as? String ?? item["session_id"] as? String ?? "").prefix(36) + let status = item["status"] as? String ?? "unknown" + let shell = item["shell"] as? String ?? "bash" + let created = (item["created_at"] as? String ?? "").prefix(19) + rows.append([String(id), status, shell, String(created)]) + } + case "service": + headers = ["ID", "NAME", "STATUS", "PORTS", "CREATED"] + for item in items { + let id = (item["id"] as? String ?? item["service_id"] as? String ?? "").prefix(36) + let name = (item["name"] as? String ?? "").prefix(20) + let status = item["status"] as? String ?? "unknown" + let ports = (item["ports"] as? [Int] ?? []).map { String($0) }.joined(separator: ",").prefix(15) + let created = (item["created_at"] as? String ?? "").prefix(19) + rows.append([String(id), String(name), status, String(ports), String(created)]) + } + case "snapshot": + headers = ["ID", "NAME", "TYPE", "SIZE", "CREATED"] + for item in items { + let id = (item["id"] as? String ?? item["snapshot_id"] as? String ?? "").prefix(36) + let name = (item["name"] as? String ?? "").prefix(20) + let sourceType = item["source_type"] as? String ?? "unknown" + let size = item["size"] as? String ?? "" + let created = (item["created_at"] as? String ?? "").prefix(19) + rows.append([String(id), String(name), sourceType, size, String(created)]) + } + default: + headers = ["ID", "STATUS"] + for item in items { + rows.append([item["id"] as? String ?? "", item["status"] as? String ?? ""]) + } + } + + // Calculate column widths + var widths = headers.map { $0.count } + for row in rows { + for (i, cell) in row.enumerated() { + widths[i] = max(widths[i], cell.count) + } + } + + // Build output + var lines: [String] = [] + let headerLine = zip(headers, widths).map { $0.0.padding(toLength: $0.1, withPad: " ", startingAt: 0) }.joined(separator: " ") + lines.append(headerLine) + + for row in rows { + let line = zip(row, widths).map { $0.0.padding(toLength: $0.1, withPad: " ", startingAt: 0) }.joined(separator: " ") + lines.append(line) + } + + return lines.joined(separator: "\n") +} + +/// Print usage help +func printHelp() { + let help = """ + Unsandbox CLI - Execute code in secure containers + + USAGE: + un [options] Execute code file + un session [options] Interactive session + un service [options] Manage services + un service-env Manage service environment + un snapshot [options] Manage snapshots + un key Check API key + + GLOBAL OPTIONS: + -s, --shell LANG Language for inline code + -e, --env KEY=VAL Set environment variable + -f, --file FILE Add input file to /tmp/ + -F, --file-path FILE Add input file with path preserved + -a, --artifacts Return compiled artifacts + -o, --output DIR Output directory for artifacts + -p, --public-key KEY API public key + -k, --secret-key KEY API secret key + -n, --network MODE Network: zerotrust or semitrusted + -v, --vcpu N vCPU count (1-8) + -y, --yes Skip confirmation prompts + -h, --help Show help + + SESSION OPTIONS: + -l, --list List active sessions + --attach ID Reconnect to existing session + --kill ID Terminate a session + --freeze ID Pause session + --unfreeze ID Resume session + --boost ID Add resources to session + --unboost ID Remove boost from session + --snapshot ID Create snapshot of session + --shell SHELL Shell/REPL to use (default: bash) + --tmux Enable persistence with tmux + --screen Enable persistence with screen + + SERVICE OPTIONS: + -l, --list List all services + --info ID Get service details + --logs ID Get all logs + --tail ID Get last 9000 lines of logs + --freeze ID Pause service + --unfreeze ID Resume service + --destroy ID Delete service + --lock ID Prevent deletion + --unlock ID Allow deletion + --resize ID Resize service (with --vcpu) + --redeploy ID Re-run bootstrap + --execute ID CMD Run command in service + --snapshot ID Create snapshot of service + --name NAME Service name (creates new) + --ports PORTS Comma-separated ports + --bootstrap CMD Bootstrap command + --bootstrap-file FILE Bootstrap from file + --env-file FILE Load env from .env file + + SERVICE-ENV ACTIONS: + status Show vault status + set Set from --env-file or stdin + export Export to stdout + delete Delete vault + + SNAPSHOT OPTIONS: + -l, --list List all snapshots + --info ID Get snapshot details + --delete ID Delete snapshot + --lock ID Prevent deletion + --unlock ID Allow deletion + --clone ID Clone snapshot + --type TYPE Clone type: session or service + --name NAME Name for cloned resource + + EXAMPLES: + un script.py Execute Python script + un -s bash 'echo hello' Inline bash command + un session --list List active sessions + un service --list List all services + un snapshot --list List all snapshots + un key Check API key + """ + print(help) +} + +/// CLI argument parser +class CLIArgs { + var command: String? + var source: String? + var shell: String? + var env: [String] = [] + var files: [String] = [] + var filesPath: [String] = [] + var artifacts: Bool = false + var output: String? + var publicKey: String? + var secretKey: String? + var networkMode: String = "zerotrust" + var vcpu: Int = 1 + var yes: Bool = false + + // Session options + var listFlag: Bool = false + var attach: String? + var kill: String? + var freeze: String? + var unfreeze: String? + var boost: String? + var unboost: String? + var snapshot: String? + var snapshotName: String? + var hot: Bool = false + var audit: Bool = false + var tmux: Bool = false + var screen: Bool = false + + // Service options + var info: String? + var logs: String? + var tail: String? + var destroy: String? + var lock: String? + var unlock: String? + var resize: String? + var redeploy: String? + var execute: (String, String)? + var name: String? + var ports: String? + var domains: String? + var serviceType: String? + var bootstrap: String? + var bootstrapFile: String? + var envFile: String? + + // Snapshot options + var delete: String? + var clone: String? + var cloneType: String? + + // Service-env + var serviceEnvAction: String? + var serviceEnvId: String? + + func parse(_ args: [String]) { + var i = 0 + let args = Array(args.dropFirst()) // Skip program name + + while i < args.count { + let arg = args[i] + + switch arg { + case "-h", "--help": + printHelp() + exit(0) + case "-s", "--shell": + i += 1 + if i < args.count { shell = args[i] } + case "-e", "--env": + i += 1 + if i < args.count { env.append(args[i]) } + case "-f", "--file": + i += 1 + if i < args.count { files.append(args[i]) } + case "-F", "--file-path": + i += 1 + if i < args.count { filesPath.append(args[i]) } + case "-a", "--artifacts": + artifacts = true + case "-o", "--output": + i += 1 + if i < args.count { output = args[i] } + case "-p", "--public-key": + i += 1 + if i < args.count { publicKey = args[i] } + case "-k", "--secret-key": + i += 1 + if i < args.count { secretKey = args[i] } + case "-n", "--network": + i += 1 + if i < args.count { networkMode = args[i] } + case "-v", "--vcpu": + i += 1 + if i < args.count { vcpu = Int(args[i]) ?? 1 } + case "-y", "--yes": + yes = true + case "-l", "--list": + listFlag = true + case "--attach": + i += 1 + if i < args.count { attach = args[i] } + case "--kill": + i += 1 + if i < args.count { kill = args[i] } + case "--freeze": + i += 1 + if i < args.count { freeze = args[i] } + case "--unfreeze": + i += 1 + if i < args.count { unfreeze = args[i] } + case "--boost": + i += 1 + if i < args.count { boost = args[i] } + case "--unboost": + i += 1 + if i < args.count { unboost = args[i] } + case "--snapshot": + i += 1 + if i < args.count { snapshot = args[i] } + case "--snapshot-name": + i += 1 + if i < args.count { snapshotName = args[i] } + case "--hot": + hot = true + case "--audit": + audit = true + case "--tmux": + tmux = true + case "--screen": + screen = true + case "--info": + i += 1 + if i < args.count { info = args[i] } + case "--logs": + i += 1 + if i < args.count { logs = args[i] } + case "--tail": + i += 1 + if i < args.count { tail = args[i] } + case "--destroy": + i += 1 + if i < args.count { destroy = args[i] } + case "--lock": + i += 1 + if i < args.count { lock = args[i] } + case "--unlock": + i += 1 + if i < args.count { unlock = args[i] } + case "--resize": + i += 1 + if i < args.count { resize = args[i] } + case "--redeploy": + i += 1 + if i < args.count { redeploy = args[i] } + case "--execute": + i += 1 + if i + 1 < args.count { + execute = (args[i], args[i + 1]) + i += 1 + } + case "--name": + i += 1 + if i < args.count { name = args[i] } + case "--ports": + i += 1 + if i < args.count { ports = args[i] } + case "--domains": + i += 1 + if i < args.count { domains = args[i] } + case "--type": + i += 1 + if i < args.count { + if args[i] == "session" || args[i] == "service" { + cloneType = args[i] + } else { + serviceType = args[i] + } + } + case "--bootstrap": + i += 1 + if i < args.count { bootstrap = args[i] } + case "--bootstrap-file": + i += 1 + if i < args.count { bootstrapFile = args[i] } + case "--env-file": + i += 1 + if i < args.count { envFile = args[i] } + case "--delete": + i += 1 + if i < args.count { delete = args[i] } + case "--clone": + i += 1 + if i < args.count { clone = args[i] } + case "session", "service", "snapshot", "key": + command = arg + case "service-env": + command = "service-env" + i += 1 + if i < args.count { serviceEnvAction = args[i] } + i += 1 + if i < args.count { serviceEnvId = args[i] } + default: + if arg.hasPrefix("-") { + fputs("Error: Unknown option \(arg)\n", stderr) + exit(2) + } else if command == nil && (arg == "session" || arg == "service" || arg == "snapshot" || arg == "key" || arg == "service-env") { + command = arg + } else if source == nil { + source = arg + } + } + i += 1 + } + } +} + +/// Handle execute command +func handleExecuteCommand(_ args: CLIArgs, _ pk: String, _ sk: String) throws { + var language: String + var code: String + + if let shell = args.shell { + // Inline code mode + guard let source = args.source else { + fputs("Error: Code required with -s/--shell\n", stderr) + exit(2) + } + language = shell + code = source + } else { + // File mode + guard let source = args.source else { + fputs("Error: Source file required\n", stderr) + exit(2) + } + + // Detect language from filename + guard let detected = detectLanguage(source) else { + fputs("Error: Cannot detect language from '\(source)'\n", stderr) + exit(2) + } + language = detected + + // Read source file + let url = URL(fileURLWithPath: source) + guard FileManager.default.fileExists(atPath: url.path) else { + fputs("Error: File not found: \(source)\n", stderr) + exit(1) + } + + do { + code = try String(contentsOf: url, encoding: .utf8) + } catch { + fputs("Error: Failed to read file: \(error)\n", stderr) + exit(1) + } + } + + // Parse environment variables + var envDict: [String: String]? = nil + if !args.env.isEmpty { + envDict = [:] + for envVar in args.env { + if let eqIndex = envVar.firstIndex(of: "=") { + let key = String(envVar[..