diff --git a/clients/awk/sync/src/un.awk b/clients/awk/sync/src/un.awk index 8bee959..fcc82e4 100644 --- a/clients/awk/sync/src/un.awk +++ b/clients/awk/sync/src/un.awk @@ -45,6 +45,8 @@ BEGIN { API_BASE = "https://api.unsandbox.com" PORTAL_BASE = "https://unsandbox.com" + LANGUAGES_CACHE_TTL = 3600 # 1 hour cache TTL + LANGUAGES_CACHE_FILE = ENVIRON["HOME"] "/.unsandbox/languages.json" # Extension to language map split("py:python js:javascript ts:typescript rb:ruby php:php pl:perl lua:lua sh:bash go:go rs:rust c:c cpp:cpp java:java kt:kotlin cs:csharp fs:fsharp hs:haskell ml:ocaml clj:clojure scm:scheme lisp:commonlisp erl:erlang ex:elixir jl:julia r:r cr:crystal d:d nim:nim zig:zig v:v dart:dart groovy:groovy f90:fortran cob:cobol pro:prolog forth:forth tcl:tcl raku:raku m:objc awk:awk ps1:powershell", pairs, " ") @@ -637,7 +639,66 @@ function cmd_key(do_extend) { validate_key(do_extend) } -function languages_list(json_output , timestamp, sig_headers, signature, sig_input, sig_cmd, line, response, i, lang) { +function read_languages_cache( cmd, line, cache_content, cache_timestamp, current_time) { + # Check if cache file exists and is valid + cmd = "cat '" LANGUAGES_CACHE_FILE "' 2>/dev/null" + cache_content = "" + while ((cmd | getline line) > 0) { + cache_content = cache_content line + } + close(cmd) + + if (cache_content == "") return "" + + # Extract timestamp from cache + if (match(cache_content, /"timestamp":([0-9]+)/, arr)) { + cache_timestamp = arr[1] + current_time = systime() + # Check if cache is still valid (within TTL) + if ((current_time - cache_timestamp) < LANGUAGES_CACHE_TTL) { + return cache_content + } + } + return "" +} + +function write_languages_cache(languages_array , cmd, cache_json, current_time) { + current_time = systime() + # Build cache JSON: {"languages": [...], "timestamp": unix_seconds} + cache_json = "{\"languages\":" languages_array ",\"timestamp\":" current_time "}" + + # Ensure ~/.unsandbox directory exists + system("mkdir -p \"" ENVIRON["HOME"] "/.unsandbox\"") + + # Write cache file + cmd = "cat > '" LANGUAGES_CACHE_FILE "'" + print cache_json | cmd + close(cmd) +} + +function languages_list(json_output , timestamp, sig_headers, signature, sig_input, sig_cmd, line, response, i, lang, cache_content, languages_array, content, first, m) { + # Try to read from cache first + cache_content = read_languages_cache() + if (cache_content != "") { + # Extract languages array from cache + if (match(cache_content, /"languages":(\[[^\]]*\])/, arr)) { + languages_array = arr[1] + if (json_output) { + print languages_array + } else { + # Parse and print one per line + content = languages_array + gsub(/[\[\]"]/, "", content) + n = split(content, langs, ",") + for (i = 1; i <= n; i++) { + if (langs[i] != "") print langs[i] + } + } + return + } + } + + # No valid cache, fetch from API get_api_keys() timestamp = systime() sig_headers = "" @@ -655,32 +716,41 @@ function languages_list(json_output , timestamp, sig_headers, signature, sig_ } close(cmd) + # Build languages array for caching + languages_array = "" + if (match(response, /"languages":\[([^\]]*)\]/, arr)) { + # Parse out language names from the array + content = arr[1] + languages_array = "[" + first = 1 + while (match(content, /"name":"([^"]*)"/, m)) { + if (!first) languages_array = languages_array "," + languages_array = languages_array "\"" m[1] "\"" + first = 0 + content = substr(content, RSTART + RLENGTH) + } + languages_array = languages_array "]" + + # Save to cache + write_languages_cache(languages_array) + } + if (json_output) { # Output raw JSON array of language names - # Extract languages array and convert to simple array - if (match(response, /"languages":\[([^\]]*)\]/, arr)) { - # Parse out language names from the array - content = arr[1] - printf "[" - first = 1 - while (match(content, /"name":"([^"]*)"/, m)) { - if (!first) printf "," - printf "\"%s\"", m[1] - first = 0 - content = substr(content, RSTART + RLENGTH) - } - printf "]\n" + if (languages_array != "") { + print languages_array } else { # Fallback: just print raw response print response } } else { # Output one language per line - if (match(response, /"languages":\[([^\]]*)\]/, arr)) { - content = arr[1] - while (match(content, /"name":"([^"]*)"/, m)) { - print m[1] - content = substr(content, RSTART + RLENGTH) + if (languages_array != "") { + content = languages_array + gsub(/[\[\]"]/, "", content) + n = split(content, langs, ",") + for (i = 1; i <= n; i++) { + if (langs[i] != "") print langs[i] } } } diff --git a/clients/c/src/un.c b/clients/c/src/un.c index 982fd90..7cc5b3f 100644 --- a/clients/c/src/un.c +++ b/clients/c/src/un.c @@ -47,6 +47,7 @@ #define LARGE_UPLOAD_WARN_SIZE (1024L * 1024 * 1024) // Warn if total > 1GB #define MAX_ENV_VARS 256 // LXC limit is typically higher #define MAX_ENV_CONTENT_SIZE (64 * 1024) // 64KB max env vault size +#define LANGUAGES_CACHE_TTL 3600 // 1 hour cache for languages list // ============================================================================ // SHA-256 Implementation (for HMAC-SHA256) @@ -1834,8 +1835,155 @@ static int list_sessions(const UnsandboxCredentials *creds) { return 0; } +// Get path to languages cache file (~/.unsandbox/languages.json) +static char* get_languages_cache_path(void) { + const char *home = getenv("HOME"); + if (!home) { + struct passwd *pw = getpwuid(getuid()); + if (pw) home = pw->pw_dir; + } + if (!home) return NULL; + + char *path = malloc(strlen(home) + 32); + if (!path) return NULL; + sprintf(path, "%s/.unsandbox/languages.json", home); + return path; +} + +// Load languages from cache if valid (< 1 hour old) +// Returns JSON string with languages array, or NULL if cache invalid/missing +static char* load_languages_cache(void) { + char *cache_path = get_languages_cache_path(); + if (!cache_path) return NULL; + + struct stat st; + if (stat(cache_path, &st) != 0) { + free(cache_path); + return NULL; + } + + // Check if cache is fresh (< 1 hour old) + time_t now = time(NULL); + if (now - st.st_mtime >= LANGUAGES_CACHE_TTL) { + free(cache_path); + return NULL; + } + + FILE *f = fopen(cache_path, "r"); + free(cache_path); + if (!f) return NULL; + + fseek(f, 0, SEEK_END); + long size = ftell(f); + fseek(f, 0, SEEK_SET); + + if (size <= 0 || size > 1024 * 1024) { // Sanity check: max 1MB + fclose(f); + return NULL; + } + + char *data = malloc(size + 1); + if (!data) { + fclose(f); + return NULL; + } + + size_t read = fread(data, 1, size, f); + fclose(f); + data[read] = '\0'; + + // Verify it contains languages array + if (!strstr(data, "\"languages\"")) { + free(data); + return NULL; + } + + return data; +} + +// Save languages to cache +static void save_languages_cache(const char *json_response) { + if (!json_response) return; + + char *cache_path = get_languages_cache_path(); + if (!cache_path) return; + + // Ensure ~/.unsandbox directory exists + char *dir = strdup(cache_path); + if (dir) { + char *last_slash = strrchr(dir, '/'); + if (last_slash) { + *last_slash = '\0'; + mkdir(dir, 0700); + } + free(dir); + } + + FILE *f = fopen(cache_path, "w"); + free(cache_path); + if (!f) return; + + // Extract just the languages array if present, wrap in standard format + const char *langs_start = strstr(json_response, "\"languages\":["); + if (langs_start) { + const char *arr_start = strchr(langs_start, '['); + const char *arr_end = strchr(arr_start, ']'); + if (arr_start && arr_end) { + fprintf(f, "{\"languages\":%.*s,\"timestamp\":%ld}", + (int)(arr_end - arr_start + 1), arr_start, (long)time(NULL)); + } + } else { + // Try to find raw array + const char *arr_start = strchr(json_response, '['); + const char *arr_end = arr_start ? strchr(arr_start, ']') : NULL; + if (arr_start && arr_end) { + fprintf(f, "{\"languages\":%.*s,\"timestamp\":%ld}", + (int)(arr_end - arr_start + 1), arr_start, (long)time(NULL)); + } + } + + fclose(f); +} + // List supported languages (CLI helper) static int list_languages_cli(const UnsandboxCredentials *creds, int json_output) { + // Try cache first + char *cached = load_languages_cache(); + if (cached) { + const char *langs_start = strstr(cached, "\"languages\":["); + if (!langs_start) langs_start = strchr(cached, '['); + + if (langs_start) { + const char *arr_start = strchr(langs_start, '['); + if (arr_start) { + if (json_output) { + const char *arr_end = strchr(arr_start, ']'); + if (arr_end) { + printf("%.*s\n", (int)(arr_end - arr_start + 1), arr_start); + } + } else { + const char *p = arr_start + 1; + while (*p && *p != ']') { + if (*p == '"') { + p++; + const char *end = strchr(p, '"'); + if (end) { + printf("%.*s\n", (int)(end - p), p); + p = end + 1; + } + } else { + p++; + } + } + } + free(cached); + return 0; + } + } + free(cached); + } + + // Cache miss or invalid - fetch from API CURL *curl = curl_easy_init(); if (!curl) return 1; @@ -1911,6 +2059,9 @@ static int list_languages_cli(const UnsandboxCredentials *creds, int json_output } } + // Save to cache for future use + save_languages_cache(response.data); + free(response.data); return 0; } diff --git a/clients/clojure/sync/src/un.clj b/clients/clojure/sync/src/un.clj index c6105e9..854dcc3 100644 --- a/clients/clojure/sync/src/un.clj +++ b/clients/clojure/sync/src/un.clj @@ -65,6 +65,8 @@ (def portal-base "https://unsandbox.com") +(def languages-cache-ttl 3600) ;; 1 hour in seconds + (def ext-map {".hs" "haskell" ".ml" "ocaml" ".clj" "clojure" ".scm" "scheme" ".lisp" "commonlisp" ".erl" "erlang" ".ex" "elixir" ".exs" "elixir" @@ -518,9 +520,42 @@ (let [api-key (get-api-key)] (validate-key api-key extend?))) +(defn get-languages-cache-path [] + (let [home (System/getenv "HOME")] + (str home "/.unsandbox/languages.json"))) + +(defn load-languages-cache [] + (let [cache-path (get-languages-cache-path)] + (when (.exists (io/file cache-path)) + (try + (let [content (slurp cache-path) + timestamp (extract-field "timestamp" content)] + (when timestamp + (let [cache-time (Long/parseLong timestamp) + now (quot (System/currentTimeMillis) 1000)] + (when (< (- now cache-time) languages-cache-ttl) + content)))) + (catch Exception _ nil))))) + +(defn save-languages-cache [languages-json] + (let [cache-path (get-languages-cache-path) + cache-dir (.getParent (io/file cache-path))] + (.mkdirs (io/file cache-dir)) + (let [timestamp (quot (System/currentTimeMillis) 1000) + cache-content (str "{\"languages\":" languages-json ",\"timestamp\":" timestamp "}")] + (spit cache-path cache-content)))) + (defn languages-command [json-output?] (let [api-key (get-api-key) - response (curl-get api-key "/languages")] + ;; Try cache first + cached (load-languages-cache) + response (if cached + cached + (let [resp (curl-get api-key "/languages")] + ;; Extract and cache the languages array + (when-let [match (re-find #"\"languages\":\s*\[([^\]]*)\]" resp)] + (save-languages-cache (str "[" (second match) "]"))) + resp))] (if json-output? ;; Extract language names and output as JSON array (let [langs (re-seq #"\"name\":\"([^\"]+)\"" response) diff --git a/clients/cobol/sync/src/un.cob b/clients/cobol/sync/src/un.cob index 1030445..b93c3cd 100644 --- a/clients/cobol/sync/src/un.cob +++ b/clients/cobol/sync/src/un.cob @@ -79,6 +79,7 @@ 01 WS-INPUT-FILES PIC X(1024). 01 WS-PORTAL-BASE PIC X(256) VALUE "https://unsandbox.com". + 01 WS-LANGUAGES-CACHE-TTL PIC 9(8) VALUE 3600. 01 WS-EXTEND-FLAG PIC X(8). 01 WS-SVC-ENVS PIC X(2048). 01 WS-SVC-ENV-FILE PIC X(256). @@ -1038,9 +1039,18 @@ PERFORM LANGUAGES-LIST. LANGUAGES-LIST. + * Languages list with 1-hour cache IF WS-JSON-OUTPUT = "true" - * JSON output: extract language names as array - STRING "TS=$(date +%s); " + * JSON output: extract language names as array with caching + STRING "CACHE_TTL=3600; " + "CACHE_FILE=\"$HOME/.unsandbox/languages.json\"; " + "if [ -f \"$CACHE_FILE\" ]; then " + "CACHE_TS=$(jq -r '.timestamp // 0' \"$CACHE_FILE\" 2>/dev/null); " + "CURRENT_TS=$(date +%s); " + "AGE=$((CURRENT_TS - CACHE_TS)); " + "if [ $AGE -lt $CACHE_TTL ]; then " + "jq -c '.languages' \"$CACHE_FILE\"; exit 0; fi; fi; " + "TS=$(date +%s); " "SIG=$(echo -n \"$TS:GET:/languages:\" | " "openssl dgst -sha256 -hmac '" FUNCTION TRIM(WS-SECRET-KEY) @@ -1051,12 +1061,24 @@ "' " "-H 'X-Timestamp: '$TS " "-H 'X-Signature: '$SIG); " - "echo \"$RESP\" | jq -c '[.languages[].name]'" + "LANGS=$(echo \"$RESP\" | jq -c '[.languages[].name]'); " + "mkdir -p \"$HOME/.unsandbox\"; " + "echo \"{\\\"languages\\\":$LANGS,\\\"timestamp\\\":$(date +%s)}\" " + "> \"$CACHE_FILE\"; " + "echo \"$LANGS\"" DELIMITED BY SIZE INTO WS-CURL-CMD END-STRING ELSE - * Plain output: one language per line - STRING "TS=$(date +%s); " + * Plain output: one language per line with caching + STRING "CACHE_TTL=3600; " + "CACHE_FILE=\"$HOME/.unsandbox/languages.json\"; " + "if [ -f \"$CACHE_FILE\" ]; then " + "CACHE_TS=$(jq -r '.timestamp // 0' \"$CACHE_FILE\" 2>/dev/null); " + "CURRENT_TS=$(date +%s); " + "AGE=$((CURRENT_TS - CACHE_TS)); " + "if [ $AGE -lt $CACHE_TTL ]; then " + "jq -r '.languages[]' \"$CACHE_FILE\"; exit 0; fi; fi; " + "TS=$(date +%s); " "SIG=$(echo -n \"$TS:GET:/languages:\" | " "openssl dgst -sha256 -hmac '" FUNCTION TRIM(WS-SECRET-KEY) @@ -1067,6 +1089,10 @@ "' " "-H 'X-Timestamp: '$TS " "-H 'X-Signature: '$SIG); " + "LANGS=$(echo \"$RESP\" | jq -c '[.languages[].name]'); " + "mkdir -p \"$HOME/.unsandbox\"; " + "echo \"{\\\"languages\\\":$LANGS,\\\"timestamp\\\":$(date +%s)}\" " + "> \"$CACHE_FILE\"; " "echo \"$RESP\" | jq -r '.languages[].name'" DELIMITED BY SIZE INTO WS-CURL-CMD END-STRING diff --git a/clients/cpp/sync/src/un.cpp b/clients/cpp/sync/src/un.cpp index ada4aa7..6dce33d 100644 --- a/clients/cpp/sync/src/un.cpp +++ b/clients/cpp/sync/src/un.cpp @@ -73,6 +73,7 @@ using namespace std; const string API_BASE = "https://api.unsandbox.com"; const string PORTAL_BASE = "https://unsandbox.com"; +const int LANGUAGES_CACHE_TTL = 3600; // 1 hour in seconds const string BLUE = "\033[34m"; const string RED = "\033[31m"; const string GREEN = "\033[32m"; @@ -209,6 +210,79 @@ string build_env_content(const vector& envs, const string& env_file) { return parts.str(); } +string get_languages_cache_path() { + const char* home = getenv("HOME"); + if (!home) home = "."; + return string(home) + "/.unsandbox/languages.json"; +} + +vector load_languages_cache() { + vector empty; + string cache_path = get_languages_cache_path(); + + struct stat st; + if (stat(cache_path.c_str(), &st) != 0) { + return empty; // File doesn't exist + } + + // Check if cache is fresh (< 1 hour old) + time_t now = time(nullptr); + if (now - st.st_mtime >= LANGUAGES_CACHE_TTL) { + return empty; // Cache expired + } + + string content = read_file(cache_path); + if (content.empty()) { + return empty; + } + + // Parse languages from JSON {"languages": [...], "timestamp": ...} + vector languages; + size_t pos = content.find("\"languages\":"); + if (pos == string::npos) return empty; + + pos = content.find('[', pos); + if (pos == string::npos) return empty; + pos++; + + while (pos < content.length()) { + // Skip whitespace + while (pos < content.length() && (content[pos] == ' ' || content[pos] == '\n' || content[pos] == '\t')) pos++; + if (content[pos] == ']') break; + if (content[pos] == '"') { + pos++; + size_t end = content.find('"', pos); + if (end != string::npos) { + languages.push_back(content.substr(pos, end - pos)); + pos = end + 1; + } + } + // Skip comma + while (pos < content.length() && (content[pos] == ',' || content[pos] == ' ' || content[pos] == '\n' || content[pos] == '\t')) pos++; + } + + return languages; +} + +void save_languages_cache(const vector& languages) { + string cache_path = get_languages_cache_path(); + + // Create directory if needed + string dir = cache_path.substr(0, cache_path.rfind('/')); + mkdir(dir.c_str(), 0755); + + ofstream f(cache_path); + if (!f) return; + + f << "{\"languages\":["; + for (size_t i = 0; i < languages.size(); i++) { + if (i > 0) f << ","; + f << "\"" << languages[i] << "\""; + } + f << "],\"timestamp\":" << time(nullptr) << "}"; + f.close(); +} + string service_env_status(const string& service_id, const string& public_key, const string& secret_key) { string path = "/services/" + service_id + "/env"; string auth_headers = build_auth_headers("GET", path, "", public_key, secret_key); @@ -695,13 +769,16 @@ void cmd_service(const string& name, const string& ports, const string& type, co } void cmd_languages(bool json_output, const string& public_key, const string& secret_key) { - string auth_headers = build_auth_headers("GET", "/languages", "", public_key, secret_key); - string cmd = "curl -s -X GET '" + API_BASE + "/languages' " + auth_headers; - string result = exec_curl(cmd); + // Try cache first + vector names = load_languages_cache(); - if (json_output) { - // Extract language names and output as JSON array - vector names; + if (names.empty()) { + // Fetch from API + string auth_headers = build_auth_headers("GET", "/languages", "", public_key, secret_key); + string cmd = "curl -s -X GET '" + API_BASE + "/languages' " + auth_headers; + string result = exec_curl(cmd); + + // Extract language names from response size_t pos = 0; string search = "\"name\":\""; while ((pos = result.find(search, pos)) != string::npos) { @@ -712,6 +789,14 @@ void cmd_languages(bool json_output, const string& public_key, const string& sec pos = end; } } + + // Save to cache + if (!names.empty()) { + save_languages_cache(names); + } + } + + if (json_output) { cout << "["; for (size_t i = 0; i < names.size(); i++) { if (i > 0) cout << ","; @@ -720,15 +805,8 @@ void cmd_languages(bool json_output, const string& public_key, const string& sec cout << "]" << endl; } else { // Output one language per line - size_t pos = 0; - string search = "\"name\":\""; - while ((pos = result.find(search, pos)) != string::npos) { - pos += search.length(); - size_t end = result.find("\"", pos); - if (end != string::npos) { - cout << result.substr(pos, end - pos) << endl; - pos = end; - } + for (const auto& name : names) { + cout << name << endl; } } } diff --git a/clients/crystal/sync/src/un.cr b/clients/crystal/sync/src/un.cr index 41f655a..ac05233 100644 --- a/clients/crystal/sync/src/un.cr +++ b/clients/crystal/sync/src/un.cr @@ -70,12 +70,68 @@ RESET = "\033[0m" API_BASE = "https://api.unsandbox.com" PORTAL_BASE = "https://unsandbox.com" MAX_ENV_CONTENT_SIZE = 65536 +LANGUAGES_CACHE_TTL = 3600 # 1 hour in seconds def detect_language(filename : String) : String ext = File.extname(filename).downcase EXT_MAP.fetch(ext, "unknown") end +def get_languages_cache_path : String? + home = ENV["HOME"]? + return nil if home.nil? || home.empty? + File.join(home, ".unsandbox", "languages.json") +end + +def load_languages_cache : String? + cache_path = get_languages_cache_path + return nil if cache_path.nil? || !File.exists?(cache_path) + + begin + content = File.read(cache_path) + # Parse timestamp from JSON + parsed = JSON.parse(content) + cached_time = parsed["timestamp"]?.try(&.as_i64?) + return nil if cached_time.nil? + + current_time = Time.utc.to_unix + + # Check if cache is still valid (within TTL) + if current_time - cached_time < LANGUAGES_CACHE_TTL + return content + end + rescue + # Cache read failed, return nil to fetch fresh + end + + nil +end + +def save_languages_cache(response : JSON::Any) + cache_path = get_languages_cache_path + return if cache_path.nil? + + begin + # Ensure directory exists + cache_dir = File.dirname(cache_path) + Dir.mkdir_p(cache_dir) unless Dir.exists?(cache_dir) + + # Extract languages array from response + languages = response["languages"]? + return if languages.nil? + + # Build cache JSON with timestamp + timestamp = Time.utc.to_unix + cache_data = { + "languages" => languages, + "timestamp" => timestamp + } + File.write(cache_path, cache_data.to_json) + rescue + # Cache write failed, ignore + end +end + def get_api_keys(args_key : String?) : {String, String?} public_key = ENV["UNSANDBOX_PUBLIC_KEY"]? secret_key = ENV["UNSANDBOX_SECRET_KEY"]? @@ -417,7 +473,20 @@ end def cmd_languages(args) public_key, secret_key = get_api_keys(args[:api_key]?) - result = api_request("/languages", public_key, secret_key) + # Try to load from cache first + cached_response = load_languages_cache + result : JSON::Any + + if cached_response + result = JSON.parse(cached_response) + else + # Fetch from API + result = api_request("/languages", public_key, secret_key) + + # Save to cache + save_languages_cache(result) + end + languages = result["languages"]?.try(&.as_a?) || [] of JSON::Any if args[:json]?.as?(Bool) diff --git a/clients/d/sync/src/un.d b/clients/d/sync/src/un.d index eaf6f19..5a82883 100644 --- a/clients/d/sync/src/un.d +++ b/clients/d/sync/src/un.d @@ -61,6 +61,7 @@ immutable string GREEN = "\033[32m"; immutable string YELLOW = "\033[33m"; immutable string RESET = "\033[0m"; immutable size_t MAX_ENV_CONTENT_SIZE = 65536; +immutable int LANGUAGES_CACHE_TTL = 3600; // 1 hour in seconds string detectLanguage(string filename) { string[string] langMap = [ @@ -130,6 +131,88 @@ string getTimestamp() { return format("%d", Clock.currTime.toUnixTime()); } +string getLanguagesCachePath() { + string home = environment.get("HOME", ""); + if (home.empty) return ""; + return buildPath(home, ".unsandbox", "languages.json"); +} + +string loadLanguagesCache() { + import std.datetime.systime : Clock; + string cachePath = getLanguagesCachePath(); + if (cachePath.empty || !exists(cachePath)) return ""; + + try { + string content = readText(cachePath); + // Parse timestamp from JSON + import std.algorithm : findSplitAfter; + auto tsSearch = content.findSplitAfter(`"timestamp":`); + if (tsSearch[0].length == 0) return ""; + + // Find the end of the number + string remaining = tsSearch[1]; + size_t numEnd = 0; + while (numEnd < remaining.length && (remaining[numEnd] >= '0' && remaining[numEnd] <= '9')) { + numEnd++; + } + if (numEnd == 0) return ""; + + long cachedTime = to!long(remaining[0..numEnd]); + long currentTime = Clock.currTime.toUnixTime(); + + // Check if cache is still valid (within TTL) + if (currentTime - cachedTime < LANGUAGES_CACHE_TTL) { + return content; + } + } catch (Exception e) { + // Cache read failed, return empty to fetch fresh + } + return ""; +} + +void saveLanguagesCache(string response) { + import std.datetime.systime : Clock; + string cachePath = getLanguagesCachePath(); + if (cachePath.empty) return; + + try { + // Ensure directory exists + string cacheDir = dirName(cachePath); + if (!exists(cacheDir)) { + mkdirRecurse(cacheDir); + } + + // Extract languages array from response + import std.algorithm : findSplitAfter; + + // Find the languages array + auto langSearch = response.findSplitAfter(`"languages":`); + if (langSearch[0].length == 0) return; + + // Find the array brackets + auto bracketStart = langSearch[1].findSplitAfter("["); + if (bracketStart[0].length == 0) return; + + // Find matching closing bracket + string rest = "[" ~ bracketStart[1]; + int depth = 1; + size_t endPos = 1; + while (endPos < rest.length && depth > 0) { + if (rest[endPos] == '[') depth++; + else if (rest[endPos] == ']') depth--; + endPos++; + } + string languagesArray = rest[0..endPos]; + + // Build cache JSON with timestamp + long timestamp = Clock.currTime.toUnixTime(); + string cacheJson = format(`{"languages":%s,"timestamp":%d}`, languagesArray, timestamp); + std.file.write(cachePath, cacheJson); + } catch (Exception e) { + // Cache write failed, ignore + } +} + string buildAuthHeaders(string method, string path, string body, string publicKey, string secretKey) { if (secretKey.empty) { // Legacy mode: use public_key as bearer token @@ -695,9 +778,21 @@ void cmdImage(bool list, string info, string del, string lock, string unlock, } void cmdLanguages(bool jsonOutput, string publicKey, string secretKey) { - string authHeaders = buildAuthHeaders("GET", "/languages", "", publicKey, secretKey); - string cmd = format(`curl -s -X GET '%s/languages' %s`, API_BASE, authHeaders); - string result = execCurl(cmd); + // Try to load from cache first + string cachedResponse = loadLanguagesCache(); + string result; + + if (!cachedResponse.empty) { + result = cachedResponse; + } else { + // Fetch from API + string authHeaders = buildAuthHeaders("GET", "/languages", "", publicKey, secretKey); + string cmd = format(`curl -s -X GET '%s/languages' %s`, API_BASE, authHeaders); + result = execCurl(cmd); + + // Save to cache + saveLanguagesCache(result); + } if (jsonOutput) { // Extract language names and output as JSON array diff --git a/clients/dart/sync/src/un.dart b/clients/dart/sync/src/un.dart index 367e1b6..9ce983d 100644 --- a/clients/dart/sync/src/un.dart +++ b/clients/dart/sync/src/un.dart @@ -51,6 +51,7 @@ const String red = '\x1B[31m'; const String green = '\x1B[32m'; const String yellow = '\x1B[33m'; const String reset = '\x1B[0m'; +const int languagesCacheTtl = 3600; // 1 hour in seconds const Map extMap = { '.py': 'python', '.js': 'javascript', '.ts': 'typescript', @@ -150,6 +151,69 @@ String detectLanguage(String filename) { return lang; } +String? getLanguagesCachePath() { + final home = Platform.environment['HOME']; + if (home == null || home.isEmpty) return null; + return '$home/.unsandbox/languages.json'; +} + +Future?> loadLanguagesCache() async { + final cachePath = getLanguagesCachePath(); + if (cachePath == null) return null; + + final cacheFile = File(cachePath); + if (!await cacheFile.exists()) return null; + + try { + final content = await cacheFile.readAsString(); + final data = jsonDecode(content) as Map; + + final cachedTime = data['timestamp'] as int?; + if (cachedTime == null) return null; + + final currentTime = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + // Check if cache is still valid (within TTL) + if (currentTime - cachedTime < languagesCacheTtl) { + return data; + } + } catch (e) { + // Cache read failed, return null to fetch fresh + } + + return null; +} + +Future saveLanguagesCache(Map response) async { + final cachePath = getLanguagesCachePath(); + if (cachePath == null) return; + + try { + final cacheFile = File(cachePath); + + // Ensure directory exists + final cacheDir = cacheFile.parent; + if (!await cacheDir.exists()) { + await cacheDir.create(recursive: true); + } + + // Extract languages from response + final languages = response['languages']; + if (languages == null) return; + + // Build cache JSON with timestamp + final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final cacheData = { + 'languages': languages, + 'timestamp': timestamp, + }; + + await cacheFile.writeAsString(jsonEncode(cacheData)); + } catch (e) { + // Cache write failed, ignore + } +} + Future> apiRequestCurl(String endpoint, String method, String? jsonData, String publicKey, String? secretKey, {String? baseUrl}) async { final base = baseUrl ?? apiBase; final tempFile = await File('${Directory.systemTemp.path}/un_request_${DateTime.now().millisecondsSinceEpoch}.json').create(); @@ -722,7 +786,20 @@ Future cmdLanguages(Args args) async { final publicKey = keys[0]!; final secretKey = keys[1]; - final result = await apiRequestCurl('/languages', 'GET', null, publicKey, secretKey); + // Try to load from cache first + var cachedData = await loadLanguagesCache(); + Map result; + + if (cachedData != null) { + result = cachedData; + } else { + // Fetch from API + result = await apiRequestCurl('/languages', 'GET', null, publicKey, secretKey); + + // Save to cache + await saveLanguagesCache(result); + } + final languages = result['languages'] as List? ?? []; if (args.languagesJson) { diff --git a/clients/elixir/sync/src/un.ex b/clients/elixir/sync/src/un.ex index 0092d3f..d66d241 100755 --- a/clients/elixir/sync/src/un.ex +++ b/clients/elixir/sync/src/un.ex @@ -59,6 +59,7 @@ defmodule Un do @reset "\e[0m" @portal_base "https://unsandbox.com" + @languages_cache_ttl 3600 @ext_map %{ ".ex" => "elixir", ".exs" => "elixir", ".erl" => "erlang", @@ -567,13 +568,103 @@ defmodule Un do System.halt(1) end + # Languages cache functions + defp get_languages_cache_path do + home = System.get_env("HOME") || "." + Path.join([home, ".unsandbox", "languages.json"]) + end + + defp load_languages_cache do + cache_path = get_languages_cache_path() + + case File.read(cache_path) do + {:ok, content} -> + case Jason.decode(content) do + {:ok, data} -> + timestamp = Map.get(data, "timestamp", 0) + now = System.system_time(:second) + + if now - timestamp < @languages_cache_ttl do + Map.get(data, "languages", []) + else + nil + end + + {:error, _} -> + # Fallback to manual JSON parsing for environments without Jason + timestamp = extract_json_number(content, "timestamp") + now = System.system_time(:second) + + if timestamp != 0 and now - timestamp < @languages_cache_ttl do + extract_json_array(content, "languages") + else + nil + end + end + + {:error, _} -> + nil + end + rescue + UndefinedFunctionError -> + # Jason not available, use manual parsing + cache_path = get_languages_cache_path() + + case File.read(cache_path) do + {:ok, content} -> + timestamp = extract_json_number(content, "timestamp") + now = System.system_time(:second) + + if timestamp != 0 and now - timestamp < @languages_cache_ttl do + extract_json_array(content, "languages") + else + nil + end + + {:error, _} -> + nil + end + end + + defp save_languages_cache(languages) do + cache_path = get_languages_cache_path() + cache_dir = Path.dirname(cache_path) + + # Ensure directory exists + File.mkdir_p(cache_dir) + + timestamp = System.system_time(:second) + languages_json = "[" <> Enum.map_join(languages, ",", &("\"#{&1}\"")) <> "]" + json = "{\"languages\":#{languages_json},\"timestamp\":#{timestamp}}" + + File.write(cache_path, json) + end + + defp extract_json_number(json_str, key) do + case Regex.run(~r/"#{key}"\s*:\s*(\d+)/, json_str) do + [_, value] -> String.to_integer(value) + _ -> 0 + end + end + # Languages command defp languages_command(args) do - api_key = get_api_key() json_output = "--json" in args - response = curl_get(api_key, "/languages") - languages = extract_json_array(response, "languages") + # Try to load from cache first + languages = + case load_languages_cache() do + nil -> + # Cache miss or expired, fetch from API + api_key = get_api_key() + response = curl_get(api_key, "/languages") + langs = extract_json_array(response, "languages") + save_languages_cache(langs) + langs + + cached_languages -> + cached_languages + end if json_output do # Output as JSON array diff --git a/clients/erlang/sync/src/un.erl b/clients/erlang/sync/src/un.erl index 5202321..c051a79 100755 --- a/clients/erlang/sync/src/un.erl +++ b/clients/erlang/sync/src/un.erl @@ -561,23 +561,102 @@ build_spawn_json(Name, Ports) -> end, "{" ++ string:join(lists:reverse(Parts2), ",") ++ "}". +%% Languages cache TTL (1 hour in seconds) +-define(LANGUAGES_CACHE_TTL, 3600). + +%% Get languages cache path +get_languages_cache_path() -> + Home = os:getenv("HOME"), + case Home of + false -> "/tmp/.unsandbox/languages.json"; + _ -> Home ++ "/.unsandbox/languages.json" + end. + +%% Load languages from cache +load_languages_cache() -> + CachePath = get_languages_cache_path(), + case file:read_file(CachePath) of + {ok, Bin} -> + Content = binary_to_list(Bin), + Timestamp = extract_json_number(Content, "timestamp"), + Now = erlang:system_time(second), + if + Timestamp > 0 andalso (Now - Timestamp) < ?LANGUAGES_CACHE_TTL -> + extract_json_array(Content, "languages"); + true -> + undefined + end; + {error, _} -> + undefined + end. + +%% Save languages to cache +save_languages_cache(Languages) -> + CachePath = get_languages_cache_path(), + CacheDir = filename:dirname(CachePath), + filelib:ensure_dir(CachePath), + file:make_dir(CacheDir), + Timestamp = erlang:system_time(second), + LanguagesJson = "[" ++ string:join(["\"" ++ L ++ "\"" || L <- Languages], ",") ++ "]", + Json = "{\"languages\":" ++ LanguagesJson ++ ",\"timestamp\":" ++ integer_to_list(Timestamp) ++ "}", + file:write_file(CachePath, Json). + +%% Extract JSON number field +extract_json_number(Json, Field) -> + Pattern = "\"" ++ Field ++ "\":", + case string:str(Json, Pattern) of + 0 -> 0; + Pos -> + Start = Pos + length(Pattern), + Rest = lists:nthtail(Start - 1, Json), + extract_number(Rest) + end. + +extract_number(Str) -> + extract_number(Str, []). + +extract_number([], Acc) -> + case Acc of + [] -> 0; + _ -> list_to_integer(lists:reverse(Acc)) + end; +extract_number([C | Rest], Acc) when C >= $0, C =< $9 -> + extract_number(Rest, [C | Acc]); +extract_number(_, Acc) -> + case Acc of + [] -> 0; + _ -> list_to_integer(lists:reverse(Acc)) + end. + %% Languages command languages_command(Args) -> - ApiKey = get_api_key(), - Response = curl_get(ApiKey, "/languages"), JsonOutput = lists:member("--json", Args), + + %% Try to load from cache first + Languages = case load_languages_cache() of + undefined -> + %% Cache miss or expired, fetch from API + ApiKey = get_api_key(), + Response = curl_get(ApiKey, "/languages"), + Langs = extract_json_array(Response, "languages"), + save_languages_cache(Langs), + Langs; + CachedLanguages -> + CachedLanguages + end, + if JsonOutput -> %% Output raw JSON array - case extract_json_array(Response, "languages") of + case Languages of [] -> io:format("[]~n"); - Languages -> io:format("[~s]~n", [string:join(["\"" ++ L ++ "\"" || L <- Languages], ",")]) + _ -> io:format("[~s]~n", [string:join(["\"" ++ L ++ "\"" || L <- Languages], ",")]) end; true -> %% Output one language per line - case extract_json_array(Response, "languages") of + case Languages of [] -> ok; - Languages -> [io:format("~s~n", [L]) || L <- Languages] + _ -> [io:format("~s~n", [L]) || L <- Languages] end end. diff --git a/clients/forth/sync/src/un.forth b/clients/forth/sync/src/un.forth index 7eab014..e839e10 100644 --- a/clients/forth/sync/src/un.forth +++ b/clients/forth/sync/src/un.forth @@ -46,6 +46,12 @@ s" https://unsandbox.com" ; +3600 constant LANGUAGES_CACHE_TTL + +: languages-cache-file ( -- addr len ) + s" $HOME/.unsandbox/languages.json" +; + \ Extension to language mapping (simple linear search) : ext-lang ( addr len -- addr len | 0 0 ) 2dup s" .jl" compare 0= if 2drop s" julia" exit then @@ -986,27 +992,54 @@ 1 (bye) ; -\ Languages list +\ Languages list with caching : languages-list ( json-flag -- ) get-api-key s" /tmp/unsandbox_cmd.sh" w/o create-file throw >r s" #!/bin/bash" r@ write-line throw + s" CACHE_TTL=3600" r@ write-line throw + s" CACHE_FILE=\"$HOME/.unsandbox/languages.json\"" r@ write-line throw + s" JSON_OUTPUT=" r@ write-file throw + if + s" 1" r@ write-line throw + else + s" 0" r@ write-line throw + then s" PUBLIC_KEY='" r@ write-file throw get-public-key r@ write-file throw s" '" r@ write-line throw s" SECRET_KEY='" r@ write-file throw get-secret-key r@ write-file throw s" '" r@ write-line throw + \ Check cache first + s" if [ -f \"$CACHE_FILE\" ]; then" r@ write-line throw + s" CACHE_TS=$(jq -r '.timestamp // 0' \"$CACHE_FILE\" 2>/dev/null)" r@ write-line throw + s" CURRENT_TS=$(date +%s)" r@ write-line throw + s" AGE=$((CURRENT_TS - CACHE_TS))" r@ write-line throw + s" if [ $AGE -lt $CACHE_TTL ]; then" r@ write-line throw + s" if [ \"$JSON_OUTPUT\" = \"1\" ]; then" r@ write-line throw + s" jq -c '.languages' \"$CACHE_FILE\"" r@ write-line throw + s" else" r@ write-line throw + s" jq -r '.languages[]' \"$CACHE_FILE\"" r@ write-line throw + s" fi" r@ write-line throw + s" exit 0" r@ write-line throw + s" fi" r@ write-line throw + s" fi" r@ write-line throw + \ Fetch from API s" TIMESTAMP=$(date +%s)" r@ write-line throw s" MESSAGE=\"$TIMESTAMP:GET:/languages:\"" r@ write-line throw s" SIGNATURE=$(echo -n \"$MESSAGE\" | openssl dgst -sha256 -hmac \"$SECRET_KEY\" -hex | sed 's/.*= //')" r@ write-line throw - if - \ JSON output - s" curl -s -X GET https://api.unsandbox.com/languages -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" | jq -c '.languages'" r@ write-line throw - else - \ One per line - s" curl -s -X GET https://api.unsandbox.com/languages -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\" | jq -r '.languages[]'" r@ write-line throw - then + s" RESP=$(curl -s -X GET https://api.unsandbox.com/languages -H \"Authorization: Bearer $PUBLIC_KEY\" -H \"X-Timestamp: $TIMESTAMP\" -H \"X-Signature: $SIGNATURE\")" r@ write-line throw + s" LANGS=$(echo \"$RESP\" | jq -c '.languages // []')" r@ write-line throw + \ Save to cache + s" mkdir -p \"$HOME/.unsandbox\"" r@ write-line throw + s" echo \"{\\\"languages\\\":$LANGS,\\\"timestamp\\\":$(date +%s)}\" > \"$CACHE_FILE\"" r@ write-line throw + \ Output + s" if [ \"$JSON_OUTPUT\" = \"1\" ]; then" r@ write-line throw + s" echo \"$LANGS\"" r@ write-line throw + s" else" r@ write-line throw + s" echo \"$LANGS\" | jq -r '.[]'" r@ write-line throw + s" fi" r@ write-line throw r> close-file throw s" chmod +x /tmp/unsandbox_cmd.sh && /tmp/unsandbox_cmd.sh && rm -f /tmp/unsandbox_cmd.sh" system ; diff --git a/clients/fortran/sync/src/un.f90 b/clients/fortran/sync/src/un.f90 index 11b3b33..e0af7f0 100644 --- a/clients/fortran/sync/src/un.f90 +++ b/clients/fortran/sync/src/un.f90 @@ -116,6 +116,7 @@ module unsandbox_sdk character(len=*), parameter, public :: PORTAL_BASE = 'https://unsandbox.com' integer, parameter, public :: DEFAULT_TTL = 60 integer, parameter, public :: DEFAULT_TIMEOUT = 300 + integer, parameter, public :: LANGUAGES_CACHE_TTL = 3600 ! 1 hour cache TTL !-------------------------------------------------------------------------- ! Type: execution_result @@ -1821,7 +1822,7 @@ contains end subroutine handle_key subroutine handle_languages() - character(len=4096) :: full_cmd + character(len=8192) :: full_cmd character(len=256) :: arg character(len=1024) :: public_key, secret_key integer :: i, stat @@ -1845,23 +1846,47 @@ contains end if if (json_mode) then - ! Output as JSON array - write(full_cmd, '(20A)') & + ! Output as JSON array with caching + write(full_cmd, '(40A)') & + 'CACHE_TTL=3600; ', & + 'CACHE_FILE="$HOME/.unsandbox/languages.json"; ', & + 'if [ -f "$CACHE_FILE" ]; then ', & + 'CACHE_TS=$(jq -r ".timestamp // 0" "$CACHE_FILE" 2>/dev/null); ', & + 'CURRENT_TS=$(date +%s); ', & + 'AGE=$((CURRENT_TS - CACHE_TS)); ', & + 'if [ $AGE -lt $CACHE_TTL ]; then ', & + 'jq -c ".languages" "$CACHE_FILE"; exit 0; fi; fi; ', & 'TS=$(date +%s); ', & 'SIG=$(echo -n "$TS:GET:/languages:" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & - 'curl -s -X GET https://api.unsandbox.com/languages ', & + 'RESP=$(curl -s -X GET https://api.unsandbox.com/languages ', & '-H "Authorization: Bearer ', trim(public_key), '" ', & '-H "X-Timestamp: $TS" ', & - '-H "X-Signature: $SIG" | jq -c ".languages"' + '-H "X-Signature: $SIG"); ', & + 'LANGS=$(echo "$RESP" | jq -c ".languages // []"); ', & + 'mkdir -p "$HOME/.unsandbox"; ', & + 'echo "{\"languages\":$LANGS,\"timestamp\":$(date +%s)}" > "$CACHE_FILE"; ', & + 'echo "$LANGS"' else - ! Output one language per line - write(full_cmd, '(20A)') & + ! Output one language per line with caching + write(full_cmd, '(40A)') & + 'CACHE_TTL=3600; ', & + 'CACHE_FILE="$HOME/.unsandbox/languages.json"; ', & + 'if [ -f "$CACHE_FILE" ]; then ', & + 'CACHE_TS=$(jq -r ".timestamp // 0" "$CACHE_FILE" 2>/dev/null); ', & + 'CURRENT_TS=$(date +%s); ', & + 'AGE=$((CURRENT_TS - CACHE_TS)); ', & + 'if [ $AGE -lt $CACHE_TTL ]; then ', & + 'jq -r ".languages[]" "$CACHE_FILE"; exit 0; fi; fi; ', & 'TS=$(date +%s); ', & 'SIG=$(echo -n "$TS:GET:/languages:" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & - 'curl -s -X GET https://api.unsandbox.com/languages ', & + 'RESP=$(curl -s -X GET https://api.unsandbox.com/languages ', & '-H "Authorization: Bearer ', trim(public_key), '" ', & '-H "X-Timestamp: $TS" ', & - '-H "X-Signature: $SIG" | jq -r ".languages[]"' + '-H "X-Signature: $SIG"); ', & + 'LANGS=$(echo "$RESP" | jq -c ".languages // []"); ', & + 'mkdir -p "$HOME/.unsandbox"; ', & + 'echo "{\"languages\":$LANGS,\"timestamp\":$(date +%s)}" > "$CACHE_FILE"; ', & + 'echo "$RESP" | jq -r ".languages[]"' end if call execute_command_line(trim(full_cmd), wait=.true., exitstat=stat) diff --git a/clients/fsharp/sync/src/un.fs b/clients/fsharp/sync/src/un.fs index 86cb11e..6c2a3e7 100644 --- a/clients/fsharp/sync/src/un.fs +++ b/clients/fsharp/sync/src/un.fs @@ -48,6 +48,8 @@ open System.Security.Cryptography let apiBase = "https://api.unsandbox.com" let portalBase = "https://unsandbox.com" +let languagesCacheTtl = 3600 // 1 hour in seconds + let blue = "\x1B[34m" let red = "\x1B[31m" let green = "\x1B[32m" @@ -433,6 +435,38 @@ let apiRequestText (endpoint: string) (method: string) (body: string) (publicKey ex.Message failwithf "HTTP error - %s" errorMsg +let getLanguagesCachePath () = + let home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + Path.Combine(home, ".unsandbox", "languages.json") + +let loadLanguagesCache () = + let cachePath = getLanguagesCachePath () + if File.Exists(cachePath) then + try + let content = File.ReadAllText(cachePath) + let timestamp = extractJsonValue content "timestamp" + match timestamp with + | Some ts -> + let cacheTime = int64 ts + let now = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + if now - cacheTime < int64 languagesCacheTtl then + Some content + else + None + | None -> None + with _ -> None + else + None + +let saveLanguagesCache (languages: string) = + let cachePath = getLanguagesCachePath () + let cacheDir = Path.GetDirectoryName(cachePath) + if not (Directory.Exists(cacheDir)) then + Directory.CreateDirectory(cacheDir) |> ignore + let timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + let cacheContent = sprintf "{\"languages\":%s,\"timestamp\":%d}" languages timestamp + File.WriteAllText(cachePath, cacheContent) + let readEnvFile (path: string) = if not (File.Exists(path)) then failwithf "Env file not found: %s" path @@ -735,33 +769,57 @@ let cmdKey (args: Args) = let cmdLanguages (args: Args) = let (publicKey, secretKey) = getApiKeys args.ApiKey - let result = apiRequest "/languages" "GET" None publicKey secretKey + // Try to load from cache first + let cachedResponse = loadLanguagesCache () + let response = + match cachedResponse with + | Some cached -> cached + | None -> + // Fetch from API and cache the result + let result = apiRequest "/languages" "GET" None publicKey secretKey + // Extract the languages array as a string for caching + match result.TryFind "languages" with + | Some langs -> + let langStr = langs.ToString() + let languagesJson = + if langStr.StartsWith("[") && langStr.EndsWith("]") then + langStr + else + sprintf "[\"%s\"]" langStr + saveLanguagesCache languagesJson + | None -> () + // Return the result as a formatted string + sprintf "{\"languages\":%s}" (match result.TryFind "languages" with | Some l -> l.ToString() | None -> "[]") - // Extract languages array from the response - match result.TryFind "languages" with - | Some langs -> - // Parse the languages - they come as a string representation - let langStr = langs.ToString() - // Simple parsing for array of strings like: python, javascript, ... - let languages = - if langStr.StartsWith("[") && langStr.EndsWith("]") then - langStr.Substring(1, langStr.Length - 2).Split(',') - |> Array.map (fun s -> s.Trim().Trim('"')) - |> Array.filter (fun s -> not (String.IsNullOrEmpty(s))) + // Parse the response (either from cache or fresh) + let langStr = + match extractJsonValue response "languages" with + | Some l -> l + | None -> + // Try to find the array directly + let start = response.IndexOf("[") + let endIdx = response.LastIndexOf("]") + if start >= 0 && endIdx > start then + response.Substring(start, endIdx - start + 1) else - [| langStr |] + "[]" - if args.LanguagesJson then - // Output as JSON array - let jsonArray = sprintf "[%s]" (languages |> Array.map (sprintf "\"%s\"") |> String.concat ",") - printfn "%s" jsonArray + let languages = + if langStr.StartsWith("[") && langStr.EndsWith("]") then + langStr.Substring(1, langStr.Length - 2).Split(',') + |> Array.map (fun s -> s.Trim().Trim('"')) + |> Array.filter (fun s -> not (String.IsNullOrEmpty(s))) else - // Output one language per line - for lang in languages do - printfn "%s" lang - | None -> - // Fallback: try to extract from raw JSON using regex - () + [| langStr |] + + if args.LanguagesJson then + // Output as JSON array + let jsonArray = sprintf "[%s]" (languages |> Array.map (sprintf "\"%s\"") |> String.concat ",") + printfn "%s" jsonArray + else + // Output one language per line + for lang in languages do + printfn "%s" lang let cmdImage (args: Args) = let (publicKey, secretKey) = getApiKeys args.ApiKey diff --git a/clients/haskell/sync/src/un.hs b/clients/haskell/sync/src/un.hs index a5a9960..1103252 100644 --- a/clients/haskell/sync/src/un.hs +++ b/clients/haskell/sync/src/un.hs @@ -65,12 +65,16 @@ import Data.List (isPrefixOf, intercalate) import Data.Char (isDigit, ord) import Text.Printf (printf) import Control.Monad (when, unless, forM_) +import Control.Exception (try, catch, IOError) import qualified Data.ByteString as BS import qualified Data.ByteString.Char8 as BSC import qualified Data.ByteString.Base64 as B64 import Crypto.Hash.SHA256 (hmac) import Numeric (showHex) -import Data.Time.Clock.POSIX (getPOSIXTime) +import Data.Time.Clock.POSIX (getPOSIXTime, posixSecondsToUTCTime) +import Data.Time.Clock (diffUTCTime) +import qualified Data.Time.Clock +import qualified System.Posix.Files -- API constants apiBase :: String @@ -79,6 +83,9 @@ apiBase = "https://api.unsandbox.com" portalBase :: String portalBase = "https://unsandbox.com" +languagesCacheTtl :: Int +languagesCacheTtl = 3600 -- 1 hour in seconds + -- ANSI colors blue, red, green, yellow, reset :: String blue = "\x1b[34m" @@ -986,23 +993,79 @@ imageCommand opts = do putStrLn $ green ++ "Image cloned" ++ reset putStrLn stdout +-- Languages cache functions +getLanguagesCachePath :: IO FilePath +getLanguagesCachePath = do + home <- lookupEnv "HOME" + let homeDir = maybe "." id home + return $ homeDir ++ "/.unsandbox/languages.json" + +loadLanguagesCache :: IO (Maybe [String]) +loadLanguagesCache = do + cachePath <- getLanguagesCachePath + exists <- doesFileExist cachePath + if not exists + then return Nothing + else do + -- Check if cache is fresh (< 1 hour old) + modTime <- getModificationTime cachePath + now <- getCurrentTime + let ageSeconds = floor $ diffUTCTime now modTime + if ageSeconds >= languagesCacheTtl + then return Nothing + else do + content <- readFile cachePath + return $ extractJsonArray content "languages" + where + doesFileExist path = do + result <- try (readFile path) :: IO (Either IOError String) + case result of + Left _ -> return False + Right _ -> return True + getModificationTime path = do + status <- System.Posix.Files.getFileStatus path + return $ posixSecondsToUTCTime $ realToFrac $ modificationTime status + getCurrentTime = Data.Time.Clock.getCurrentTime + +saveLanguagesCache :: [String] -> IO () +saveLanguagesCache languages = do + cachePath <- getLanguagesCachePath + let cacheDir = takeDirectory cachePath + -- Create directory if needed + createDirectoryIfMissing True cacheDir + now <- getPOSIXTime + let timestamp = show (floor now :: Integer) + let langsJson = "[" ++ intercalate "," (map (\l -> "\"" ++ l ++ "\"") languages) ++ "]" + let content = "{\"languages\":" ++ langsJson ++ ",\"timestamp\":" ++ timestamp ++ "}" + writeFile cachePath content + `catch` (\(_ :: IOError) -> return ()) -- Cache failures are non-fatal + where + takeDirectory path = reverse $ dropWhile (/= '/') $ reverse path + -- Languages command languagesCommand :: LanguagesOpts -> IO () languagesCommand opts = do apiKey <- getApiKey - (_, stdout, _) <- curlGet apiKey "https://api.unsandbox.com/languages" + + -- Try cache first + cached <- loadLanguagesCache + langs <- case cached of + Just languages -> return languages + Nothing -> do + (_, stdout, _) <- curlGet apiKey "https://api.unsandbox.com/languages" + let languages = maybe [] id (extractJsonArray stdout "languages") + -- Save to cache + when (not (null languages)) $ saveLanguagesCache languages + return languages + let jsonOutput = langJson opts if jsonOutput then do -- Extract languages array and print as JSON - case extractJsonArray stdout "languages" of - Just langs -> putStrLn $ "[" ++ intercalate "," (map (\l -> "\"" ++ l ++ "\"") langs) ++ "]" - Nothing -> putStrLn stdout + putStrLn $ "[" ++ intercalate "," (map (\l -> "\"" ++ l ++ "\"") langs) ++ "]" else do -- Print each language on its own line - case extractJsonArray stdout "languages" of - Just langs -> mapM_ putStrLn langs - Nothing -> putStrLn stdout + mapM_ putStrLn langs -- Extract JSON array of strings from response extractJsonArray :: String -> String -> Maybe [String] diff --git a/clients/julia/sync/src/un.jl b/clients/julia/sync/src/un.jl index 9a72e46..81549ec 100755 --- a/clients/julia/sync/src/un.jl +++ b/clients/julia/sync/src/un.jl @@ -70,6 +70,7 @@ const RESET = "\033[0m" const API_BASE = "https://api.unsandbox.com" const PORTAL_BASE = "https://unsandbox.com" +const LANGUAGES_CACHE_TTL = 3600 function detect_language(filename::String)::String ext = lowercase(match(r"\.[^.]+$", filename).match) @@ -762,11 +763,65 @@ function validate_key(api_key::String) end end -function cmd_languages(args) - (public_key, secret_key) = get_api_keys(args["api-key"]) +function get_languages_cache_path()::String + home = get(ENV, "HOME", ".") + return joinpath(home, ".unsandbox", "languages.json") +end - result = api_request("/languages", public_key, secret_key) - langs = get(result, "languages", []) +function load_languages_cache()::Union{Vector{String}, Nothing} + cache_path = get_languages_cache_path() + + if !isfile(cache_path) + return nothing + end + + try + content = read(cache_path, String) + data = JSON.parse(content) + timestamp = get(data, "timestamp", 0) + now = Int64(floor(time())) + + if now - timestamp < LANGUAGES_CACHE_TTL + langs = get(data, "languages", []) + return [string(l) for l in langs] + else + return nothing + end + catch + return nothing + end +end + +function save_languages_cache(languages::Vector) + cache_path = get_languages_cache_path() + cache_dir = dirname(cache_path) + + # Ensure directory exists + mkpath(cache_dir) + + timestamp = Int64(floor(time())) + data = Dict("languages" => languages, "timestamp" => timestamp) + + try + open(cache_path, "w") do f + write(f, JSON.json(data)) + end + catch + # Ignore write errors + end +end + +function cmd_languages(args) + # Try to load from cache first + langs = load_languages_cache() + + if langs === nothing + # Cache miss or expired, fetch from API + (public_key, secret_key) = get_api_keys(args["api-key"]) + result = api_request("/languages", public_key, secret_key) + langs = get(result, "languages", []) + save_languages_cache(langs) + end if args["json"] println(JSON.json(langs)) diff --git a/clients/kotlin/sync/src/un.kt b/clients/kotlin/sync/src/un.kt index 998c82a..b17ab23 100644 --- a/clients/kotlin/sync/src/un.kt +++ b/clients/kotlin/sync/src/un.kt @@ -50,6 +50,7 @@ import javax.crypto.spec.SecretKeySpec val API_BASE = "https://api.unsandbox.com" val PORTAL_BASE = "https://unsandbox.com" +val LANGUAGES_CACHE_TTL = 3600L // 1 hour in seconds val BLUE = "\u001B[34m" val RED = "\u001B[31m" val GREEN = "\u001B[32m" @@ -475,12 +476,56 @@ fun cmdService(args: Args) { exitProcess(1) } +fun getLanguagesCachePath(): String { + val home = System.getenv("HOME") ?: System.getProperty("user.home") ?: "." + return "$home/.unsandbox/languages.json" +} + +fun loadLanguagesCache(): List? { + try { + val cacheFile = File(getLanguagesCachePath()) + if (!cacheFile.exists()) return null + + // Check if cache is fresh (< 1 hour old) + val ageSeconds = (System.currentTimeMillis() - cacheFile.lastModified()) / 1000 + if (ageSeconds >= LANGUAGES_CACHE_TTL) return null + + val content = cacheFile.readText() + val parsed = parseJson(content) + @Suppress("UNCHECKED_CAST") + return parsed["languages"] as? List + } catch (e: Exception) { + return null + } +} + +fun saveLanguagesCache(languages: List) { + try { + val cachePath = getLanguagesCachePath() + val cacheDir = File(cachePath).parentFile + if (!cacheDir.exists()) { + cacheDir.mkdirs() + } + val timestamp = System.currentTimeMillis() / 1000 + val data = mapOf("languages" to languages, "timestamp" to timestamp) + File(cachePath).writeText(toJson(data)) + } catch (e: Exception) { + // Cache failures are non-fatal + } +} + fun cmdLanguages(args: Args) { val (publicKey, secretKey) = getApiKeys(args.apiKey) - val result = apiRequest("/languages", "GET", null, publicKey, secretKey) - @Suppress("UNCHECKED_CAST") - val languages = result["languages"] as? List ?: emptyList() + // Try cache first + var languages = loadLanguagesCache() + if (languages == null) { + val result = apiRequest("/languages", "GET", null, publicKey, secretKey) + @Suppress("UNCHECKED_CAST") + languages = result["languages"] as? List ?: emptyList() + // Save to cache + saveLanguagesCache(languages) + } if (args.jsonOutput) { println(toJson(languages)) diff --git a/clients/lisp/sync/src/un.lisp b/clients/lisp/sync/src/un.lisp index 07321c3..318e6c7 100644 --- a/clients/lisp/sync/src/un.lisp +++ b/clients/lisp/sync/src/un.lisp @@ -55,6 +55,8 @@ (defparameter *portal-base* "https://unsandbox.com") +(defparameter *languages-cache-ttl* 3600) ;; 1 hour in seconds + (defparameter *ext-map* '((".hs" . "haskell") (".ml" . "ocaml") (".clj" . "clojure") (".scm" . "scheme") (".lisp" . "commonlisp") (".erl" . "erlang") @@ -555,10 +557,52 @@ (uiop:quit 1)))))) (nreverse files))) +(defun get-languages-cache-path () + "Get the path to the languages cache file" + (let ((home (uiop:getenv "HOME"))) + (format nil "~a/.unsandbox/languages.json" home))) + +(defun load-languages-cache () + "Load languages from cache if valid" + (let ((cache-path (get-languages-cache-path))) + (when (probe-file cache-path) + (handler-case + (let* ((content (read-file cache-path)) + (timestamp-str (parse-json-field content "timestamp"))) + (when timestamp-str + (let* ((cache-time (parse-integer timestamp-str)) + (now (get-universal-time))) + (when (< (- now cache-time) *languages-cache-ttl*) + content)))) + (error () nil))))) + +(defun save-languages-cache (languages-json) + "Save languages to cache" + (let* ((cache-path (get-languages-cache-path)) + (cache-dir (directory-namestring cache-path))) + (ensure-directories-exist cache-path) + (let* ((timestamp (get-universal-time)) + (cache-content (format nil "{\"languages\":~a,\"timestamp\":~a}" languages-json timestamp))) + (with-open-file (stream cache-path :direction :output :if-exists :supersede) + (write-string cache-content stream))))) + (defun languages-cmd (json-output) "List available programming languages" (let* ((api-key (get-api-key)) - (response (curl-get api-key "/languages")) + ;; Try cache first + (cached (load-languages-cache)) + (response (if cached + cached + (let ((resp (curl-get api-key "/languages"))) + ;; Extract and cache the languages array + (let ((start (search "\"languages\":[" resp))) + (when start + (let* ((array-start (+ start (length "\"languages\":"))) + (array-end (position #\] resp :start array-start))) + (when array-end + (let ((languages-json (subseq resp array-start (1+ array-end)))) + (save-languages-cache languages-json)))))) + resp))) (languages-start (search "\"languages\":[" response))) (if json-output ;; JSON output - extract and print the languages array diff --git a/clients/nim/sync/src/un.nim b/clients/nim/sync/src/un.nim index c2248fd..0bdc21e 100644 --- a/clients/nim/sync/src/un.nim +++ b/clients/nim/sync/src/un.nim @@ -53,6 +53,7 @@ const GREEN = "\x1b[32m" YELLOW = "\x1b[33m" RESET = "\x1b[0m" + LANGUAGES_CACHE_TTL = 3600 # 1 hour in seconds let langMap = { ".py": "python", ".js": "javascript", ".ts": "typescript", @@ -97,6 +98,82 @@ proc computeHmac(secretKey: string, message: string): string = proc getTimestamp(): string = result = $toUnix(getTime()) +proc getLanguagesCachePath(): string = + let home = getEnv("HOME", "") + if home == "": + return "" + return joinPath(home, ".unsandbox", "languages.json") + +proc loadLanguagesCache(): string = + let cachePath = getLanguagesCachePath() + if cachePath == "" or not fileExists(cachePath): + return "" + + try: + let content = readFile(cachePath) + # Parse timestamp from JSON + let tsStart = content.find("\"timestamp\":") + if tsStart < 0: + return "" + + let numStart = tsStart + 12 # Length of "\"timestamp\":" + var numEnd = numStart + while numEnd < content.len and content[numEnd] in {'0'..'9'}: + inc numEnd + + if numEnd == numStart: + return "" + + let cachedTime = parseInt(content[numStart.. 0: + if response[bracketEnd] == '[': inc depth + elif response[bracketEnd] == ']': dec depth + inc bracketEnd + + let languagesArray = response[bracketStart.. "" +(** Get languages cache file path *) +let get_languages_cache_path () = + let home = try Sys.getenv "HOME" with Not_found -> "." in + Filename.concat home ".unsandbox/languages.json" + +(** Load languages from cache if valid *) +let load_languages_cache () = + let cache_path = get_languages_cache_path () in + if Sys.file_exists cache_path then + try + let content = read_file cache_path in + let timestamp = extract_json_int content "timestamp" in + match timestamp with + | Some ts -> + let now = int_of_float (Unix.time ()) in + if now - ts < languages_cache_ttl then + Some content + else + None + | None -> None + with _ -> None + else + None + +(** Save languages to cache *) +let save_languages_cache languages_json = + let cache_path = get_languages_cache_path () in + let cache_dir = Filename.dirname cache_path in + (try Unix.mkdir cache_dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ()); + let timestamp = int_of_float (Unix.time ()) in + let cache_content = Printf.sprintf "{\"languages\":%s,\"timestamp\":%d}" languages_json timestamp in + let oc = open_out cache_path in + output_string oc cache_content; + close_out oc + (** Escape JSON string *) let escape_json s = let buf = Buffer.create (String.length s) in @@ -717,13 +755,27 @@ let image ?public_key ?secret_key ?(model="") ?(size="1024x1024") ?(quality="sta (** Get list of supported programming languages. + Uses cached data if available and not expired (1 hour TTL). @param public_key API public key (optional) @param secret_key API secret key (optional) @return JSON response with languages list *) let languages ?public_key ?secret_key () = - api_get ?public_key ?secret_key "/languages" + match load_languages_cache () with + | Some cached -> cached + | None -> + let response = api_get ?public_key ?secret_key "/languages" in + (* Extract and cache the languages array *) + let langs_pattern = "\"languages\":\\s*\\[\\([^]]*\\)\\]" in + let langs_regex = Str.regexp langs_pattern in + (try + let _ = Str.search_forward langs_regex response 0 in + let langs_str = Str.matched_group 1 response in + let languages_json = "[" ^ langs_str ^ "]" in + save_languages_cache languages_json + with Not_found -> ()); + response (* ============================================================================ Client Module diff --git a/clients/powershell/sync/src/un.ps1 b/clients/powershell/sync/src/un.ps1 index 4835111..7be2a4f 100644 --- a/clients/powershell/sync/src/un.ps1 +++ b/clients/powershell/sync/src/un.ps1 @@ -44,6 +44,7 @@ $API_BASE = "https://api.unsandbox.com" $PORTAL_BASE = "https://unsandbox.com" +$LANGUAGES_CACHE_TTL = 3600 # 1 hour in seconds $EXT_MAP = @{ ".ps1" = "powershell"; ".py" = "python"; ".js" = "javascript" @@ -398,13 +399,69 @@ function Invoke-Session { $result | ConvertTo-Json -Depth 5 } +function Get-LanguagesCachePath { + $cacheDir = Join-Path $env:HOME ".unsandbox" + if (-not (Test-Path $cacheDir)) { + New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null + } + return Join-Path $cacheDir "languages.json" +} + +function Get-CachedLanguages { + $cachePath = Get-LanguagesCachePath + if (-not (Test-Path $cachePath)) { + return $null + } + + try { + $cacheContent = Get-Content -Raw $cachePath | ConvertFrom-Json + $currentTime = [int][double]::Parse((Get-Date -UFormat %s)) + + if ($cacheContent.timestamp -and ($currentTime - $cacheContent.timestamp) -lt $LANGUAGES_CACHE_TTL) { + return $cacheContent.languages + } + } catch { + # Cache is invalid, return null + } + return $null +} + +function Save-LanguagesCache { + param($Languages) + + $cachePath = Get-LanguagesCachePath + $timestamp = [int][double]::Parse((Get-Date -UFormat %s)) + + $cacheData = @{ + languages = $Languages + timestamp = $timestamp + } + + try { + $cacheData | ConvertTo-Json -Compress | Set-Content -Path $cachePath + } catch { + # Silently fail if we can't write cache + } +} + function Invoke-Languages { param($Args) $jsonOutput = $Args -contains "--json" - $result = Invoke-Api -Endpoint "/languages" - $languages = $result.languages + # Check cache first + $languages = Get-CachedLanguages + + if (-not $languages) { + # Fetch from API + $result = Invoke-Api -Endpoint "/languages" + $languages = $result.languages + + # Save to cache + if ($languages) { + Save-LanguagesCache -Languages $languages + } + } if ($jsonOutput) { # Output as JSON array diff --git a/clients/prolog/sync/src/un.pro b/clients/prolog/sync/src/un.pro index 2bd863c..d0fa74a 100644 --- a/clients/prolog/sync/src/un.pro +++ b/clients/prolog/sync/src/un.pro @@ -41,6 +41,12 @@ % Constants portal_base('https://unsandbox.com'). +languages_cache_ttl(3600). % 1 hour cache TTL + +% Get languages cache file path +languages_cache_file(Path) :- + getenv('HOME', Home), + atomic_list_concat([Home, '/.unsandbox/languages.json'], Path). % Extension to language mapping ext_lang('.jl', 'julia'). @@ -342,22 +348,64 @@ validate_key(Extend) :- ), shell(Cmd, 0). -% Languages command -languages_command(JsonOutput) :- - get_public_key(PublicKey), - get_secret_key(SecretKey), - ( JsonOutput = true - -> % Output as JSON array - format(atom(Cmd), - 'TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:GET:/languages:"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); curl -s -X GET https://api.unsandbox.com/languages -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" | jq -c ".languages // []"', - [SecretKey, PublicKey]) - ; % Output one language per line - format(atom(Cmd), - 'TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:GET:/languages:"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); curl -s -X GET https://api.unsandbox.com/languages -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE" | jq -r ".languages[]"', - [SecretKey, PublicKey]) +% Read languages cache +read_languages_cache(Languages) :- + languages_cache_file(CacheFile), + exists_file(CacheFile), + languages_cache_ttl(TTL), + % Read cache file and check timestamp + format(atom(Cmd), + 'CACHE=$(cat "~w" 2>/dev/null); if [ -n "$CACHE" ]; then TIMESTAMP=$(echo "$CACHE" | jq -r ".timestamp // 0"); CURRENT=$(date +%s); AGE=$((CURRENT - TIMESTAMP)); if [ $AGE -lt ~w ]; then echo "$CACHE" | jq -c ".languages // []"; fi; fi', + [CacheFile, TTL]), + setup_call_cleanup( + process_create(path(sh), ['-c', Cmd], [stdout(pipe(Out))]), + read_string(Out, _, Output), + close(Out) ), + Output \= '', + Output \= '\n', + Languages = Output. + +% Write languages cache +write_languages_cache(LanguagesJson) :- + languages_cache_file(CacheFile), + getenv('HOME', Home), + atomic_list_concat([Home, '/.unsandbox'], CacheDir), + format(atom(MkdirCmd), 'mkdir -p "~w"', [CacheDir]), + shell(MkdirCmd, 0), + format(atom(Cmd), + 'TIMESTAMP=$(date +%s); echo \'\'\'\'{"languages":~w,"timestamp":\'\'\'\'$TIMESTAMP\'\'\'\'}\'\'\'\'> "~w"', + [LanguagesJson, CacheFile]), shell(Cmd, 0). +% Languages command with caching +languages_command(JsonOutput) :- + % Try to read from cache first + ( read_languages_cache(CachedLangs), + CachedLangs \= '' + -> % Use cached data + ( JsonOutput = true + -> format('~w~n', [CachedLangs]) + ; % Parse and print one per line + format(atom(Cmd), 'echo \'\'~w\'\' | jq -r ".[]"', [CachedLangs]), + shell(Cmd, 0) + ) + ; % No valid cache, fetch from API + get_public_key(PublicKey), + get_secret_key(SecretKey), + ( JsonOutput = true + -> % Output as JSON array and cache + format(atom(Cmd), + 'TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:GET:/languages:"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); RESP=$(curl -s -X GET https://api.unsandbox.com/languages -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE"); LANGS=$(echo "$RESP" | jq -c ".languages // []"); echo "$LANGS"; CACHE_DIR="$HOME/.unsandbox"; mkdir -p "$CACHE_DIR"; echo "{\\\"languages\\\":$LANGS,\\\"timestamp\\\":$(date +%s)}" > "$CACHE_DIR/languages.json"', + [SecretKey, PublicKey]) + ; % Output one language per line and cache + format(atom(Cmd), + 'TIMESTAMP=$(date +%s); MESSAGE="$TIMESTAMP:GET:/languages:"; SIGNATURE=$(echo -n "$MESSAGE" | openssl dgst -sha256 -hmac "~w" -hex | sed \'\'s/.*= //\'\'); RESP=$(curl -s -X GET https://api.unsandbox.com/languages -H "Authorization: Bearer ~w" -H "X-Timestamp: $TIMESTAMP" -H "X-Signature: $SIGNATURE"); LANGS=$(echo "$RESP" | jq -c ".languages // []"); echo "$RESP" | jq -r ".languages[]"; CACHE_DIR="$HOME/.unsandbox"; mkdir -p "$CACHE_DIR"; echo "{\\\"languages\\\":$LANGS,\\\"timestamp\\\":$(date +%s)}" > "$CACHE_DIR/languages.json"', + [SecretKey, PublicKey]) + ), + shell(Cmd, 0) + ). + % Handle languages subcommand handle_languages(['--json'|_]) :- languages_command(true). handle_languages(_) :- languages_command(false). diff --git a/clients/r/sync/src/un.r b/clients/r/sync/src/un.r index 5863c7b..9e15e3f 100644 --- a/clients/r/sync/src/un.r +++ b/clients/r/sync/src/un.r @@ -95,6 +95,11 @@ API_BASE <- "https://api.unsandbox.com" #' @export PORTAL_BASE <- "https://unsandbox.com" +#' @title Languages Cache TTL +#' @description Cache time-to-live in seconds (1 hour) +#' @export +LANGUAGES_CACHE_TTL <- 3600 + MAX_ENV_CONTENT_SIZE <- 65536 # ============================================================================= @@ -1002,13 +1007,88 @@ cmd_session <- function(args) { cat(sprintf("%s(Interactive sessions require WebSocket - use un2 for full support)%s\n", YELLOW, RESET)) } -cmd_languages <- function(args) { - keys <- get_api_keys(args$api_key) - public_key <- keys$public_key - secret_key <- keys$secret_key +#' Get Languages Cache Path +#' +#' Returns the path to the languages cache file. +#' +#' @return Path to ~/.unsandbox/languages.json +#' @keywords internal +get_languages_cache_path <- function() { + home <- Sys.getenv("HOME") + if (home == "") { + home <- "." + } + return(file.path(home, ".unsandbox", "languages.json")) +} - result <- api_request("/languages", public_key, secret_key) - langs <- result$languages +#' Load Languages Cache +#' +#' Loads languages from the cache file if it exists and is not expired. +#' +#' @return Vector of language names, or NULL if cache is missing/expired +#' @keywords internal +load_languages_cache <- function() { + cache_path <- get_languages_cache_path() + + if (!file.exists(cache_path)) { + return(NULL) + } + + tryCatch({ + content <- paste(readLines(cache_path, warn = FALSE), collapse = "\n") + data <- fromJSON(content) + timestamp <- data$timestamp + now <- as.integer(Sys.time()) + + if (!is.null(timestamp) && (now - timestamp) < LANGUAGES_CACHE_TTL) { + return(data$languages) + } else { + return(NULL) + } + }, error = function(e) { + return(NULL) + }) +} + +#' Save Languages Cache +#' +#' Saves languages to the cache file. +#' +#' @param languages Vector of language names +#' @keywords internal +save_languages_cache <- function(languages) { + cache_path <- get_languages_cache_path() + cache_dir <- dirname(cache_path) + + # Ensure directory exists + if (!dir.exists(cache_dir)) { + dir.create(cache_dir, recursive = TRUE, showWarnings = FALSE) + } + + timestamp <- as.integer(Sys.time()) + data <- list(languages = languages, timestamp = timestamp) + + tryCatch({ + writeLines(toJSON(data, auto_unbox = TRUE), cache_path) + }, error = function(e) { + # Ignore write errors + }) +} + +cmd_languages <- function(args) { + # Try to load from cache first + langs <- load_languages_cache() + + if (is.null(langs)) { + # Cache miss or expired, fetch from API + keys <- get_api_keys(args$api_key) + public_key <- keys$public_key + secret_key <- keys$secret_key + + result <- api_request("/languages", public_key, secret_key) + langs <- result$languages + save_languages_cache(langs) + } if (!is.null(args$json_output) && args$json_output) { # JSON output - print as JSON array diff --git a/clients/scheme/sync/src/un.scm b/clients/scheme/sync/src/un.scm index bf6161b..c84c348 100644 --- a/clients/scheme/sync/src/un.scm +++ b/clients/scheme/sync/src/un.scm @@ -55,6 +55,8 @@ (define portal-base "https://unsandbox.com") +(define languages-cache-ttl 3600) ;; 1 hour in seconds + (define ext-map '((".hs" . "haskell") (".ml" . "ocaml") (".clj" . "clojure") (".scm" . "scheme") (".lisp" . "commonlisp") (".erl" . "erlang") @@ -396,9 +398,55 @@ (close-pipe port) (filter (lambda (s) (> (string-length s) 0)) result))) +(define (get-languages-cache-path) + "Get the path to the languages cache file" + (let ((home (getenv "HOME"))) + (string-append home "/.unsandbox/languages.json"))) + +(define (load-languages-cache) + "Load languages from cache if valid" + (let ((cache-path (get-languages-cache-path))) + (if (file-exists? cache-path) + (catch #t + (lambda () + (let* ((content (read-file cache-path)) + (timestamp-str (json-extract-string content "timestamp"))) + (if timestamp-str + (let* ((cache-time (string->number timestamp-str)) + (now (current-time))) + (if (< (- now cache-time) languages-cache-ttl) + content + #f)) + #f))) + (lambda args #f)) + #f))) + +(define (save-languages-cache languages-json) + "Save languages to cache" + (let* ((cache-path (get-languages-cache-path)) + (cache-dir (dirname cache-path))) + ;; Create directory if it doesn't exist + (catch #t + (lambda () (mkdir cache-dir)) + (lambda args #f)) + (let* ((timestamp (current-time)) + (cache-content (format #f "{\"languages\":~a,\"timestamp\":~a}" languages-json timestamp))) + (call-with-output-file cache-path + (lambda (port) (display cache-content port)))))) + (define (languages-cmd json-output) (let* ((api-key (get-api-key)) - (response (curl-get api-key "/languages")) + ;; Try cache first + (cached (load-languages-cache)) + (response (if cached + cached + (let ((resp (curl-get api-key "/languages"))) + ;; Extract and cache the languages array + (let ((langs (json-extract-array resp "languages"))) + (when (not (null? langs)) + (let ((languages-json (string-append "[" (string-join (map (lambda (l) (format #f "\"~a\"" l)) langs) ",") "]"))) + (save-languages-cache languages-json)))) + resp))) (langs (json-extract-array response "languages"))) (if json-output ;; JSON array output diff --git a/clients/tcl/sync/src/un.tcl b/clients/tcl/sync/src/un.tcl index 12aa9c2..69c7d62 100755 --- a/clients/tcl/sync/src/un.tcl +++ b/clients/tcl/sync/src/un.tcl @@ -49,6 +49,8 @@ package require sha256 set API_BASE "https://api.unsandbox.com" set PORTAL_BASE "https://unsandbox.com" +set LANGUAGES_CACHE_TTL 3600 +set LANGUAGES_CACHE_FILE [file join $::env(HOME) ".unsandbox" "languages.json"] set BLUE "\033\[34m" set RED "\033\[31m" set GREEN "\033\[32m" @@ -573,8 +575,57 @@ proc cmd_session {args} { puts "${::YELLOW}(Interactive sessions require WebSocket - use un2 for full support)${::RESET}" } +proc read_languages_cache {} { + if {![file exists $::LANGUAGES_CACHE_FILE]} { + return {} + } + + if {[catch {open $::LANGUAGES_CACHE_FILE r} fp]} { + return {} + } + set content [read $fp] + close $fp + + if {[catch {::json::json2dict $content} cache_data]} { + return {} + } + + # Check if cache is valid (within TTL) + if {[dict exists $cache_data timestamp]} { + set cache_time [dict get $cache_data timestamp] + set current_time [clock seconds] + if {($current_time - $cache_time) < $::LANGUAGES_CACHE_TTL} { + if {[dict exists $cache_data languages]} { + return [dict get $cache_data languages] + } + } + } + return {} +} + +proc write_languages_cache {languages} { + # Ensure ~/.unsandbox directory exists + set cache_dir [file dirname $::LANGUAGES_CACHE_FILE] + if {![file exists $cache_dir]} { + file mkdir $cache_dir + } + + # Build cache JSON + set json_langs [list] + foreach lang $languages { + lappend json_langs [::json::write string $lang] + } + set langs_array [::json::write array {*}$json_langs] + set timestamp [clock seconds] + set cache_json [::json::write object languages $langs_array timestamp $timestamp] + + # Write cache file + set fp [open $::LANGUAGES_CACHE_FILE w] + puts -nonewline $fp $cache_json + close $fp +} + proc cmd_languages {args} { - lassign [get_api_keys] public_key secret_key set json_output 0 # Parse arguments @@ -585,9 +636,31 @@ proc cmd_languages {args} { } } + # Try to read from cache first + set cached_langs [read_languages_cache] + if {[llength $cached_langs] > 0} { + if {$json_output} { + set json_langs [list] + foreach lang $cached_langs { + lappend json_langs [::json::write string $lang] + } + puts [::json::write array {*}$json_langs] + } else { + foreach lang $cached_langs { + puts $lang + } + } + return + } + + # No valid cache, fetch from API + lassign [get_api_keys] public_key secret_key set result [api_request "/languages" "GET" {} $public_key $secret_key] set langs [dict get $result languages] + # Save to cache + write_languages_cache $langs + if {$json_output} { # JSON array output set json_langs [list] diff --git a/clients/typescript/sync/src/un.ts b/clients/typescript/sync/src/un.ts index 8c95054..a6a3c98 100644 --- a/clients/typescript/sync/src/un.ts +++ b/clients/typescript/sync/src/un.ts @@ -58,6 +58,7 @@ import * as crypto from 'crypto'; const API_BASE = "https://api.unsandbox.com"; const PORTAL_BASE = "https://unsandbox.com"; +const LANGUAGES_CACHE_TTL = 3600; // 1 hour in seconds const BLUE = "\x1b[34m"; const RED = "\x1b[31m"; const GREEN = "\x1b[32m"; @@ -408,6 +409,43 @@ function readEnvFile(filepath: string): string { } } +function getLanguagesCachePath(): string { + const homeDir = process.env.HOME || process.env.USERPROFILE || '.'; + return path.join(homeDir, '.unsandbox', 'languages.json'); +} + +function loadLanguagesCache(): string[] | null { + try { + const cachePath = getLanguagesCachePath(); + if (!fs.existsSync(cachePath)) { + return null; + } + const stat = fs.statSync(cachePath); + const ageSeconds = (Date.now() - stat.mtimeMs) / 1000; + if (ageSeconds >= LANGUAGES_CACHE_TTL) { + return null; + } + const data = JSON.parse(fs.readFileSync(cachePath, 'utf-8')); + return data.languages || null; + } catch (e) { + return null; + } +} + +function saveLanguagesCache(languages: string[]): void { + try { + const cachePath = getLanguagesCachePath(); + const cacheDir = path.dirname(cachePath); + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }); + } + const data = { languages: languages, timestamp: Math.floor(Date.now() / 1000) }; + fs.writeFileSync(cachePath, JSON.stringify(data)); + } catch (e) { + // Cache failures are non-fatal + } +} + function buildEnvContent(envs: string[], envFile: string | null): string { const parts: string[] = []; @@ -803,8 +841,15 @@ async function validateKey(keys: ApiKeys, shouldExtend: boolean): Promise async function cmdLanguages(args: Args): Promise { const keys = getApiKeys(args.apiKey); - const result = await apiRequest("/languages", "GET", null, keys); - const langs = result.languages || []; + + // Try cache first + let langs = loadLanguagesCache(); + if (!langs) { + const result = await apiRequest("/languages", "GET", null, keys); + langs = result.languages || []; + // Save to cache + saveLanguagesCache(langs); + } if (args.jsonOutput) { // JSON array output diff --git a/clients/v/sync/src/un.v b/clients/v/sync/src/un.v index bd17ae3..6c6469c 100644 --- a/clients/v/sync/src/un.v +++ b/clients/v/sync/src/un.v @@ -48,6 +48,7 @@ import os const api_base = 'https://api.unsandbox.com' const portal_base = 'https://unsandbox.com' const max_env_content_size = 65536 +const languages_cache_ttl = 3600 // 1 hour in seconds const blue = '\x1b[34m' const red = '\x1b[31m' const green = '\x1b[32m' @@ -687,55 +688,163 @@ fn cmd_image(list bool, info string, delete string, lock string, unlock string, exit(1) } +fn get_languages_cache_path() string { + home := os.getenv('HOME') + cache_dir := '${home}/.unsandbox' + if !os.exists(cache_dir) { + os.mkdir(cache_dir) or {} + } + return '${cache_dir}/languages.json' +} + +fn get_cached_languages() ?[]string { + cache_path := get_languages_cache_path() + if !os.exists(cache_path) { + return none + } + + content := os.read_file(cache_path) or { return none } + + // Extract timestamp + timestamp_str := extract_json_string(content, 'timestamp') + if timestamp_str == '' { + return none + } + + // Parse timestamp (it's stored as a number, not a string) + // Look for "timestamp": followed by digits + ts_search := '"timestamp":' + ts_idx := content.index(ts_search) or { return none } + ts_start := ts_idx + ts_search.len + mut ts_end := ts_start + for ts_end < content.len && (content[ts_end].is_digit() || content[ts_end] == ` `) { + ts_end++ + } + timestamp_num := content[ts_start..ts_end].trim_space() + cache_time := timestamp_num.i64() + + // Get current time + current_time_cmd := 'date +%s' + current_time_result := os.execute(current_time_cmd) + current_time := current_time_result.output.trim_space().i64() + + // Check if cache is still valid + if current_time - cache_time >= languages_cache_ttl { + return none + } + + // Extract languages array + start_idx := content.index('"languages":[') or { return none } + arr_start := content.index_after('[', start_idx) + if arr_start < 0 { + return none + } + arr_end := content.index_after(']', arr_start) + if arr_end < 0 { + return none + } + + arr_content := content[arr_start + 1..arr_end] + mut languages := []string{} + for item in arr_content.split(',') { + lang := item.trim_space().trim('"') + if lang.len > 0 { + languages << lang + } + } + + return languages +} + +fn save_languages_cache(languages []string) { + cache_path := get_languages_cache_path() + + // Get current timestamp + timestamp_cmd := 'date +%s' + timestamp_result := os.execute(timestamp_cmd) + timestamp := timestamp_result.output.trim_space() + + // Build JSON array + mut lang_json := '[' + for i, lang in languages { + if i > 0 { + lang_json += ',' + } + lang_json += '"${lang}"' + } + lang_json += ']' + + cache_content := '{"languages":${lang_json},"timestamp":${timestamp}}' + os.write_file(cache_path, cache_content) or {} +} + fn cmd_languages(json_output bool, api_key string) { + // Check cache first + cached := get_cached_languages() + + if cached != none { + languages := cached or { []string{} } + if json_output { + mut lang_json := '[' + for i, lang in languages { + if i > 0 { + lang_json += ',' + } + lang_json += '"${lang}"' + } + lang_json += ']' + println(lang_json) + } else { + for lang in languages { + println(lang) + } + } + return + } + + // Fetch from API pub_key := get_public_key() secret_key := get_secret_key() cmd := "TIMESTAMP=\$(date +%s); MESSAGE=\"\$TIMESTAMP:GET:/languages:\"; SIGNATURE=\$(echo -n \"\$MESSAGE\" | openssl dgst -sha256 -hmac '${secret_key}' -hex | sed 's/.*= //'); curl -s -X GET '${api_base}/languages' -H 'Authorization: Bearer ${pub_key}' -H \"X-Timestamp: \$TIMESTAMP\" -H \"X-Signature: \$SIGNATURE\"" result := exec_curl(cmd) + // Parse and cache the languages + start_idx := result.index('"languages":[') or { + println(result) + return + } + arr_start := result.index_after('[', start_idx) + if arr_start < 0 { + println(result) + return + } + arr_end := result.index_after(']', arr_start) + if arr_end < 0 { + println(result) + return + } + + // Extract languages for caching + arr_content := result[arr_start + 1..arr_end] + mut languages := []string{} + for item in arr_content.split(',') { + lang := item.trim_space().trim('"') + if lang.len > 0 { + languages << lang + } + } + + // Save to cache + if languages.len > 0 { + save_languages_cache(languages) + } + if json_output { - // Extract languages array and print as JSON - // Find the languages array in the response - start_idx := result.index('"languages":[') or { - println(result) - return - } - arr_start := result.index_after('[', start_idx) - if arr_start < 0 { - println(result) - return - } - arr_end := result.index_after(']', arr_start) - if arr_end < 0 { - println(result) - return - } println(result[arr_start..arr_end + 1]) } else { - // Parse languages array and print one per line - start_idx := result.index('"languages":[') or { - println(result) - return - } - arr_start := result.index_after('[', start_idx) - if arr_start < 0 { - println(result) - return - } - arr_end := result.index_after(']', arr_start) - if arr_end < 0 { - println(result) - return - } - // Extract array content and parse - arr_content := result[arr_start + 1..arr_end] - // Split by comma and extract language names - for item in arr_content.split(',') { - lang := item.trim_space().trim('"') - if lang.len > 0 { - println(lang) - } + for lang in languages { + println(lang) } } } diff --git a/clients/zig/sync/src/un.zig b/clients/zig/sync/src/un.zig index 23f8d43..285a937 100644 --- a/clients/zig/sync/src/un.zig +++ b/clients/zig/sync/src/un.zig @@ -73,6 +73,7 @@ const time = std.time; const API_BASE = "https://api.unsandbox.com"; const PORTAL_BASE = "https://unsandbox.com"; const MAX_ENV_CONTENT_SIZE: usize = 65536; +const LANGUAGES_CACHE_TTL: i64 = 3600; // 1 hour in seconds const GREEN = "\x1b[32m"; const RED = "\x1b[31m"; const YELLOW = "\x1b[33m"; @@ -733,22 +734,90 @@ pub fn main() !u8 { } } - // Fetch languages from API - const json_file = "/tmp/unsandbox_languages.json"; - const auth_headers = try buildAuthCmd(allocator, "GET", "/languages", "", public_key, secret_key); - defer allocator.free(auth_headers); - const cmd = try std.fmt.allocPrint(allocator, "curl -s -X GET '{s}/languages' {s} -o {s}", .{ API_BASE, auth_headers, json_file }); - defer allocator.free(cmd); - _ = std.c.system(cmd.ptr); + // Get cache path (~/.unsandbox/languages.json) + const home_env = std.process.getEnvVarOwned(allocator, "HOME") catch try allocator.dupe(u8, "/tmp"); + defer allocator.free(home_env); + const cache_dir = try std.fmt.allocPrint(allocator, "{s}/.unsandbox", .{home_env}); + defer allocator.free(cache_dir); + const cache_file = try std.fmt.allocPrint(allocator, "{s}/languages.json", .{cache_dir}); + defer allocator.free(cache_file); - // Read the JSON response - const json_content = fs.cwd().readFileAlloc(allocator, json_file, 1024 * 1024) catch |err| { - std.debug.print("{s}Error reading languages response: {}{s}\n", .{ RED, err, RESET }); - std.fs.cwd().deleteFile(json_file) catch {}; - return 1; + // Ensure cache directory exists + fs.cwd().makeDir(cache_dir) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => {}, }; - defer allocator.free(json_content); - std.fs.cwd().deleteFile(json_file) catch {}; + + // Check cache first + var use_cache = false; + const cache_content = fs.cwd().readFileAlloc(allocator, cache_file, 1024 * 1024) catch null; + defer if (cache_content) |cc| allocator.free(cc); + + if (cache_content) |cc| { + // Check timestamp + const ts_prefix = "\"timestamp\":"; + if (mem.indexOf(u8, cc, ts_prefix)) |ts_start_idx| { + const ts_value_start = ts_start_idx + ts_prefix.len; + var ts_value_end = ts_value_start; + while (ts_value_end < cc.len and (cc[ts_value_end] >= '0' and cc[ts_value_end] <= '9')) { + ts_value_end += 1; + } + if (ts_value_end > ts_value_start) { + const ts_str = cc[ts_value_start..ts_value_end]; + const cache_timestamp = std.fmt.parseInt(i64, ts_str, 10) catch 0; + const current_timestamp = std.time.timestamp(); + if (current_timestamp - cache_timestamp < LANGUAGES_CACHE_TTL) { + use_cache = true; + } + } + } + } + + var json_content: []const u8 = undefined; + var json_content_owned = false; + + if (use_cache) { + json_content = cache_content.?; + } else { + // Fetch languages from API + const json_file = "/tmp/unsandbox_languages_tmp.json"; + const auth_headers = try buildAuthCmd(allocator, "GET", "/languages", "", public_key, secret_key); + defer allocator.free(auth_headers); + const cmd = try std.fmt.allocPrint(allocator, "curl -s -X GET '{s}/languages' {s} -o {s}", .{ API_BASE, auth_headers, json_file }); + defer allocator.free(cmd); + _ = std.c.system(cmd.ptr); + + // Read the JSON response + const api_content = fs.cwd().readFileAlloc(allocator, json_file, 1024 * 1024) catch |err| { + std.debug.print("{s}Error reading languages response: {}{s}\n", .{ RED, err, RESET }); + std.fs.cwd().deleteFile(json_file) catch {}; + return 1; + }; + json_content = api_content; + json_content_owned = true; + std.fs.cwd().deleteFile(json_file) catch {}; + + // Save to cache with timestamp + const arr_prefix = "\"languages\":["; + if (mem.indexOf(u8, api_content, arr_prefix)) |start_idx| { + const arr_start = start_idx + arr_prefix.len - 1; // Include the '[' + if (mem.indexOfPos(u8, api_content, arr_start, "]")) |end_idx| { + const languages_array = api_content[arr_start .. end_idx + 1]; + const current_ts = std.time.timestamp(); + const cache_data = try std.fmt.allocPrint(allocator, "{{\"languages\":{s},\"timestamp\":{d}}}", .{ languages_array, current_ts }); + defer allocator.free(cache_data); + + // Write cache file + const cache_file_handle = fs.cwd().createFile(cache_file, .{}) catch null; + if (cache_file_handle) |fh| { + fh.writeAll(cache_data) catch {}; + fh.close(); + } + } + } + } + + defer if (json_content_owned) allocator.free(json_content); if (json_output) { // Find and print just the languages array