diff --git a/clients/csharp/dotnet/src/Program.cs b/clients/csharp/dotnet/src/Program.cs deleted file mode 100644 index a19be19..0000000 --- a/clients/csharp/dotnet/src/Program.cs +++ /dev/null @@ -1,1014 +0,0 @@ -// PUBLIC DOMAIN - NO LICENSE, NO WARRANTY -// -// This is free public domain software for the public good of a permacomputer hosted -// at permacomputer.com - an always-on computer by the people, for the people. One -// which is durable, easy to repair, and distributed like tap water for machine -// learning intelligence. -// -// The permacomputer is community-owned infrastructure optimized around four values: -// -// TRUTH - First principles, math & science, open source code freely distributed -// FREEDOM - Voluntary partnerships, freedom from tyranny & corporate control -// HARMONY - Minimal waste, self-renewing systems with diverse thriving connections -// LOVE - Be yourself without hurting others, cooperation through natural law -// -// This software contributes to that vision by enabling code execution across 42+ -// programming languages through a unified interface, accessible to all. Code is -// seeds to sprout on any abandoned technology. -// -// Learn more: https://www.permacomputer.com -// -// Anyone is free to copy, modify, publish, use, compile, sell, or distribute this -// software, either in source code form or as a compiled binary, for any purpose, -// commercial or non-commercial, and by any means. -// -// NO WARRANTY. THE SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND. -// -// That said, our permacomputer's digital membrane stratum continuously runs unit, -// integration, and functional tests on all of it's own software - with our -// permacomputer monitoring itself, repairing itself, with minimal human in the -// loop guidance. Our agents do their best. -// -// Copyright 2025 TimeHexOn & foxhop & russell@unturf -// https://www.timehexon.com -// https://www.foxhop.net -// https://www.unturf.com/software - - -// Program.cs - Unsandbox CLI Client (.NET 10 Implementation) -// Build: dotnet build -// Run: dotnet run -- [options] -// Requires: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY environment variables - -using System.Diagnostics; -using System.Net.Http.Headers; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace Unsandbox.Cli; - -class Program -{ - private const string API_BASE = "https://api.unsandbox.com"; - private const string PORTAL_BASE = "https://unsandbox.com"; - private const string BLUE = "\x1B[34m"; - private const string RED = "\x1B[31m"; - private const string GREEN = "\x1B[32m"; - private const string YELLOW = "\x1B[33m"; - private const string RESET = "\x1B[0m"; - - private static readonly HttpClient httpClient = new() - { - Timeout = TimeSpan.FromMinutes(5) - }; - - private static readonly Dictionary ExtMap = new() - { - {".py", "python"}, {".js", "javascript"}, {".ts", "typescript"}, - {".rb", "ruby"}, {".php", "php"}, {".pl", "perl"}, {".lua", "lua"}, - {".sh", "bash"}, {".go", "go"}, {".rs", "rust"}, {".c", "c"}, - {".cpp", "cpp"}, {".cc", "cpp"}, {".cxx", "cpp"}, - {".java", "java"}, {".kt", "kotlin"}, {".cs", "csharp"}, {".fs", "fsharp"}, - {".hs", "haskell"}, {".ml", "ocaml"}, {".clj", "clojure"}, {".scm", "scheme"}, - {".lisp", "commonlisp"}, {".erl", "erlang"}, {".ex", "elixir"}, {".exs", "elixir"}, - {".jl", "julia"}, {".r", "r"}, {".R", "r"}, {".cr", "crystal"}, - {".d", "d"}, {".nim", "nim"}, {".zig", "zig"}, {".v", "v"}, - {".dart", "dart"}, {".groovy", "groovy"}, {".scala", "scala"}, - {".f90", "fortran"}, {".f95", "fortran"}, {".cob", "cobol"}, - {".pro", "prolog"}, {".forth", "forth"}, {".4th", "forth"}, - {".tcl", "tcl"}, {".raku", "raku"}, {".m", "objc"} - }; - - static async Task Main(string[] args) - { - try - { - var parsedArgs = ParseArgs(args); - - if (parsedArgs.Command == "session") - { - await CmdSession(parsedArgs); - } - else if (parsedArgs.Command == "service") - { - await CmdService(parsedArgs); - } - else if (parsedArgs.Command == "key") - { - await CmdKey(parsedArgs); - } - else if (parsedArgs.SourceFile != null) - { - return await CmdExecute(parsedArgs); - } - else - { - PrintHelp(); - return 1; - } - return 0; - } - catch (Exception ex) - { - Console.Error.WriteLine($"{RED}Error: {ex.Message}{RESET}"); - return 1; - } - } - - static async Task CmdExecute(Args args) - { - var (publicKey, secretKey) = GetApiKeys(args.ApiKey); - string code = await File.ReadAllTextAsync(args.SourceFile!); - string language = DetectLanguage(args.SourceFile!); - - var payload = new Dictionary - { - ["language"] = language, - ["code"] = code - }; - - if (args.Env.Count > 0) - { - var envVars = new Dictionary(); - foreach (var e in args.Env) - { - var parts = e.Split('=', 2); - if (parts.Length == 2) - { - envVars[parts[0]] = parts[1]; - } - } - if (envVars.Count > 0) - { - payload["env"] = envVars; - } - } - - if (args.Files.Count > 0) - { - var inputFiles = new List>(); - foreach (var filepath in args.Files) - { - var content = await File.ReadAllBytesAsync(filepath); - inputFiles.Add(new Dictionary - { - ["filename"] = Path.GetFileName(filepath), - ["content_base64"] = Convert.ToBase64String(content) - }); - } - payload["input_files"] = inputFiles; - } - - if (args.Artifacts) - { - payload["return_artifacts"] = true; - } - if (args.Network != null) - { - payload["network"] = args.Network; - } - if (args.Vcpu > 0) - { - payload["vcpu"] = args.Vcpu; - } - - var result = await ApiRequest("/execute", "POST", payload, publicKey, secretKey); - - if (result.TryGetValue("stdout", out var stdout) && !string.IsNullOrEmpty(stdout.ToString())) - { - Console.Write($"{BLUE}{stdout}{RESET}"); - } - if (result.TryGetValue("stderr", out var stderr) && !string.IsNullOrEmpty(stderr.ToString())) - { - Console.Error.Write($"{RED}{stderr}{RESET}"); - } - - if (args.Artifacts && result.TryGetValue("artifacts", out var artifactsObj)) - { - var artifacts = ((JsonArray)artifactsObj!).Deserialize>>(); - string outDir = args.OutputDir ?? "."; - Directory.CreateDirectory(outDir); - - if (artifacts != null) - { - foreach (var artifact in artifacts) - { - string filename = artifact.ContainsKey("filename") ? artifact["filename"] : "artifact"; - byte[] content = Convert.FromBase64String(artifact["content_base64"]); - string path = Path.Combine(outDir, filename); - await File.WriteAllBytesAsync(path, content); - Console.Error.WriteLine($"{GREEN}Saved: {path}{RESET}"); - } - } - } - - int exitCode = result.ContainsKey("exit_code") ? Convert.ToInt32(result["exit_code"]) : 0; - return exitCode; - } - - static async Task CmdSession(Args args) - { - var (publicKey, secretKey) = GetApiKeys(args.ApiKey); - - if (args.SessionList) - { - var result = await ApiRequest("/sessions", "GET", null, publicKey, secretKey); - var sessions = result.TryGetValue("sessions", out var sessionsObj) - ? ((JsonArray)sessionsObj!).Deserialize>>() - : null; - - if (sessions == null || sessions.Count == 0) - { - Console.WriteLine("No active sessions"); - } - else - { - Console.WriteLine("{0,-40} {1,-10} {2,-10} {3}", "ID", "Shell", "Status", "Created"); - foreach (var s in sessions) - { - Console.WriteLine("{0,-40} {1,-10} {2,-10} {3}", - s.GetValueOrDefault("id", "N/A"), - s.GetValueOrDefault("shell", "N/A"), - s.GetValueOrDefault("status", "N/A"), - s.GetValueOrDefault("created_at", "N/A")); - } - } - return; - } - - if (args.SessionKill != null) - { - await ApiRequest($"/sessions/{args.SessionKill}", "DELETE", null, publicKey, secretKey); - Console.WriteLine($"{GREEN}Session terminated: {args.SessionKill}{RESET}"); - return; - } - - var payload = new Dictionary - { - ["shell"] = args.SessionShell ?? "bash" - }; - if (args.Network != null) - { - payload["network"] = args.Network; - } - if (args.Vcpu > 0) - { - payload["vcpu"] = args.Vcpu; - } - - Console.WriteLine($"{YELLOW}Creating session...{RESET}"); - var createResult = await ApiRequest("/sessions", "POST", payload, publicKey, secretKey); - Console.WriteLine($"{GREEN}Session created: {createResult["id"]}{RESET}"); - Console.WriteLine($"{YELLOW}(Interactive sessions require WebSocket - use un2 for full support){RESET}"); - } - - static async Task CmdKey(Args args) - { - var (publicKey, secretKey) = GetApiKeys(args.ApiKey); - - var result = await ApiRequest("/keys/validate", "POST", null, publicKey, secretKey); - - if (!result.ContainsKey("valid")) - { - Console.Error.WriteLine($"{RED}Error: Invalid response from server{RESET}"); - Environment.Exit(1); - } - - bool isValid = (bool)result["valid"]!; - bool isExpired = result.TryGetValue("expired", out var expiredObj) && (bool)expiredObj!; - - if (isValid && !isExpired) - { - Console.WriteLine($"{GREEN}Valid{RESET}"); - if (result.TryGetValue("public_key", out var pk)) - { - Console.WriteLine($"Public Key: {pk}"); - } - if (result.TryGetValue("tier", out var tier)) - { - Console.WriteLine($"Tier: {tier}"); - } - if (result.TryGetValue("expires_at", out var expiresAt)) - { - Console.WriteLine($"Expires: {expiresAt}"); - } - } - else if (isExpired) - { - Console.WriteLine($"{RED}Expired{RESET}"); - if (result.TryGetValue("public_key", out var pk)) - { - Console.WriteLine($"Public Key: {pk}"); - } - if (result.TryGetValue("tier", out var tier)) - { - Console.WriteLine($"Tier: {tier}"); - } - if (result.TryGetValue("expired_at", out var expiredAt)) - { - Console.WriteLine($"Expired: {expiredAt}"); - } - Console.WriteLine($"{YELLOW}To renew: Visit {PORTAL_BASE}/keys/extend{RESET}"); - - if (args.KeyExtend && result.TryGetValue("public_key", out var pkForExtend)) - { - string url = $"{PORTAL_BASE}/keys/extend?pk={pkForExtend}"; - Console.WriteLine($"{YELLOW}Opening: {url}{RESET}"); - OpenBrowser(url); - } - } - else - { - Console.WriteLine($"{RED}Invalid{RESET}"); - } - } - - static void OpenBrowser(string url) - { - try - { - if (OperatingSystem.IsWindows()) - { - Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); - } - else if (OperatingSystem.IsLinux()) - { - Process.Start("xdg-open", url); - } - else if (OperatingSystem.IsMacOS()) - { - Process.Start("open", url); - } - } - catch (Exception ex) - { - Console.Error.WriteLine($"{RED}Failed to open browser: {ex.Message}{RESET}"); - } - } - - static async Task CmdService(Args args) - { - var (publicKey, secretKey) = GetApiKeys(args.ApiKey); - - // Handle env subcommand - if (!string.IsNullOrEmpty(args.EnvAction)) - { - await CmdServiceEnv(args, publicKey, secretKey); - return; - } - - if (args.ServiceList) - { - var result = await ApiRequest("/services", "GET", null, publicKey, secretKey); - var services = result.TryGetValue("services", out var servicesObj) - ? ((JsonArray)servicesObj!).Deserialize>>() - : null; - - if (services == null || services.Count == 0) - { - Console.WriteLine("No services"); - } - else - { - Console.WriteLine("{0,-20} {1,-15} {2,-10} {3,-15} {4}", "ID", "Name", "Status", "Ports", "Domains"); - foreach (var s in services) - { - var ports = s.ContainsKey("ports") ? s["ports"].Deserialize>() : null; - var domains = s.ContainsKey("domains") ? s["domains"].Deserialize>() : null; - string portsStr = ports != null ? string.Join(",", ports) : ""; - string domainsStr = domains != null ? string.Join(",", domains) : ""; - Console.WriteLine("{0,-20} {1,-15} {2,-10} {3,-15} {4}", - s.GetValueOrDefault("id").ToString(), - s.GetValueOrDefault("name").ToString(), - s.GetValueOrDefault("status").ToString(), - portsStr, domainsStr); - } - } - return; - } - - if (args.ServiceInfo != null) - { - var result = await ApiRequest($"/services/{args.ServiceInfo}", "GET", null, publicKey, secretKey); - Console.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true })); - return; - } - - if (args.ServiceLogs != null) - { - var result = await ApiRequest($"/services/{args.ServiceLogs}/logs", "GET", null, publicKey, secretKey); - Console.WriteLine(result.GetValueOrDefault("logs", "")); - return; - } - - if (args.ServiceTail != null) - { - var result = await ApiRequest($"/services/{args.ServiceTail}/logs?lines=9000", "GET", null, publicKey, secretKey); - Console.WriteLine(result.GetValueOrDefault("logs", "")); - return; - } - - if (args.ServiceSleep != null) - { - await ApiRequest($"/services/{args.ServiceSleep}/freeze", "POST", null, publicKey, secretKey); - Console.WriteLine($"{GREEN}Service frozen: {args.ServiceSleep}{RESET}"); - return; - } - - if (args.ServiceWake != null) - { - await ApiRequest($"/services/{args.ServiceWake}/unfreeze", "POST", null, publicKey, secretKey); - Console.WriteLine($"{GREEN}Service unfreezing: {args.ServiceWake}{RESET}"); - return; - } - - if (args.ServiceDestroy != null) - { - await ApiRequest($"/services/{args.ServiceDestroy}", "DELETE", null, publicKey, secretKey); - Console.WriteLine($"{GREEN}Service destroyed: {args.ServiceDestroy}{RESET}"); - return; - } - - if (args.ServiceExecute != null) - { - var payload = new Dictionary - { - ["command"] = args.ServiceCommand! - }; - var result = await ApiRequest($"/services/{args.ServiceExecute}/execute", "POST", payload, publicKey, secretKey); - if (result.TryGetValue("stdout", out var stdout) && !string.IsNullOrEmpty(stdout.ToString())) - { - Console.Write($"{BLUE}{stdout}{RESET}"); - } - if (result.TryGetValue("stderr", out var stderr) && !string.IsNullOrEmpty(stderr.ToString())) - { - Console.Error.Write($"{RED}{stderr}{RESET}"); - } - return; - } - - if (args.ServiceDumpBootstrap != null) - { - Console.Error.WriteLine($"Fetching bootstrap script from {args.ServiceDumpBootstrap}..."); - var payload = new Dictionary - { - ["command"] = "cat /tmp/bootstrap.sh" - }; - var result = await ApiRequest($"/services/{args.ServiceDumpBootstrap}/execute", "POST", payload, publicKey, secretKey); - - var bootstrap = result.TryGetValue("stdout", out var stdout) ? stdout?.ToString() : null; - if (!string.IsNullOrEmpty(bootstrap)) - { - if (args.ServiceDumpFile != null) - { - try - { - await File.WriteAllTextAsync(args.ServiceDumpFile, bootstrap); - Console.WriteLine($"Bootstrap saved to {args.ServiceDumpFile}"); - } - catch (Exception e) - { - Console.Error.WriteLine($"{RED}Error: Could not write to {args.ServiceDumpFile}: {e.Message}{RESET}"); - Environment.Exit(1); - } - } - else - { - Console.Write(bootstrap); - } - } - else - { - Console.Error.WriteLine($"{RED}Error: Failed to fetch bootstrap (service not running or no bootstrap file){RESET}"); - Environment.Exit(1); - } - return; - } - - if (args.ServiceName != null) - { - var payload = new Dictionary - { - ["name"] = args.ServiceName - }; - if (args.ServicePorts != null) - { - var ports = args.ServicePorts.Split(',').Select(p => int.Parse(p.Trim())).ToList(); - payload["ports"] = ports; - } - if (args.ServiceType != null) - { - payload["service_type"] = args.ServiceType; - } - if (args.ServiceBootstrap != null) - { - payload["bootstrap"] = args.ServiceBootstrap; - } - if (args.Network != null) - { - payload["network"] = args.Network; - } - if (args.Vcpu > 0) - { - payload["vcpu"] = args.Vcpu; - } - - var result = await ApiRequest("/services", "POST", payload, publicKey, secretKey); - string? serviceId = result.TryGetValue("id", out var idObj) ? idObj?.ToString() : null; - Console.WriteLine($"{GREEN}Service created: {serviceId}{RESET}"); - Console.WriteLine($"Name: {result["name"]}"); - if (result.TryGetValue("url", out var url)) - { - Console.WriteLine($"URL: {url}"); - } - - // Auto-set vault if env vars were provided - if (!string.IsNullOrEmpty(serviceId) && (args.Env.Count > 0 || !string.IsNullOrEmpty(args.EnvFile))) - { - string envContent = await BuildEnvContent(args.Env, args.EnvFile); - if (!string.IsNullOrEmpty(envContent)) - { - if (await ServiceEnvSet(serviceId, envContent, publicKey, secretKey)) - { - Console.WriteLine($"{GREEN}Vault configured with environment variables{RESET}"); - } - else - { - Console.Error.WriteLine($"{YELLOW}Warning: Failed to set vault{RESET}"); - } - } - } - return; - } - - Console.Error.WriteLine($"{RED}Error: Specify --name to create a service, or use --list, --info, etc.{RESET}"); - Environment.Exit(1); - } - - static (string, string) GetApiKeys(string? argsKey) - { - string? publicKey = Environment.GetEnvironmentVariable("UNSANDBOX_PUBLIC_KEY"); - string? secretKey = Environment.GetEnvironmentVariable("UNSANDBOX_SECRET_KEY"); - - // Fall back to UNSANDBOX_API_KEY for backwards compatibility - if (string.IsNullOrEmpty(publicKey) || string.IsNullOrEmpty(secretKey)) - { - string? legacyKey = argsKey ?? Environment.GetEnvironmentVariable("UNSANDBOX_API_KEY"); - if (string.IsNullOrEmpty(legacyKey)) - { - Console.Error.WriteLine($"{RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set{RESET}"); - Environment.Exit(1); - } - return (legacyKey, string.Empty); - } - - return (publicKey, secretKey); - } - - static string DetectLanguage(string filename) - { - int dotIndex = filename.LastIndexOf('.'); - if (dotIndex == -1) - { - throw new Exception("Cannot detect language: no file extension"); - } - string ext = filename[dotIndex..].ToLower(); - if (!ExtMap.TryGetValue(ext, out var language)) - { - throw new Exception($"Unsupported file extension: {ext}"); - } - return language; - } - - static async Task> ApiRequest( - string endpoint, - string method, - Dictionary? data, - string publicKey, - string secretKey) - { - using var request = new HttpRequestMessage(new HttpMethod(method), API_BASE + endpoint); - request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - - string body = ""; - if (data != null) - { - body = JsonSerializer.Serialize(data); - request.Content = new StringContent(body, Encoding.UTF8, "application/json"); - } - - // Add HMAC authentication headers if secretKey is provided - if (!string.IsNullOrEmpty(secretKey)) - { - long timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - string message = $"{timestamp}:{method}:{endpoint}:{body}"; - - using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey)); - byte[] hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(message)); - string signature = Convert.ToHexString(hash).ToLower(); - - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", publicKey); - request.Headers.Add("X-Timestamp", timestamp.ToString()); - request.Headers.Add("X-Signature", signature); - } - else - { - // Legacy API key authentication - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", publicKey); - } - - try - { - using var response = await httpClient.SendAsync(request); - string responseText = await response.Content.ReadAsStringAsync(); - - if (!response.IsSuccessStatusCode) - { - // Check for clock drift errors - if (responseText.Contains("timestamp") && - ((int)response.StatusCode == 401 || responseText.ToLower().Contains("expired") || responseText.ToLower().Contains("invalid"))) - { - Console.Error.WriteLine($"{RED}Error: Request timestamp expired (must be within 5 minutes of server time){RESET}"); - Console.Error.WriteLine($"{YELLOW}Your computer's clock may have drifted.{RESET}"); - Console.Error.WriteLine("Check your system time and sync with NTP if needed:"); - Console.Error.WriteLine(" Linux: sudo ntpdate -s time.nist.gov"); - Console.Error.WriteLine(" macOS: sudo sntp -sS time.apple.com"); - Console.Error.WriteLine(" Windows: w32tm /resync"); - Environment.Exit(1); - } - - throw new Exception($"HTTP {(int)response.StatusCode} - {responseText}"); - } - - return JsonSerializer.Deserialize>(responseText) - ?? new Dictionary(); - } - catch (HttpRequestException ex) - { - throw new Exception($"HTTP error - {ex.Message}"); - } - } - - static async Task ApiRequestText( - string endpoint, - string method, - string? body, - string publicKey, - string secretKey) - { - using var request = new HttpRequestMessage(new HttpMethod(method), API_BASE + endpoint); - - body ??= ""; - if (!string.IsNullOrEmpty(body)) - { - request.Content = new StringContent(body, Encoding.UTF8, "text/plain"); - } - - // Add HMAC authentication headers - if (!string.IsNullOrEmpty(secretKey)) - { - long timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - string message = $"{timestamp}:{method}:{endpoint}:{body}"; - - using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey)); - byte[] hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(message)); - string signature = Convert.ToHexString(hash).ToLower(); - - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", publicKey); - request.Headers.Add("X-Timestamp", timestamp.ToString()); - request.Headers.Add("X-Signature", signature); - } - else - { - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", publicKey); - } - - try - { - using var response = await httpClient.SendAsync(request); - string responseText = await response.Content.ReadAsStringAsync(); - - if (!response.IsSuccessStatusCode) - { - throw new Exception($"HTTP {(int)response.StatusCode} - {responseText}"); - } - - return responseText; - } - catch (HttpRequestException ex) - { - throw new Exception($"HTTP error - {ex.Message}"); - } - } - - static async Task ReadEnvFile(string path) - { - if (!File.Exists(path)) - { - throw new Exception($"Env file not found: {path}"); - } - return await File.ReadAllTextAsync(path); - } - - static async Task BuildEnvContent(List envs, string? envFile) - { - var lines = new List(); - - // Add from -e flags - lines.AddRange(envs); - - // Add from --env-file - if (!string.IsNullOrEmpty(envFile)) - { - string content = await ReadEnvFile(envFile); - foreach (var line in content.Split('\n')) - { - string trimmed = line.Trim(); - if (!string.IsNullOrEmpty(trimmed) && !trimmed.StartsWith("#")) - { - lines.Add(trimmed); - } - } - } - - return string.Join("\n", lines); - } - - static async Task> ServiceEnvStatus(string serviceId, string publicKey, string secretKey) - { - return await ApiRequest($"/services/{serviceId}/env", "GET", null, publicKey, secretKey); - } - - static async Task ServiceEnvSet(string serviceId, string envContent, string publicKey, string secretKey) - { - const int MAX_ENV_CONTENT_SIZE = 65536; - if (envContent.Length > MAX_ENV_CONTENT_SIZE) - { - Console.Error.WriteLine($"{RED}Error: Env content exceeds maximum size of 64KB{RESET}"); - return false; - } - - try - { - await ApiRequestText($"/services/{serviceId}/env", "PUT", envContent, publicKey, secretKey); - return true; - } - catch - { - return false; - } - } - - static async Task> ServiceEnvExport(string serviceId, string publicKey, string secretKey) - { - return await ApiRequest($"/services/{serviceId}/env/export", "POST", null, publicKey, secretKey); - } - - static async Task ServiceEnvDelete(string serviceId, string publicKey, string secretKey) - { - try - { - await ApiRequest($"/services/{serviceId}/env", "DELETE", null, publicKey, secretKey); - return true; - } - catch - { - return false; - } - } - - static async Task CmdServiceEnv(Args args, string publicKey, string secretKey) - { - string action = args.EnvAction!; - string? target = args.EnvTarget; - - if (action == "status") - { - if (string.IsNullOrEmpty(target)) - { - Console.Error.WriteLine($"{RED}Error: service env status requires service ID{RESET}"); - Environment.Exit(1); - } - var result = await ServiceEnvStatus(target, publicKey, secretKey); - if (result.TryGetValue("has_vault", out var hasVaultObj) && (bool)hasVaultObj!) - { - Console.WriteLine($"{GREEN}Vault: configured{RESET}"); - if (result.TryGetValue("env_count", out var envCount)) - { - Console.WriteLine($"Variables: {envCount}"); - } - if (result.TryGetValue("updated_at", out var updatedAt)) - { - Console.WriteLine($"Updated: {updatedAt}"); - } - } - else - { - Console.WriteLine($"{YELLOW}Vault: not configured{RESET}"); - } - } - else if (action == "set") - { - if (string.IsNullOrEmpty(target)) - { - Console.Error.WriteLine($"{RED}Error: service env set requires service ID{RESET}"); - Environment.Exit(1); - } - if (args.Env.Count == 0 && string.IsNullOrEmpty(args.EnvFile)) - { - Console.Error.WriteLine($"{RED}Error: service env set requires -e or --env-file{RESET}"); - Environment.Exit(1); - } - string envContent = await BuildEnvContent(args.Env, args.EnvFile); - if (await ServiceEnvSet(target, envContent, publicKey, secretKey)) - { - Console.WriteLine($"{GREEN}Vault updated for service {target}{RESET}"); - } - else - { - Console.Error.WriteLine($"{RED}Error: Failed to update vault{RESET}"); - Environment.Exit(1); - } - } - else if (action == "export") - { - if (string.IsNullOrEmpty(target)) - { - Console.Error.WriteLine($"{RED}Error: service env export requires service ID{RESET}"); - Environment.Exit(1); - } - var result = await ServiceEnvExport(target, publicKey, secretKey); - if (result.TryGetValue("content", out var content)) - { - Console.Write(content); - } - } - else if (action == "delete") - { - if (string.IsNullOrEmpty(target)) - { - Console.Error.WriteLine($"{RED}Error: service env delete requires service ID{RESET}"); - Environment.Exit(1); - } - if (await ServiceEnvDelete(target, publicKey, secretKey)) - { - Console.WriteLine($"{GREEN}Vault deleted for service {target}{RESET}"); - } - else - { - Console.Error.WriteLine($"{RED}Error: Failed to delete vault{RESET}"); - Environment.Exit(1); - } - } - else - { - Console.Error.WriteLine($"{RED}Error: Unknown env action: {action}{RESET}"); - Console.Error.WriteLine("Usage: un service env "); - Environment.Exit(1); - } - } - - class Args - { - public string? Command; - public string? SourceFile; - public string? ApiKey; - public string? Network; - public int Vcpu; - public List Env = new(); - public List Files = new(); - public bool Artifacts; - public string? OutputDir; - public bool SessionList; - public string? SessionShell; - public string? SessionKill; - public bool ServiceList; - public string? ServiceName; - public string? ServicePorts; - public string? ServiceBootstrap; - public string? ServiceInfo; - public string? ServiceLogs; - public string? ServiceTail; - public string? ServiceSleep; - public string? ServiceWake; - public string? ServiceDestroy; - public string? ServiceType; - public string? ServiceExecute; - public string? ServiceCommand; - public string? ServiceDumpBootstrap; - public string? ServiceDumpFile; - public string? EnvFile; - public string? EnvAction; - public string? EnvTarget; - public bool KeyExtend; - } - - static Args ParseArgs(string[] args) - { - var result = new Args(); - for (int i = 0; i < args.Length; i++) - { - string arg = args[i]; - if (arg == "session") result.Command = "session"; - else if (arg == "service") result.Command = "service"; - else if (arg == "key") result.Command = "key"; - else if (arg == "env" && result.Command == "service") - { - // Parse: service env - if (i + 1 < args.Length && !args[i + 1].StartsWith("-")) - { - result.EnvAction = args[++i]; - if (i + 1 < args.Length && !args[i + 1].StartsWith("-")) - { - result.EnvTarget = args[++i]; - } - } - } - else if (arg == "-k" || arg == "--api-key") result.ApiKey = args[++i]; - else if (arg == "-n" || arg == "--network") result.Network = args[++i]; - else if (arg == "-v" || arg == "--vcpu") result.Vcpu = int.Parse(args[++i]); - else if (arg == "-e" || arg == "--env") result.Env.Add(args[++i]); - else if (arg == "--env-file") result.EnvFile = args[++i]; - else if (arg == "-f" || arg == "--files") result.Files.Add(args[++i]); - else if (arg == "-a" || arg == "--artifacts") result.Artifacts = true; - else if (arg == "-o" || arg == "--output-dir") result.OutputDir = args[++i]; - else if (arg == "-l" || arg == "--list") - { - if (result.Command == "session") result.SessionList = true; - else if (result.Command == "service") result.ServiceList = true; - } - else if (arg == "-s" || arg == "--shell") result.SessionShell = args[++i]; - else if (arg == "--kill") result.SessionKill = args[++i]; - else if (arg == "--name") result.ServiceName = args[++i]; - else if (arg == "--ports") result.ServicePorts = args[++i]; - else if (arg == "--type") result.ServiceType = args[++i]; - else if (arg == "--bootstrap") result.ServiceBootstrap = args[++i]; - else if (arg == "--info") result.ServiceInfo = args[++i]; - else if (arg == "--logs") result.ServiceLogs = args[++i]; - else if (arg == "--tail") result.ServiceTail = args[++i]; - else if (arg == "--freeze") result.ServiceSleep = args[++i]; - else if (arg == "--unfreeze") result.ServiceWake = args[++i]; - else if (arg == "--destroy") result.ServiceDestroy = args[++i]; - else if (arg == "--execute") result.ServiceExecute = args[++i]; - else if (arg == "--command") result.ServiceCommand = args[++i]; - else if (arg == "--dump-bootstrap") result.ServiceDumpBootstrap = args[++i]; - else if (arg == "--dump-file") result.ServiceDumpFile = args[++i]; - else if (arg == "--extend") result.KeyExtend = true; - else if (!arg.StartsWith("-")) result.SourceFile = arg; - } - return result; - } - - static void PrintHelp() - { - Console.WriteLine(@"Usage: un [options] - un session [options] - un service [options] - un service env [options] - un key [options] - -Execute options: - -e KEY=VALUE Set environment variable - -f FILE Add input file - -a Return artifacts - -o DIR Output directory for artifacts - -n MODE Network mode (zerotrust/semitrusted) - -v N vCPU count (1-8) - -k KEY API key - -Session options: - --list List active sessions - --shell NAME Shell/REPL to use - --kill ID Terminate session - -Service options: - --list List services - --name NAME Service name - --ports PORTS Comma-separated ports - --type TYPE Service type (minecraft/mumble/teamspeak/source/tcp/udp) - --bootstrap CMD Bootstrap command - --info ID Get service details - --logs ID Get all logs - --tail ID Get last 9000 lines - --freeze ID Freeze service - --unfreeze ID Unfreeze service - --destroy ID Destroy service - --execute ID Execute command in service - --command CMD Command to execute (with --execute) - --dump-bootstrap ID Dump bootstrap script - --dump-file FILE File to save bootstrap (with --dump-bootstrap) - -e KEY=VALUE Set vault env var (with --name or env set) - --env-file FILE Load vault vars from file - -Service env commands: - env status ID Check vault status - env set ID Set vault (use -e or --env-file) - env export ID Export vault contents - env delete ID Delete vault - -Key options: - --extend Open browser to extend expired key"); - } -} diff --git a/clients/dotnet/async/src/Un.cs b/clients/dotnet/async/src/Un.cs new file mode 100644 index 0000000..27b6611 --- /dev/null +++ b/clients/dotnet/async/src/Un.cs @@ -0,0 +1,1111 @@ +// PUBLIC DOMAIN - NO LICENSE, NO WARRANTY +// +// Un.cs - Unsandbox CLI Client (.NET 10 Implementation) +// Build: dotnet build +// Run: dotnet run -- [options] +// Requires: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY environment variables + +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +const string API_BASE = "https://api.unsandbox.com"; +const string PORTAL_BASE = "https://unsandbox.com"; +const string VERSION = "4.2.6"; + +// ANSI colors +const string BLUE = "\x1B[34m"; +const string RED = "\x1B[31m"; +const string GREEN = "\x1B[32m"; +const string YELLOW = "\x1B[33m"; +const string RESET = "\x1B[0m"; + +var extMap = new Dictionary(StringComparer.OrdinalIgnoreCase) +{ + [".py"] = "python", [".js"] = "javascript", [".ts"] = "typescript", + [".rb"] = "ruby", [".php"] = "php", [".pl"] = "perl", [".lua"] = "lua", + [".sh"] = "bash", [".go"] = "go", [".rs"] = "rust", [".c"] = "c", + [".cpp"] = "cpp", [".cc"] = "cpp", [".cxx"] = "cpp", + [".java"] = "java", [".kt"] = "kotlin", [".cs"] = "dotnet", [".fs"] = "fsharp", + [".hs"] = "haskell", [".ml"] = "ocaml", [".clj"] = "clojure", [".scm"] = "scheme", + [".lisp"] = "commonlisp", [".erl"] = "erlang", [".ex"] = "elixir", [".exs"] = "elixir", + [".jl"] = "julia", [".r"] = "r", [".R"] = "r", [".cr"] = "crystal", + [".d"] = "d", [".nim"] = "nim", [".zig"] = "zig", [".v"] = "v", + [".dart"] = "dart", [".groovy"] = "groovy", [".scala"] = "scala", + [".f90"] = "fortran", [".f95"] = "fortran", [".cob"] = "cobol", + [".pro"] = "prolog", [".forth"] = "forth", [".4th"] = "forth", + [".tcl"] = "tcl", [".raku"] = "raku", [".m"] = "objc" +}; + +var jsonOptions = new JsonSerializerOptions +{ + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull +}; + +using var httpClient = new HttpClient { BaseAddress = new Uri(API_BASE), Timeout = TimeSpan.FromMinutes(5) }; + +try +{ + var parsedArgs = ParseArgs(args); + + if (parsedArgs.ShowHelp) + { + PrintHelp(); + return 0; + } + + if (parsedArgs.ShowVersion) + { + Console.WriteLine($"un {VERSION} (.NET 10)"); + return 0; + } + + await (parsedArgs.Command switch + { + "session" => CmdSessionAsync(parsedArgs), + "service" => CmdServiceAsync(parsedArgs), + "snapshot" => CmdSnapshotAsync(parsedArgs), + "image" => CmdImageAsync(parsedArgs), + "languages" => CmdLanguagesAsync(parsedArgs), + "key" => CmdKeyAsync(parsedArgs), + _ when parsedArgs.SourceFile != null => CmdExecuteAsync(parsedArgs), + _ => Task.Run(() => { PrintHelp(); Environment.Exit(1); }) + }); + + return 0; +} +catch (Exception ex) +{ + Console.Error.WriteLine($"{RED}Error: {ex.Message}{RESET}"); + return 1; +} + +async Task CmdExecuteAsync(Args args) +{ + var (publicKey, secretKey) = GetApiKeys(args.ApiKey); + var code = await File.ReadAllTextAsync(args.SourceFile!); + var language = DetectLanguage(args.SourceFile!); + + var payload = new Dictionary { ["language"] = language, ["code"] = code }; + + if (args.Env.Count > 0) + { + var envVars = args.Env + .Select(e => e.Split('=', 2)) + .Where(p => p.Length == 2) + .ToDictionary(p => p[0], p => p[1]); + if (envVars.Count > 0) payload["env"] = envVars; + } + + if (args.Files.Count > 0) + { + var inputFiles = new List>(); + foreach (var filepath in args.Files) + { + var content = await File.ReadAllBytesAsync(filepath); + inputFiles.Add(new Dictionary + { + ["filename"] = Path.GetFileName(filepath), + ["content_base64"] = Convert.ToBase64String(content) + }); + } + payload["input_files"] = inputFiles; + } + + if (args.Artifacts) payload["return_artifacts"] = true; + if (args.Network != null) payload["network"] = args.Network; + if (args.Vcpu > 0) payload["vcpu"] = args.Vcpu; + + var result = await ApiRequestAsync("/execute", HttpMethod.Post, payload, publicKey, secretKey); + + if (result.TryGetValue("stdout", out var stdout) && stdout is JsonElement stdoutEl) + Console.Write($"{BLUE}{stdoutEl.GetString()}{RESET}"); + if (result.TryGetValue("stderr", out var stderr) && stderr is JsonElement stderrEl) + Console.Error.Write($"{RED}{stderrEl.GetString()}{RESET}"); + + if (args.Artifacts && result.TryGetValue("artifacts", out var artifacts) && artifacts is JsonElement artifactsEl) + { + var outDir = args.OutputDir ?? "."; + Directory.CreateDirectory(outDir); + foreach (var artifact in artifactsEl.EnumerateArray()) + { + var filename = artifact.GetProperty("filename").GetString() ?? "artifact"; + var contentB64 = artifact.GetProperty("content_base64").GetString() ?? ""; + var path = Path.Combine(outDir, filename); + await File.WriteAllBytesAsync(path, Convert.FromBase64String(contentB64)); + Console.Error.WriteLine($"{GREEN}Saved: {path}{RESET}"); + } + } + + var exitCode = result.TryGetValue("exit_code", out var ec) && ec is JsonElement ecEl ? ecEl.GetInt32() : 0; + Environment.Exit(exitCode); +} + +async Task CmdSessionAsync(Args args) +{ + var (publicKey, secretKey) = GetApiKeys(args.ApiKey); + + if (args.SessionList) + { + var result = await ApiRequestAsync("/sessions", HttpMethod.Get, null, publicKey, secretKey); + if (result.TryGetValue("sessions", out var sessions) && sessions is JsonElement sessionsEl) + { + var sessionList = sessionsEl.EnumerateArray().ToList(); + if (sessionList.Count == 0) { Console.WriteLine("No active sessions"); return; } + Console.WriteLine($"{"ID",-40} {"Shell",-10} {"Status",-10} {"Created"}"); + foreach (var s in sessionList) + { + Console.WriteLine($"{GetStr(s, "id"),-40} {GetStr(s, "shell"),-10} {GetStr(s, "status"),-10} {GetStr(s, "created_at")}"); + } + } + return; + } + + if (args.SessionKill != null) + { + await ApiRequestAsync($"/sessions/{args.SessionKill}", HttpMethod.Delete, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Session terminated: {args.SessionKill}{RESET}"); + return; + } + + if (args.SessionFreeze != null) + { + await ApiRequestAsync($"/sessions/{args.SessionFreeze}/freeze", HttpMethod.Post, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Session frozen: {args.SessionFreeze}{RESET}"); + return; + } + + if (args.SessionUnfreeze != null) + { + await ApiRequestAsync($"/sessions/{args.SessionUnfreeze}/unfreeze", HttpMethod.Post, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Session unfreezing: {args.SessionUnfreeze}{RESET}"); + return; + } + + if (args.SessionBoost != null) + { + await ApiRequestAsync($"/sessions/{args.SessionBoost}/boost", HttpMethod.Post, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Session boosted: {args.SessionBoost}{RESET}"); + return; + } + + if (args.SessionUnboost != null) + { + await ApiRequestAsync($"/sessions/{args.SessionUnboost}/unboost", HttpMethod.Post, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Session unboosted: {args.SessionUnboost}{RESET}"); + return; + } + + if (args.SessionSnapshot != null) + { + var payload = new Dictionary(); + if (args.SnapshotName != null) payload["name"] = args.SnapshotName; + if (args.SnapshotHot) payload["hot"] = true; + + var result = await ApiRequestAsync($"/sessions/{args.SessionSnapshot}/snapshot", HttpMethod.Post, payload, publicKey, secretKey); + var id = result.TryGetValue("id", out var idObj) && idObj is JsonElement idEl ? idEl.GetString() : "unknown"; + Console.WriteLine($"{GREEN}Snapshot created: {id}{RESET}"); + return; + } + + var payload = new Dictionary { ["shell"] = args.SessionShell ?? "bash" }; + if (args.Network != null) payload["network"] = args.Network; + if (args.Vcpu > 0) payload["vcpu"] = args.Vcpu; + + Console.WriteLine($"{YELLOW}Creating session...{RESET}"); + var createResult = await ApiRequestAsync("/sessions", HttpMethod.Post, payload, publicKey, secretKey); + var sessionId = createResult.TryGetValue("id", out var id) && id is JsonElement idEl ? idEl.GetString() : "unknown"; + Console.WriteLine($"{GREEN}Session created: {sessionId}{RESET}"); + Console.WriteLine($"{YELLOW}(Interactive sessions require WebSocket - use un2 for full support){RESET}"); +} + +async Task CmdKeyAsync(Args args) +{ + var (publicKey, secretKey) = GetApiKeys(args.ApiKey); + var result = await ApiRequestAsync("/keys/validate", HttpMethod.Post, null, publicKey, secretKey); + + if (!result.TryGetValue("valid", out var validObj) || validObj is not JsonElement validEl) + { + Console.Error.WriteLine($"{RED}Error: Invalid response from server{RESET}"); + Environment.Exit(1); + } + + var isValid = validEl.GetBoolean(); + var isExpired = result.TryGetValue("expired", out var expObj) && expObj is JsonElement expEl && expEl.GetBoolean(); + + if (isValid && !isExpired) + { + Console.WriteLine($"{GREEN}Valid{RESET}"); + if (result.TryGetValue("public_key", out var pk) && pk is JsonElement pkEl) Console.WriteLine($"Public Key: {pkEl.GetString()}"); + if (result.TryGetValue("tier", out var tier) && tier is JsonElement tierEl) Console.WriteLine($"Tier: {tierEl.GetString()}"); + if (result.TryGetValue("expires_at", out var exp) && exp is JsonElement expAtEl) Console.WriteLine($"Expires: {expAtEl.GetString()}"); + } + else if (isExpired) + { + Console.WriteLine($"{RED}Expired{RESET}"); + string? pkStr = null; + if (result.TryGetValue("public_key", out var pk) && pk is JsonElement pkEl) { pkStr = pkEl.GetString(); Console.WriteLine($"Public Key: {pkStr}"); } + if (result.TryGetValue("tier", out var tier) && tier is JsonElement tierEl) Console.WriteLine($"Tier: {tierEl.GetString()}"); + if (result.TryGetValue("expired_at", out var expAt) && expAt is JsonElement expAtEl) Console.WriteLine($"Expired: {expAtEl.GetString()}"); + Console.WriteLine($"{YELLOW}To renew: Visit {PORTAL_BASE}/keys/extend{RESET}"); + + if (args.KeyExtend && !string.IsNullOrEmpty(pkStr)) + { + var url = $"{PORTAL_BASE}/keys/extend?pk={pkStr}"; + Console.WriteLine($"{YELLOW}Opening: {url}{RESET}"); + OpenBrowser(url); + } + } + else + { + Console.WriteLine($"{RED}Invalid{RESET}"); + } +} + +void OpenBrowser(string url) +{ + try + { + if (OperatingSystem.IsWindows()) + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(url) { UseShellExecute = true }); + else if (OperatingSystem.IsLinux()) + System.Diagnostics.Process.Start("xdg-open", url); + else if (OperatingSystem.IsMacOS()) + System.Diagnostics.Process.Start("open", url); + } + catch (Exception ex) { Console.Error.WriteLine($"{RED}Failed to open browser: {ex.Message}{RESET}"); } +} + +async Task CmdServiceAsync(Args args) +{ + var (publicKey, secretKey) = GetApiKeys(args.ApiKey); + + if (!string.IsNullOrEmpty(args.EnvAction)) + { + await CmdServiceEnvAsync(args, publicKey, secretKey); + return; + } + + if (args.ServiceList) + { + var result = await ApiRequestAsync("/services", HttpMethod.Get, null, publicKey, secretKey); + if (result.TryGetValue("services", out var services) && services is JsonElement servicesEl) + { + var serviceList = servicesEl.EnumerateArray().ToList(); + if (serviceList.Count == 0) { Console.WriteLine("No services"); return; } + Console.WriteLine($"{"ID",-20} {"Name",-15} {"Status",-10} {"Ports",-15} {"Domains"}"); + foreach (var s in serviceList) + { + var ports = s.TryGetProperty("ports", out var p) ? string.Join(",", p.EnumerateArray().Select(x => x.GetInt32())) : ""; + var domains = s.TryGetProperty("domains", out var d) ? string.Join(",", d.EnumerateArray().Select(x => x.GetString())) : ""; + Console.WriteLine($"{GetStr(s, "id"),-20} {GetStr(s, "name"),-15} {GetStr(s, "status"),-10} {ports,-15} {domains}"); + } + } + return; + } + + if (args.ServiceInfo != null) + { + var result = await ApiRequestAsync($"/services/{args.ServiceInfo}", HttpMethod.Get, null, publicKey, secretKey); + Console.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true })); + return; + } + + if (args.ServiceLogs != null) + { + var result = await ApiRequestAsync($"/services/{args.ServiceLogs}/logs", HttpMethod.Get, null, publicKey, secretKey); + if (result.TryGetValue("logs", out var logs) && logs is JsonElement logsEl) Console.WriteLine(logsEl.GetString()); + return; + } + + if (args.ServiceTail != null) + { + var result = await ApiRequestAsync($"/services/{args.ServiceTail}/logs?lines=9000", HttpMethod.Get, null, publicKey, secretKey); + if (result.TryGetValue("logs", out var logs) && logs is JsonElement logsEl) Console.WriteLine(logsEl.GetString()); + return; + } + + if (args.ServiceSleep != null) + { + await ApiRequestAsync($"/services/{args.ServiceSleep}/freeze", HttpMethod.Post, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Service frozen: {args.ServiceSleep}{RESET}"); + return; + } + + if (args.ServiceWake != null) + { + await ApiRequestAsync($"/services/{args.ServiceWake}/unfreeze", HttpMethod.Post, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Service unfreezing: {args.ServiceWake}{RESET}"); + return; + } + + if (args.ServiceUnfreezeOnDemand != null) + { + var payload = new Dictionary { ["unfreeze_on_demand"] = args.ServiceUnfreezeOnDemandEnabled }; + await ApiRequestAsync($"/services/{args.ServiceUnfreezeOnDemand}", new HttpMethod("PATCH"), payload, publicKey, secretKey); + string status = args.ServiceUnfreezeOnDemandEnabled ? "enabled" : "disabled"; + Console.WriteLine($"{GREEN}Unfreeze-on-demand {status} for service: {args.ServiceUnfreezeOnDemand}{RESET}"); + return; + } + + if (args.ServiceDestroy != null) + { + await ApiRequestAsync($"/services/{args.ServiceDestroy}", HttpMethod.Delete, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Service destroyed: {args.ServiceDestroy}{RESET}"); + return; + } + + if (args.ServiceLock != null) + { + await ApiRequestAsync($"/services/{args.ServiceLock}/lock", HttpMethod.Post, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Service locked: {args.ServiceLock}{RESET}"); + return; + } + + if (args.ServiceUnlock != null) + { + await ApiRequestAsync($"/services/{args.ServiceUnlock}/unlock", HttpMethod.Post, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Service unlocked: {args.ServiceUnlock}{RESET}"); + return; + } + + if (args.ServiceResize != null) + { + var payload = new Dictionary(); + if (args.Vcpu > 0) payload["vcpu"] = args.Vcpu; + + await ApiRequestAsync($"/services/{args.ServiceResize}/resize", HttpMethod.Post, payload, publicKey, secretKey); + Console.WriteLine($"{GREEN}Service resized: {args.ServiceResize}{RESET}"); + return; + } + + if (args.ServiceRedeploy != null) + { + await ApiRequestAsync($"/services/{args.ServiceRedeploy}/redeploy", HttpMethod.Post, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Service redeploying: {args.ServiceRedeploy}{RESET}"); + return; + } + + if (args.ServiceSnapshot != null) + { + var payload = new Dictionary(); + if (args.SnapshotName != null) payload["name"] = args.SnapshotName; + if (args.SnapshotHot) payload["hot"] = true; + + var result = await ApiRequestAsync($"/services/{args.ServiceSnapshot}/snapshot", HttpMethod.Post, payload, publicKey, secretKey); + var id = result.TryGetValue("id", out var idObj) && idObj is JsonElement idEl ? idEl.GetString() : "unknown"; + Console.WriteLine($"{GREEN}Snapshot created: {id}{RESET}"); + return; + } + + if (args.ServiceExecute != null) + { + var payload = new Dictionary { ["command"] = args.ServiceCommand ?? "" }; + var result = await ApiRequestAsync($"/services/{args.ServiceExecute}/execute", HttpMethod.Post, payload, publicKey, secretKey); + if (result.TryGetValue("stdout", out var stdout) && stdout is JsonElement stdoutEl) Console.Write($"{BLUE}{stdoutEl.GetString()}{RESET}"); + if (result.TryGetValue("stderr", out var stderr) && stderr is JsonElement stderrEl) Console.Error.Write($"{RED}{stderrEl.GetString()}{RESET}"); + return; + } + + if (args.ServiceDumpBootstrap != null) + { + Console.Error.WriteLine($"Fetching bootstrap script from {args.ServiceDumpBootstrap}..."); + var payload = new Dictionary { ["command"] = "cat /tmp/bootstrap.sh" }; + var result = await ApiRequestAsync($"/services/{args.ServiceDumpBootstrap}/execute", HttpMethod.Post, payload, publicKey, secretKey); + var bootstrap = result.TryGetValue("stdout", out var bs) && bs is JsonElement bsEl ? bsEl.GetString() : null; + if (!string.IsNullOrEmpty(bootstrap)) + { + if (args.ServiceDumpFile != null) + { + await File.WriteAllTextAsync(args.ServiceDumpFile, bootstrap); + Console.WriteLine($"Bootstrap saved to {args.ServiceDumpFile}"); + } + else Console.Write(bootstrap); + } + else + { + Console.Error.WriteLine($"{RED}Error: Failed to fetch bootstrap (service not running or no bootstrap file){RESET}"); + Environment.Exit(1); + } + return; + } + + if (args.ServiceName != null) + { + var payload = new Dictionary { ["name"] = args.ServiceName }; + if (args.ServicePorts != null) + payload["ports"] = args.ServicePorts.Split(',').Select(p => int.Parse(p.Trim())).ToList(); + if (args.ServiceType != null) payload["service_type"] = args.ServiceType; + if (args.ServiceBootstrap != null) payload["bootstrap"] = args.ServiceBootstrap; + else if (args.ServiceBootstrapFile != null) payload["bootstrap"] = File.ReadAllText(args.ServiceBootstrapFile); + if (args.Network != null) payload["network"] = args.Network; + if (args.Vcpu > 0) payload["vcpu"] = args.Vcpu; + if (args.ServiceCreateUnfreezeOnDemand) payload["unfreeze_on_demand"] = true; + + var result = await ApiRequestAsync("/services", HttpMethod.Post, payload, publicKey, secretKey); + var serviceId = result.TryGetValue("id", out var id) && id is JsonElement idEl ? idEl.GetString() : null; + Console.WriteLine($"{GREEN}Service created: {serviceId}{RESET}"); + if (result.TryGetValue("name", out var name) && name is JsonElement nameEl) Console.WriteLine($"Name: {nameEl.GetString()}"); + if (result.TryGetValue("url", out var url) && url is JsonElement urlEl) Console.WriteLine($"URL: {urlEl.GetString()}"); + + if (!string.IsNullOrEmpty(serviceId) && (args.Env.Count > 0 || !string.IsNullOrEmpty(args.EnvFile))) + { + var envContent = BuildEnvContent(args.Env, args.EnvFile); + if (!string.IsNullOrEmpty(envContent)) + { + if (await ServiceEnvSetAsync(serviceId, envContent, publicKey, secretKey)) + Console.WriteLine($"{GREEN}Vault configured with environment variables{RESET}"); + else + Console.Error.WriteLine($"{YELLOW}Warning: Failed to set vault{RESET}"); + } + } + return; + } + + Console.Error.WriteLine($"{RED}Error: Specify --name to create a service, or use --list, --info, etc.{RESET}"); + Environment.Exit(1); +} + +async Task CmdServiceEnvAsync(Args args, string publicKey, string secretKey) +{ + var action = args.EnvAction; + var target = args.EnvTarget; + + if (action == "status") + { + if (string.IsNullOrEmpty(target)) { Console.Error.WriteLine($"{RED}Error: service env status requires service ID{RESET}"); Environment.Exit(1); } + var result = await ApiRequestAsync($"/services/{target}/env", HttpMethod.Get, null, publicKey, secretKey); + if (result.TryGetValue("has_vault", out var hv) && hv is JsonElement hvEl && hvEl.GetBoolean()) + { + Console.WriteLine($"{GREEN}Vault: configured{RESET}"); + if (result.TryGetValue("env_count", out var ec) && ec is JsonElement ecEl) Console.WriteLine($"Variables: {ecEl.GetInt32()}"); + if (result.TryGetValue("updated_at", out var ua) && ua is JsonElement uaEl) Console.WriteLine($"Updated: {uaEl.GetString()}"); + } + else Console.WriteLine($"{YELLOW}Vault: not configured{RESET}"); + } + else if (action == "set") + { + if (string.IsNullOrEmpty(target)) { Console.Error.WriteLine($"{RED}Error: service env set requires service ID{RESET}"); Environment.Exit(1); } + if (args.Env.Count == 0 && string.IsNullOrEmpty(args.EnvFile)) { Console.Error.WriteLine($"{RED}Error: service env set requires -e or --env-file{RESET}"); Environment.Exit(1); } + var envContent = BuildEnvContent(args.Env, args.EnvFile); + if (await ServiceEnvSetAsync(target, envContent, publicKey, secretKey)) + Console.WriteLine($"{GREEN}Vault updated for service {target}{RESET}"); + else { Console.Error.WriteLine($"{RED}Error: Failed to update vault{RESET}"); Environment.Exit(1); } + } + else if (action == "export") + { + if (string.IsNullOrEmpty(target)) { Console.Error.WriteLine($"{RED}Error: service env export requires service ID{RESET}"); Environment.Exit(1); } + var result = await ApiRequestAsync($"/services/{target}/env/export", HttpMethod.Post, null, publicKey, secretKey); + if (result.TryGetValue("content", out var content) && content is JsonElement contentEl) Console.Write(contentEl.GetString()); + } + else if (action == "delete") + { + if (string.IsNullOrEmpty(target)) { Console.Error.WriteLine($"{RED}Error: service env delete requires service ID{RESET}"); Environment.Exit(1); } + await ApiRequestAsync($"/services/{target}/env", HttpMethod.Delete, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Vault deleted for service {target}{RESET}"); + } +} + +async Task CmdSnapshotAsync(Args args) +{ + var (publicKey, secretKey) = GetApiKeys(args.ApiKey); + + if (args.SnapshotList) + { + var result = await ApiRequestAsync("/snapshots", HttpMethod.Get, null, publicKey, secretKey); + if (result.TryGetValue("snapshots", out var snapshots) && snapshots is JsonElement snapshotsEl) + { + var snapshotList = snapshotsEl.EnumerateArray().ToList(); + if (snapshotList.Count == 0) { Console.WriteLine("No snapshots"); return; } + Console.WriteLine($"{"ID",-40} {"Name",-20} {"Type",-10} {"Status",-10} {"Created"}"); + foreach (var s in snapshotList) + { + Console.WriteLine($"{GetStr(s, "id"),-40} {GetStr(s, "name"),-20} {GetStr(s, "source_type"),-10} {GetStr(s, "status"),-10} {GetStr(s, "created_at")}"); + } + } + return; + } + + if (args.SnapshotInfo != null) + { + var result = await ApiRequestAsync($"/snapshots/{args.SnapshotInfo}", HttpMethod.Get, null, publicKey, secretKey); + Console.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true })); + return; + } + + if (args.SnapshotDelete != null) + { + await ApiRequestAsync($"/snapshots/{args.SnapshotDelete}", HttpMethod.Delete, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Snapshot deleted: {args.SnapshotDelete}{RESET}"); + return; + } + + if (args.SnapshotLock != null) + { + await ApiRequestAsync($"/snapshots/{args.SnapshotLock}/lock", HttpMethod.Post, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Snapshot locked: {args.SnapshotLock}{RESET}"); + return; + } + + if (args.SnapshotUnlock != null) + { + await ApiRequestAsync($"/snapshots/{args.SnapshotUnlock}/unlock", HttpMethod.Post, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Snapshot unlocked: {args.SnapshotUnlock}{RESET}"); + return; + } + + if (args.SnapshotClone != null) + { + var payload = new Dictionary { ["type"] = args.SnapshotCloneType ?? "session" }; + if (args.ServiceName != null) payload["name"] = args.ServiceName; + if (args.SessionShell != null) payload["shell"] = args.SessionShell; + if (args.ServicePorts != null) payload["ports"] = args.ServicePorts.Split(',').Select(p => int.Parse(p.Trim())).ToList(); + if (args.Network != null) payload["network"] = args.Network; + + var result = await ApiRequestAsync($"/snapshots/{args.SnapshotClone}/clone", HttpMethod.Post, payload, publicKey, secretKey); + var id = result.TryGetValue("id", out var idObj) && idObj is JsonElement idEl ? idEl.GetString() : "unknown"; + Console.WriteLine($"{GREEN}Cloned to {args.SnapshotCloneType ?? "session"}: {id}{RESET}"); + return; + } + + Console.Error.WriteLine($"{RED}Error: Use --list, --info, --delete, --lock, --unlock, or --clone{RESET}"); + Environment.Exit(1); +} + +async Task CmdImageAsync(Args args) +{ + var (publicKey, secretKey) = GetApiKeys(args.ApiKey); + + if (args.ImageList) + { + var result = await ApiRequestAsync("/images", HttpMethod.Get, null, publicKey, secretKey); + if (result.TryGetValue("images", out var images) && images is JsonElement imagesEl) + { + var imageList = imagesEl.EnumerateArray().ToList(); + if (imageList.Count == 0) { Console.WriteLine("No images"); return; } + Console.WriteLine($"{"ID",-40} {"Name",-20} {"Visibility",-12} {"Status",-10} {"Created"}"); + foreach (var img in imageList) + { + Console.WriteLine($"{GetStr(img, "id"),-40} {GetStr(img, "name"),-20} {GetStr(img, "visibility"),-12} {GetStr(img, "status"),-10} {GetStr(img, "created_at")}"); + } + } + return; + } + + if (args.ImageInfo != null) + { + var result = await ApiRequestAsync($"/images/{args.ImageInfo}", HttpMethod.Get, null, publicKey, secretKey); + Console.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true })); + return; + } + + if (args.ImageDelete != null) + { + await ApiRequestAsync($"/images/{args.ImageDelete}", HttpMethod.Delete, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Image deleted: {args.ImageDelete}{RESET}"); + return; + } + + if (args.ImageLock != null) + { + await ApiRequestAsync($"/images/{args.ImageLock}/lock", HttpMethod.Post, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Image locked: {args.ImageLock}{RESET}"); + return; + } + + if (args.ImageUnlock != null) + { + await ApiRequestAsync($"/images/{args.ImageUnlock}/unlock", HttpMethod.Post, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Image unlocked: {args.ImageUnlock}{RESET}"); + return; + } + + if (args.ImagePublish != null) + { + var payload = new Dictionary { ["source_id"] = args.ImagePublish }; + if (args.ImageSourceType != null) payload["source_type"] = args.ImageSourceType; + if (args.ServiceName != null) payload["name"] = args.ServiceName; + + var result = await ApiRequestAsync("/images", HttpMethod.Post, payload, publicKey, secretKey); + var id = result.TryGetValue("id", out var idObj) && idObj is JsonElement idEl ? idEl.GetString() : "unknown"; + Console.WriteLine($"{GREEN}Image published: {id}{RESET}"); + return; + } + + if (args.ImageVisibility != null) + { + var payload = new Dictionary { ["visibility"] = args.ImageVisibilityMode ?? "private" }; + await ApiRequestAsync($"/images/{args.ImageVisibility}", new HttpMethod("PATCH"), payload, publicKey, secretKey); + Console.WriteLine($"{GREEN}Image visibility set to {args.ImageVisibilityMode}: {args.ImageVisibility}{RESET}"); + return; + } + + if (args.ImageSpawn != null) + { + var payload = new Dictionary(); + if (args.ServiceName != null) payload["name"] = args.ServiceName; + if (args.ServicePorts != null) payload["ports"] = args.ServicePorts.Split(',').Select(p => int.Parse(p.Trim())).ToList(); + if (args.Network != null) payload["network"] = args.Network; + + var result = await ApiRequestAsync($"/images/{args.ImageSpawn}/spawn", HttpMethod.Post, payload, publicKey, secretKey); + var id = result.TryGetValue("id", out var idObj) && idObj is JsonElement idEl ? idEl.GetString() : "unknown"; + Console.WriteLine($"{GREEN}Service spawned: {id}{RESET}"); + if (result.TryGetValue("url", out var url) && url is JsonElement urlEl) Console.WriteLine($"URL: {urlEl.GetString()}"); + return; + } + + if (args.ImageClone != null) + { + var payload = new Dictionary(); + if (args.ServiceName != null) payload["name"] = args.ServiceName; + + var result = await ApiRequestAsync($"/images/{args.ImageClone}/clone", HttpMethod.Post, payload, publicKey, secretKey); + var id = result.TryGetValue("id", out var idObj) && idObj is JsonElement idEl ? idEl.GetString() : "unknown"; + Console.WriteLine($"{GREEN}Image cloned: {id}{RESET}"); + return; + } + + Console.Error.WriteLine($"{RED}Error: Use --list, --info, --delete, --lock, --unlock, --publish, --visibility, --spawn, or --clone{RESET}"); + Environment.Exit(1); +} + +async Task CmdLanguagesAsync(Args args) +{ + var (publicKey, secretKey) = GetApiKeys(args.ApiKey); + + // Check cache first + var cacheDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unsandbox"); + var cachePath = Path.Combine(cacheDir, "languages.json"); + var cacheMaxAge = TimeSpan.FromHours(1); + + List? languages = null; + + if (File.Exists(cachePath)) + { + var cacheAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(cachePath); + if (cacheAge < cacheMaxAge) + { + try + { + var cacheContent = await File.ReadAllTextAsync(cachePath); + languages = JsonSerializer.Deserialize>(cacheContent); + } + catch { /* Cache corrupted, fetch fresh */ } + } + } + + if (languages == null) + { + var result = await ApiRequestAsync("/languages", HttpMethod.Get, null, publicKey, secretKey); + if (result.TryGetValue("languages", out var langsObj) && langsObj is JsonElement langsEl) + { + languages = langsEl.EnumerateArray().Select(l => l.GetString() ?? "").Where(l => !string.IsNullOrEmpty(l)).ToList(); + + // Save to cache + try + { + Directory.CreateDirectory(cacheDir); + await File.WriteAllTextAsync(cachePath, JsonSerializer.Serialize(languages)); + } + catch { /* Cache write failed, continue anyway */ } + } + else + { + Console.Error.WriteLine($"{RED}Error: Failed to fetch languages{RESET}"); + Environment.Exit(1); + return; + } + } + + if (args.LanguagesJson) + { + Console.WriteLine(JsonSerializer.Serialize(languages)); + } + else + { + foreach (var lang in languages) + { + Console.WriteLine(lang); + } + } +} + +async Task> ApiRequestAsync(string endpoint, HttpMethod method, Dictionary? data, string publicKey, string secretKey) +{ + var body = data != null ? JsonSerializer.Serialize(data, jsonOptions) : ""; + + using var request = new HttpRequestMessage(method, endpoint); + if (data != null) request.Content = new StringContent(body, Encoding.UTF8, "application/json"); + + // HMAC Authentication + if (!string.IsNullOrEmpty(secretKey)) + { + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var message = $"{timestamp}:{method.Method}:{endpoint}:{body}"; + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey)); + var signature = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(message))).ToLowerInvariant(); + request.Headers.Add("Authorization", $"Bearer {publicKey}"); + request.Headers.Add("X-Timestamp", timestamp.ToString()); + request.Headers.Add("X-Signature", signature); + } + else + { + request.Headers.Add("Authorization", $"Bearer {publicKey}"); + } + + var response = await httpClient.SendAsync(request); + var responseBody = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + if (responseBody.Contains("timestamp") && ((int)response.StatusCode == 401 || responseBody.ToLower().Contains("expired"))) + { + Console.Error.WriteLine($"{RED}Error: Request timestamp expired (must be within 5 minutes of server time){RESET}"); + Console.Error.WriteLine($"{YELLOW}Your computer's clock may have drifted.{RESET}"); + Environment.Exit(1); + } + throw new Exception($"HTTP {(int)response.StatusCode}: {responseBody}"); + } + + if (string.IsNullOrWhiteSpace(responseBody)) return new Dictionary(); + + try + { + var doc = JsonDocument.Parse(responseBody); + return doc.RootElement.EnumerateObject().ToDictionary(p => p.Name, p => (object)p.Value.Clone()); + } + catch + { + return new Dictionary { ["raw"] = responseBody }; + } +} + +async Task ServiceEnvSetAsync(string serviceId, string envContent, string publicKey, string secretKey) +{ + if (envContent.Length > 65536) { Console.Error.WriteLine($"{RED}Error: Env content exceeds maximum size of 64KB{RESET}"); return false; } + + try + { + using var request = new HttpRequestMessage(HttpMethod.Put, $"/services/{serviceId}/env"); + request.Content = new StringContent(envContent, Encoding.UTF8, "text/plain"); + + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var message = $"{timestamp}:PUT:/services/{serviceId}/env:{envContent}"; + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey)); + var signature = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(message))).ToLowerInvariant(); + request.Headers.Add("Authorization", $"Bearer {publicKey}"); + request.Headers.Add("X-Timestamp", timestamp.ToString()); + request.Headers.Add("X-Signature", signature); + + var response = await httpClient.SendAsync(request); + return response.IsSuccessStatusCode; + } + catch { return false; } +} + +(string, string) GetApiKeys(string? argsKey) +{ + var publicKey = Environment.GetEnvironmentVariable("UNSANDBOX_PUBLIC_KEY"); + var secretKey = Environment.GetEnvironmentVariable("UNSANDBOX_SECRET_KEY"); + + if (string.IsNullOrEmpty(publicKey) || string.IsNullOrEmpty(secretKey)) + { + var legacyKey = argsKey ?? Environment.GetEnvironmentVariable("UNSANDBOX_API_KEY"); + if (string.IsNullOrEmpty(legacyKey)) + { + Console.Error.WriteLine($"{RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set{RESET}"); + Environment.Exit(1); + } + return (legacyKey, ""); + } + return (publicKey, secretKey); +} + +string DetectLanguage(string filename) +{ + var ext = Path.GetExtension(filename).ToLower(); + if (string.IsNullOrEmpty(ext) || !extMap.TryGetValue(ext, out var language)) + throw new Exception($"Unsupported file extension: {ext}"); + return language; +} + +string BuildEnvContent(List envs, string? envFile) +{ + var lines = new List(envs); + if (!string.IsNullOrEmpty(envFile)) + { + var content = File.ReadAllText(envFile); + lines.AddRange(content.Split('\n').Select(l => l.Trim()).Where(l => !string.IsNullOrEmpty(l) && !l.StartsWith("#"))); + } + return string.Join("\n", lines); +} + +string GetStr(JsonElement el, string prop) => el.TryGetProperty(prop, out var p) ? p.GetString() ?? "N/A" : "N/A"; + +Args ParseArgs(string[] args) +{ + var result = new Args(); + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (arg == "-h" || arg == "--help") result.ShowHelp = true; + else if (arg == "--version") result.ShowVersion = true; + else if (arg == "session") result.Command = "session"; + else if (arg == "service") result.Command = "service"; + else if (arg == "snapshot") result.Command = "snapshot"; + else if (arg == "image") result.Command = "image"; + else if (arg == "languages") result.Command = "languages"; + else if (arg == "key") result.Command = "key"; + else if (arg == "env" && result.Command == "service") + { + if (i + 1 < args.Length && !args[i + 1].StartsWith("-")) + { + result.EnvAction = args[++i]; + if (i + 1 < args.Length && !args[i + 1].StartsWith("-")) result.EnvTarget = args[++i]; + } + } + else if (arg == "-k" || arg == "--api-key") result.ApiKey = args[++i]; + else if (arg == "-n" || arg == "--network") result.Network = args[++i]; + else if (arg == "-v" || arg == "--vcpu") result.Vcpu = int.Parse(args[++i]); + else if (arg == "-e" || arg == "--env") result.Env.Add(args[++i]); + else if (arg == "--env-file") result.EnvFile = args[++i]; + else if (arg == "-f" || arg == "--files") result.Files.Add(args[++i]); + else if (arg == "-a" || arg == "--artifacts") result.Artifacts = true; + else if (arg == "-o" || arg == "--output-dir") result.OutputDir = args[++i]; + else if (arg == "-l" || arg == "--list") + { + if (result.Command == "session") result.SessionList = true; + else if (result.Command == "service") result.ServiceList = true; + else if (result.Command == "snapshot") result.SnapshotList = true; + else if (result.Command == "image") result.ImageList = true; + } + else if (arg == "-s" || arg == "--shell") result.SessionShell = args[++i]; + else if (arg == "--kill") result.SessionKill = args[++i]; + else if (arg == "--name") result.ServiceName = args[++i]; + else if (arg == "--snapshot-name") result.SnapshotName = args[++i]; + else if (arg == "--hot") result.SnapshotHot = true; + else if (arg == "--ports") result.ServicePorts = args[++i]; + else if (arg == "--type") result.ServiceType = args[++i]; + else if (arg == "--bootstrap") result.ServiceBootstrap = args[++i]; + else if (arg == "--bootstrap-file") result.ServiceBootstrapFile = args[++i]; + else if (arg == "--info") + { + var val = args[++i]; + if (result.Command == "service") result.ServiceInfo = val; + else if (result.Command == "snapshot") result.SnapshotInfo = val; + else if (result.Command == "image") result.ImageInfo = val; + } + else if (arg == "--logs") result.ServiceLogs = args[++i]; + else if (arg == "--tail") result.ServiceTail = args[++i]; + else if (arg == "--freeze") + { + var val = args[++i]; + if (result.Command == "session") result.SessionFreeze = val; + else result.ServiceSleep = val; + } + else if (arg == "--unfreeze") + { + var val = args[++i]; + if (result.Command == "session") result.SessionUnfreeze = val; + else result.ServiceWake = val; + } + else if (arg == "--boost") result.SessionBoost = args[++i]; + else if (arg == "--unboost") result.SessionUnboost = args[++i]; + else if (arg == "--snapshot") + { + var val = args[++i]; + if (result.Command == "session") result.SessionSnapshot = val; + else if (result.Command == "service") result.ServiceSnapshot = val; + } + else if (arg == "--destroy") result.ServiceDestroy = args[++i]; + else if (arg == "--lock") + { + var val = args[++i]; + if (result.Command == "service") result.ServiceLock = val; + else if (result.Command == "snapshot") result.SnapshotLock = val; + else if (result.Command == "image") result.ImageLock = val; + } + else if (arg == "--unlock") + { + var val = args[++i]; + if (result.Command == "service") result.ServiceUnlock = val; + else if (result.Command == "snapshot") result.SnapshotUnlock = val; + else if (result.Command == "image") result.ImageUnlock = val; + } + else if (arg == "--resize") result.ServiceResize = args[++i]; + else if (arg == "--redeploy") result.ServiceRedeploy = args[++i]; + else if (arg == "--execute") result.ServiceExecute = args[++i]; + else if (arg == "--command") result.ServiceCommand = args[++i]; + else if (arg == "--dump-bootstrap") result.ServiceDumpBootstrap = args[++i]; + else if (arg == "--dump-file") result.ServiceDumpFile = args[++i]; + else if (arg == "--unfreeze-on-demand") result.ServiceUnfreezeOnDemand = args[++i]; + else if (arg == "--unfreeze-on-demand-enabled") result.ServiceUnfreezeOnDemandEnabled = args[++i].ToLower() == "true"; + else if (arg == "--with-unfreeze-on-demand") result.ServiceCreateUnfreezeOnDemand = true; + else if (arg == "--extend") result.KeyExtend = true; + // Snapshot options + else if (arg == "--delete") + { + var val = args[++i]; + if (result.Command == "snapshot") result.SnapshotDelete = val; + else if (result.Command == "image") result.ImageDelete = val; + } + else if (arg == "--clone") + { + var val = args[++i]; + if (result.Command == "snapshot") result.SnapshotClone = val; + else if (result.Command == "image") result.ImageClone = val; + } + else if (arg == "--clone-type") result.SnapshotCloneType = args[++i]; + // Image options + else if (arg == "--publish") result.ImagePublish = args[++i]; + else if (arg == "--source-type") result.ImageSourceType = args[++i]; + else if (arg == "--visibility") + { + result.ImageVisibility = args[++i]; + if (i + 1 < args.Length && !args[i + 1].StartsWith("-")) result.ImageVisibilityMode = args[++i]; + } + else if (arg == "--spawn") result.ImageSpawn = args[++i]; + // Languages options + else if (arg == "--json") result.LanguagesJson = true; + else if (!arg.StartsWith("-")) result.SourceFile = arg; + } + return result; +} + +void PrintHelp() +{ + Console.WriteLine($@"un {VERSION} (.NET 10) - Unsandbox CLI + +Usage: dotnet run -- [options] + dotnet run -- session [options] + dotnet run -- service [options] + dotnet run -- service env [options] + dotnet run -- snapshot [options] + dotnet run -- image [options] + dotnet run -- languages [options] + dotnet run -- key [options] + +Execute options: + -e KEY=VALUE Set environment variable + -f FILE Add input file + -a Return artifacts + -o DIR Output directory for artifacts + -n MODE Network mode (zerotrust/semitrusted) + -v N vCPU count (1-8) + -k KEY API key + +Session options: + --list List active sessions + --shell NAME Shell/REPL to use + --kill ID Terminate session + --freeze ID Freeze session + --unfreeze ID Unfreeze session + --boost ID Boost session resources + --unboost ID Remove session boost + --snapshot ID Create snapshot from session + --snapshot-name Name for snapshot + --hot Live snapshot (no freeze) + +Service options: + --list List services + --name NAME Create service with name + --ports PORTS Comma-separated ports + --type TYPE Service type (minecraft/mumble/teamspeak/source/tcp/udp) + --bootstrap CMD Bootstrap command + --bootstrap-file FILE Bootstrap from file + --info ID Get service details + --logs ID Get all logs + --tail ID Get last 9000 lines + --freeze ID Freeze service + --unfreeze ID Unfreeze service + --lock ID Prevent deletion + --unlock ID Allow deletion + --resize ID Resize (use with -v) + --redeploy ID Re-run bootstrap + --snapshot ID Create snapshot from service + --unfreeze-on-demand ID Set unfreeze-on-demand for service + --unfreeze-on-demand-enabled BOOL Enable/disable (default: true) + --with-unfreeze-on-demand Enable unfreeze-on-demand when creating service + --destroy ID Destroy service + --execute ID Execute command in service + --command CMD Command to execute (with --execute) + --dump-bootstrap ID Dump bootstrap script + --dump-file FILE File to save bootstrap (with --dump-bootstrap) + -e KEY=VALUE Set vault env var (with --name or env set) + --env-file FILE Load vault vars from file + +Service env commands: + env status ID Check vault status + env set ID Set vault (use -e or --env-file) + env export ID Export vault contents + env delete ID Delete vault + +Snapshot options: + --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 to session/service + --clone-type TYPE Clone type: session or service + --name NAME Name for cloned resource + --ports PORTS Ports for cloned service + +Image options: + --list List all images + --info ID Get image details + --delete ID Delete image + --lock ID Prevent deletion + --unlock ID Allow deletion + --publish ID Publish from service/snapshot + --source-type TYPE Source type: service or snapshot + --visibility ID MODE Set visibility (private/unlisted/public) + --spawn ID Spawn new service from image + --clone ID Clone image + +Languages options: + --json Output as JSON array + +Key options: + --extend Open browser to extend expired key + +Environment: + UNSANDBOX_PUBLIC_KEY Your public API key + UNSANDBOX_SECRET_KEY Your secret API key"); +} + +class Args +{ + public bool ShowHelp, ShowVersion; + public string? Command, SourceFile, ApiKey, Network, OutputDir; + public int Vcpu; + public List Env = new(), Files = new(); + public bool Artifacts, SessionList, ServiceList; + public string? SessionShell, SessionKill; + public string? SessionFreeze, SessionUnfreeze, SessionBoost, SessionUnboost, SessionSnapshot; + public string? ServiceName, ServicePorts, ServiceBootstrap, ServiceBootstrapFile, ServiceType; + public string? ServiceInfo, ServiceLogs, ServiceTail, ServiceSleep, ServiceWake, ServiceDestroy; + public string? ServiceLock, ServiceUnlock, ServiceResize, ServiceRedeploy, ServiceSnapshot; + public string? ServiceExecute, ServiceCommand; + public string? ServiceDumpBootstrap, ServiceDumpFile; + public string? ServiceUnfreezeOnDemand; + public bool ServiceUnfreezeOnDemandEnabled = true; + public bool ServiceCreateUnfreezeOnDemand; + public string? EnvFile, EnvAction, EnvTarget; + public bool KeyExtend; + // Snapshot command + public bool SnapshotList; + public string? SnapshotInfo, SnapshotDelete, SnapshotLock, SnapshotUnlock, SnapshotClone; + public string? SnapshotCloneType, SnapshotName; + public bool SnapshotHot; + // Image command + public bool ImageList; + public string? ImageInfo, ImageDelete, ImageLock, ImageUnlock; + public string? ImagePublish, ImageSourceType, ImageVisibility, ImageVisibilityMode; + public string? ImageSpawn, ImageClone; + // Languages command + public bool LanguagesJson; +} diff --git a/clients/csharp/dotnet/Un.csproj b/clients/dotnet/async/src/Un.csproj similarity index 84% rename from clients/csharp/dotnet/Un.csproj rename to clients/dotnet/async/src/Un.csproj index 5001519..51c1526 100644 --- a/clients/csharp/dotnet/Un.csproj +++ b/clients/dotnet/async/src/Un.csproj @@ -3,10 +3,10 @@ Exe net10.0 - enable + Unsandbox enable + enable un - Unsandbox.Cli diff --git a/clients/dotnet/sync/src/Un.cs b/clients/dotnet/sync/src/Un.cs index 7930294..c25144c 100644 --- a/clients/dotnet/sync/src/Un.cs +++ b/clients/dotnet/sync/src/Un.cs @@ -1,6 +1,6 @@ // PUBLIC DOMAIN - NO LICENSE, NO WARRANTY // -// Un.cs - Unsandbox CLI Client (.NET 10 Implementation) +// Un.cs - Unsandbox CLI Client (.NET 10 Synchronous Implementation) // Build: dotnet build // Run: dotnet run -- [options] // Requires: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY environment variables @@ -59,18 +59,23 @@ try if (parsedArgs.ShowVersion) { - Console.WriteLine($"un {VERSION} (.NET 10)"); + Console.WriteLine($"un {VERSION} (.NET 10 sync)"); return 0; } - await (parsedArgs.Command switch + switch (parsedArgs.Command) { - "session" => CmdSessionAsync(parsedArgs), - "service" => CmdServiceAsync(parsedArgs), - "key" => CmdKeyAsync(parsedArgs), - _ when parsedArgs.SourceFile != null => CmdExecuteAsync(parsedArgs), - _ => Task.Run(() => { PrintHelp(); Environment.Exit(1); }) - }); + case "session": CmdSession(parsedArgs); break; + case "service": CmdService(parsedArgs); break; + case "snapshot": CmdSnapshot(parsedArgs); break; + case "image": CmdImage(parsedArgs); break; + case "languages": CmdLanguages(parsedArgs); break; + case "key": CmdKey(parsedArgs); break; + default: + if (parsedArgs.SourceFile != null) CmdExecute(parsedArgs); + else { PrintHelp(); return 1; } + break; + } return 0; } @@ -80,10 +85,10 @@ catch (Exception ex) return 1; } -async Task CmdExecuteAsync(Args args) +void CmdExecute(Args args) { var (publicKey, secretKey) = GetApiKeys(args.ApiKey); - var code = await File.ReadAllTextAsync(args.SourceFile!); + var code = File.ReadAllText(args.SourceFile!); var language = DetectLanguage(args.SourceFile!); var payload = new Dictionary { ["language"] = language, ["code"] = code }; @@ -102,7 +107,7 @@ async Task CmdExecuteAsync(Args args) var inputFiles = new List>(); foreach (var filepath in args.Files) { - var content = await File.ReadAllBytesAsync(filepath); + var content = File.ReadAllBytes(filepath); inputFiles.Add(new Dictionary { ["filename"] = Path.GetFileName(filepath), @@ -116,7 +121,7 @@ async Task CmdExecuteAsync(Args args) if (args.Network != null) payload["network"] = args.Network; if (args.Vcpu > 0) payload["vcpu"] = args.Vcpu; - var result = await ApiRequestAsync("/execute", HttpMethod.Post, payload, publicKey, secretKey); + var result = ApiRequest("/execute", HttpMethod.Post, payload, publicKey, secretKey); if (result.TryGetValue("stdout", out var stdout) && stdout is JsonElement stdoutEl) Console.Write($"{BLUE}{stdoutEl.GetString()}{RESET}"); @@ -132,7 +137,7 @@ async Task CmdExecuteAsync(Args args) var filename = artifact.GetProperty("filename").GetString() ?? "artifact"; var contentB64 = artifact.GetProperty("content_base64").GetString() ?? ""; var path = Path.Combine(outDir, filename); - await File.WriteAllBytesAsync(path, Convert.FromBase64String(contentB64)); + File.WriteAllBytes(path, Convert.FromBase64String(contentB64)); Console.Error.WriteLine($"{GREEN}Saved: {path}{RESET}"); } } @@ -141,48 +146,86 @@ async Task CmdExecuteAsync(Args args) Environment.Exit(exitCode); } -async Task CmdSessionAsync(Args args) +void CmdSession(Args args) { var (publicKey, secretKey) = GetApiKeys(args.ApiKey); if (args.SessionList) { - var result = await ApiRequestAsync("/sessions", HttpMethod.Get, null, publicKey, secretKey); + var result = ApiRequest("/sessions", HttpMethod.Get, null, publicKey, secretKey); if (result.TryGetValue("sessions", out var sessions) && sessions is JsonElement sessionsEl) { var sessionList = sessionsEl.EnumerateArray().ToList(); if (sessionList.Count == 0) { Console.WriteLine("No active sessions"); return; } Console.WriteLine($"{"ID",-40} {"Shell",-10} {"Status",-10} {"Created"}"); foreach (var s in sessionList) - { Console.WriteLine($"{GetStr(s, "id"),-40} {GetStr(s, "shell"),-10} {GetStr(s, "status"),-10} {GetStr(s, "created_at")}"); - } } return; } if (args.SessionKill != null) { - await ApiRequestAsync($"/sessions/{args.SessionKill}", HttpMethod.Delete, null, publicKey, secretKey); + ApiRequest($"/sessions/{args.SessionKill}", HttpMethod.Delete, null, publicKey, secretKey); Console.WriteLine($"{GREEN}Session terminated: {args.SessionKill}{RESET}"); return; } - var payload = new Dictionary { ["shell"] = args.SessionShell ?? "bash" }; - if (args.Network != null) payload["network"] = args.Network; - if (args.Vcpu > 0) payload["vcpu"] = args.Vcpu; + if (args.SessionFreeze != null) + { + ApiRequest($"/sessions/{args.SessionFreeze}/freeze", HttpMethod.Post, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Session frozen: {args.SessionFreeze}{RESET}"); + return; + } + + if (args.SessionUnfreeze != null) + { + ApiRequest($"/sessions/{args.SessionUnfreeze}/unfreeze", HttpMethod.Post, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Session unfreezing: {args.SessionUnfreeze}{RESET}"); + return; + } + + if (args.SessionBoost != null) + { + ApiRequest($"/sessions/{args.SessionBoost}/boost", HttpMethod.Post, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Session boosted: {args.SessionBoost}{RESET}"); + return; + } + + if (args.SessionUnboost != null) + { + ApiRequest($"/sessions/{args.SessionUnboost}/unboost", HttpMethod.Post, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Session unboosted: {args.SessionUnboost}{RESET}"); + return; + } + + if (args.SessionSnapshot != null) + { + var payload = new Dictionary(); + if (args.SnapshotName != null) payload["name"] = args.SnapshotName; + if (args.SnapshotHot) payload["hot"] = true; + + var result = ApiRequest($"/sessions/{args.SessionSnapshot}/snapshot", HttpMethod.Post, payload, publicKey, secretKey); + var id = result.TryGetValue("id", out var idObj) && idObj is JsonElement idEl ? idEl.GetString() : "unknown"; + Console.WriteLine($"{GREEN}Snapshot created: {id}{RESET}"); + return; + } + + var createPayload = new Dictionary { ["shell"] = args.SessionShell ?? "bash" }; + if (args.Network != null) createPayload["network"] = args.Network; + if (args.Vcpu > 0) createPayload["vcpu"] = args.Vcpu; Console.WriteLine($"{YELLOW}Creating session...{RESET}"); - var createResult = await ApiRequestAsync("/sessions", HttpMethod.Post, payload, publicKey, secretKey); - var sessionId = createResult.TryGetValue("id", out var id) && id is JsonElement idEl ? idEl.GetString() : "unknown"; + var createResult = ApiRequest("/sessions", HttpMethod.Post, createPayload, publicKey, secretKey); + var sessionId = createResult.TryGetValue("id", out var sid) && sid is JsonElement sidEl ? sidEl.GetString() : "unknown"; Console.WriteLine($"{GREEN}Session created: {sessionId}{RESET}"); Console.WriteLine($"{YELLOW}(Interactive sessions require WebSocket - use un2 for full support){RESET}"); } -async Task CmdKeyAsync(Args args) +void CmdKey(Args args) { var (publicKey, secretKey) = GetApiKeys(args.ApiKey); - var result = await ApiRequestAsync("/keys/validate", HttpMethod.Post, null, publicKey, secretKey); + var result = ApiRequest("/keys/validate", HttpMethod.Post, null, publicKey, secretKey); if (!result.TryGetValue("valid", out var validObj) || validObj is not JsonElement validEl) { @@ -236,19 +279,19 @@ void OpenBrowser(string url) catch (Exception ex) { Console.Error.WriteLine($"{RED}Failed to open browser: {ex.Message}{RESET}"); } } -async Task CmdServiceAsync(Args args) +void CmdService(Args args) { var (publicKey, secretKey) = GetApiKeys(args.ApiKey); if (!string.IsNullOrEmpty(args.EnvAction)) { - await CmdServiceEnvAsync(args, publicKey, secretKey); + CmdServiceEnv(args, publicKey, secretKey); return; } if (args.ServiceList) { - var result = await ApiRequestAsync("/services", HttpMethod.Get, null, publicKey, secretKey); + var result = ApiRequest("/services", HttpMethod.Get, null, publicKey, secretKey); if (result.TryGetValue("services", out var services) && services is JsonElement servicesEl) { var serviceList = servicesEl.EnumerateArray().ToList(); @@ -266,35 +309,35 @@ async Task CmdServiceAsync(Args args) if (args.ServiceInfo != null) { - var result = await ApiRequestAsync($"/services/{args.ServiceInfo}", HttpMethod.Get, null, publicKey, secretKey); + var result = ApiRequest($"/services/{args.ServiceInfo}", HttpMethod.Get, null, publicKey, secretKey); Console.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true })); return; } if (args.ServiceLogs != null) { - var result = await ApiRequestAsync($"/services/{args.ServiceLogs}/logs", HttpMethod.Get, null, publicKey, secretKey); + var result = ApiRequest($"/services/{args.ServiceLogs}/logs", HttpMethod.Get, null, publicKey, secretKey); if (result.TryGetValue("logs", out var logs) && logs is JsonElement logsEl) Console.WriteLine(logsEl.GetString()); return; } if (args.ServiceTail != null) { - var result = await ApiRequestAsync($"/services/{args.ServiceTail}/logs?lines=9000", HttpMethod.Get, null, publicKey, secretKey); + var result = ApiRequest($"/services/{args.ServiceTail}/logs?lines=9000", HttpMethod.Get, null, publicKey, secretKey); if (result.TryGetValue("logs", out var logs) && logs is JsonElement logsEl) Console.WriteLine(logsEl.GetString()); return; } if (args.ServiceSleep != null) { - await ApiRequestAsync($"/services/{args.ServiceSleep}/freeze", HttpMethod.Post, null, publicKey, secretKey); + ApiRequest($"/services/{args.ServiceSleep}/freeze", HttpMethod.Post, null, publicKey, secretKey); Console.WriteLine($"{GREEN}Service frozen: {args.ServiceSleep}{RESET}"); return; } if (args.ServiceWake != null) { - await ApiRequestAsync($"/services/{args.ServiceWake}/unfreeze", HttpMethod.Post, null, publicKey, secretKey); + ApiRequest($"/services/{args.ServiceWake}/unfreeze", HttpMethod.Post, null, publicKey, secretKey); Console.WriteLine($"{GREEN}Service unfreezing: {args.ServiceWake}{RESET}"); return; } @@ -302,7 +345,7 @@ async Task CmdServiceAsync(Args args) if (args.ServiceUnfreezeOnDemand != null) { var payload = new Dictionary { ["unfreeze_on_demand"] = args.ServiceUnfreezeOnDemandEnabled }; - await ApiRequestAsync($"/services/{args.ServiceUnfreezeOnDemand}", new HttpMethod("PATCH"), payload, publicKey, secretKey); + ApiRequest($"/services/{args.ServiceUnfreezeOnDemand}", new HttpMethod("PATCH"), payload, publicKey, secretKey); string status = args.ServiceUnfreezeOnDemandEnabled ? "enabled" : "disabled"; Console.WriteLine($"{GREEN}Unfreeze-on-demand {status} for service: {args.ServiceUnfreezeOnDemand}{RESET}"); return; @@ -310,15 +353,57 @@ async Task CmdServiceAsync(Args args) if (args.ServiceDestroy != null) { - await ApiRequestAsync($"/services/{args.ServiceDestroy}", HttpMethod.Delete, null, publicKey, secretKey); + ApiRequest($"/services/{args.ServiceDestroy}", HttpMethod.Delete, null, publicKey, secretKey); Console.WriteLine($"{GREEN}Service destroyed: {args.ServiceDestroy}{RESET}"); return; } + if (args.ServiceLock != null) + { + ApiRequest($"/services/{args.ServiceLock}/lock", HttpMethod.Post, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Service locked: {args.ServiceLock}{RESET}"); + return; + } + + if (args.ServiceUnlock != null) + { + ApiRequest($"/services/{args.ServiceUnlock}/unlock", HttpMethod.Post, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Service unlocked: {args.ServiceUnlock}{RESET}"); + return; + } + + if (args.ServiceResize != null) + { + var payload = new Dictionary(); + if (args.Vcpu > 0) payload["vcpu"] = args.Vcpu; + ApiRequest($"/services/{args.ServiceResize}/resize", HttpMethod.Post, payload, publicKey, secretKey); + Console.WriteLine($"{GREEN}Service resized: {args.ServiceResize}{RESET}"); + return; + } + + if (args.ServiceRedeploy != null) + { + ApiRequest($"/services/{args.ServiceRedeploy}/redeploy", HttpMethod.Post, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Service redeploying: {args.ServiceRedeploy}{RESET}"); + return; + } + + if (args.ServiceSnapshot != null) + { + var payload = new Dictionary(); + if (args.SnapshotName != null) payload["name"] = args.SnapshotName; + if (args.SnapshotHot) payload["hot"] = true; + + var result = ApiRequest($"/services/{args.ServiceSnapshot}/snapshot", HttpMethod.Post, payload, publicKey, secretKey); + var id = result.TryGetValue("id", out var idObj) && idObj is JsonElement idEl ? idEl.GetString() : "unknown"; + Console.WriteLine($"{GREEN}Snapshot created: {id}{RESET}"); + return; + } + if (args.ServiceExecute != null) { var payload = new Dictionary { ["command"] = args.ServiceCommand ?? "" }; - var result = await ApiRequestAsync($"/services/{args.ServiceExecute}/execute", HttpMethod.Post, payload, publicKey, secretKey); + var result = ApiRequest($"/services/{args.ServiceExecute}/execute", HttpMethod.Post, payload, publicKey, secretKey); if (result.TryGetValue("stdout", out var stdout) && stdout is JsonElement stdoutEl) Console.Write($"{BLUE}{stdoutEl.GetString()}{RESET}"); if (result.TryGetValue("stderr", out var stderr) && stderr is JsonElement stderrEl) Console.Error.Write($"{RED}{stderrEl.GetString()}{RESET}"); return; @@ -328,13 +413,13 @@ async Task CmdServiceAsync(Args args) { Console.Error.WriteLine($"Fetching bootstrap script from {args.ServiceDumpBootstrap}..."); var payload = new Dictionary { ["command"] = "cat /tmp/bootstrap.sh" }; - var result = await ApiRequestAsync($"/services/{args.ServiceDumpBootstrap}/execute", HttpMethod.Post, payload, publicKey, secretKey); + var result = ApiRequest($"/services/{args.ServiceDumpBootstrap}/execute", HttpMethod.Post, payload, publicKey, secretKey); var bootstrap = result.TryGetValue("stdout", out var bs) && bs is JsonElement bsEl ? bsEl.GetString() : null; if (!string.IsNullOrEmpty(bootstrap)) { if (args.ServiceDumpFile != null) { - await File.WriteAllTextAsync(args.ServiceDumpFile, bootstrap); + File.WriteAllText(args.ServiceDumpFile, bootstrap); Console.WriteLine($"Bootstrap saved to {args.ServiceDumpFile}"); } else Console.Write(bootstrap); @@ -354,11 +439,12 @@ async Task CmdServiceAsync(Args args) payload["ports"] = args.ServicePorts.Split(',').Select(p => int.Parse(p.Trim())).ToList(); if (args.ServiceType != null) payload["service_type"] = args.ServiceType; if (args.ServiceBootstrap != null) payload["bootstrap"] = args.ServiceBootstrap; + else if (args.ServiceBootstrapFile != null) payload["bootstrap"] = File.ReadAllText(args.ServiceBootstrapFile); if (args.Network != null) payload["network"] = args.Network; if (args.Vcpu > 0) payload["vcpu"] = args.Vcpu; if (args.ServiceCreateUnfreezeOnDemand) payload["unfreeze_on_demand"] = true; - var result = await ApiRequestAsync("/services", HttpMethod.Post, payload, publicKey, secretKey); + var result = ApiRequest("/services", HttpMethod.Post, payload, publicKey, secretKey); var serviceId = result.TryGetValue("id", out var id) && id is JsonElement idEl ? idEl.GetString() : null; Console.WriteLine($"{GREEN}Service created: {serviceId}{RESET}"); if (result.TryGetValue("name", out var name) && name is JsonElement nameEl) Console.WriteLine($"Name: {nameEl.GetString()}"); @@ -369,7 +455,7 @@ async Task CmdServiceAsync(Args args) var envContent = BuildEnvContent(args.Env, args.EnvFile); if (!string.IsNullOrEmpty(envContent)) { - if (await ServiceEnvSetAsync(serviceId, envContent, publicKey, secretKey)) + if (ServiceEnvSet(serviceId, envContent, publicKey, secretKey)) Console.WriteLine($"{GREEN}Vault configured with environment variables{RESET}"); else Console.Error.WriteLine($"{YELLOW}Warning: Failed to set vault{RESET}"); @@ -382,7 +468,7 @@ async Task CmdServiceAsync(Args args) Environment.Exit(1); } -async Task CmdServiceEnvAsync(Args args, string publicKey, string secretKey) +void CmdServiceEnv(Args args, string publicKey, string secretKey) { var action = args.EnvAction; var target = args.EnvTarget; @@ -390,7 +476,7 @@ async Task CmdServiceEnvAsync(Args args, string publicKey, string secretKey) if (action == "status") { if (string.IsNullOrEmpty(target)) { Console.Error.WriteLine($"{RED}Error: service env status requires service ID{RESET}"); Environment.Exit(1); } - var result = await ApiRequestAsync($"/services/{target}/env", HttpMethod.Get, null, publicKey, secretKey); + var result = ApiRequest($"/services/{target}/env", HttpMethod.Get, null, publicKey, secretKey); if (result.TryGetValue("has_vault", out var hv) && hv is JsonElement hvEl && hvEl.GetBoolean()) { Console.WriteLine($"{GREEN}Vault: configured{RESET}"); @@ -404,25 +490,239 @@ async Task CmdServiceEnvAsync(Args args, string publicKey, string secretKey) if (string.IsNullOrEmpty(target)) { Console.Error.WriteLine($"{RED}Error: service env set requires service ID{RESET}"); Environment.Exit(1); } if (args.Env.Count == 0 && string.IsNullOrEmpty(args.EnvFile)) { Console.Error.WriteLine($"{RED}Error: service env set requires -e or --env-file{RESET}"); Environment.Exit(1); } var envContent = BuildEnvContent(args.Env, args.EnvFile); - if (await ServiceEnvSetAsync(target, envContent, publicKey, secretKey)) + if (ServiceEnvSet(target, envContent, publicKey, secretKey)) Console.WriteLine($"{GREEN}Vault updated for service {target}{RESET}"); else { Console.Error.WriteLine($"{RED}Error: Failed to update vault{RESET}"); Environment.Exit(1); } } else if (action == "export") { if (string.IsNullOrEmpty(target)) { Console.Error.WriteLine($"{RED}Error: service env export requires service ID{RESET}"); Environment.Exit(1); } - var result = await ApiRequestAsync($"/services/{target}/env/export", HttpMethod.Post, null, publicKey, secretKey); + var result = ApiRequest($"/services/{target}/env/export", HttpMethod.Post, null, publicKey, secretKey); if (result.TryGetValue("content", out var content) && content is JsonElement contentEl) Console.Write(contentEl.GetString()); } else if (action == "delete") { if (string.IsNullOrEmpty(target)) { Console.Error.WriteLine($"{RED}Error: service env delete requires service ID{RESET}"); Environment.Exit(1); } - await ApiRequestAsync($"/services/{target}/env", HttpMethod.Delete, null, publicKey, secretKey); + ApiRequest($"/services/{target}/env", HttpMethod.Delete, null, publicKey, secretKey); Console.WriteLine($"{GREEN}Vault deleted for service {target}{RESET}"); } } -async Task> ApiRequestAsync(string endpoint, HttpMethod method, Dictionary? data, string publicKey, string secretKey) +void CmdSnapshot(Args args) +{ + var (publicKey, secretKey) = GetApiKeys(args.ApiKey); + + if (args.SnapshotList) + { + var result = ApiRequest("/snapshots", HttpMethod.Get, null, publicKey, secretKey); + if (result.TryGetValue("snapshots", out var snapshots) && snapshots is JsonElement snapshotsEl) + { + var snapshotList = snapshotsEl.EnumerateArray().ToList(); + if (snapshotList.Count == 0) { Console.WriteLine("No snapshots"); return; } + Console.WriteLine($"{"ID",-40} {"Name",-20} {"Type",-10} {"Status",-10} {"Created"}"); + foreach (var s in snapshotList) + Console.WriteLine($"{GetStr(s, "id"),-40} {GetStr(s, "name"),-20} {GetStr(s, "source_type"),-10} {GetStr(s, "status"),-10} {GetStr(s, "created_at")}"); + } + return; + } + + if (args.SnapshotInfo != null) + { + var result = ApiRequest($"/snapshots/{args.SnapshotInfo}", HttpMethod.Get, null, publicKey, secretKey); + Console.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true })); + return; + } + + if (args.SnapshotDelete != null) + { + ApiRequest($"/snapshots/{args.SnapshotDelete}", HttpMethod.Delete, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Snapshot deleted: {args.SnapshotDelete}{RESET}"); + return; + } + + if (args.SnapshotLock != null) + { + ApiRequest($"/snapshots/{args.SnapshotLock}/lock", HttpMethod.Post, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Snapshot locked: {args.SnapshotLock}{RESET}"); + return; + } + + if (args.SnapshotUnlock != null) + { + ApiRequest($"/snapshots/{args.SnapshotUnlock}/unlock", HttpMethod.Post, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Snapshot unlocked: {args.SnapshotUnlock}{RESET}"); + return; + } + + if (args.SnapshotClone != null) + { + var payload = new Dictionary { ["type"] = args.SnapshotCloneType ?? "session" }; + if (args.ServiceName != null) payload["name"] = args.ServiceName; + if (args.SessionShell != null) payload["shell"] = args.SessionShell; + if (args.ServicePorts != null) payload["ports"] = args.ServicePorts.Split(',').Select(p => int.Parse(p.Trim())).ToList(); + if (args.Network != null) payload["network"] = args.Network; + + var result = ApiRequest($"/snapshots/{args.SnapshotClone}/clone", HttpMethod.Post, payload, publicKey, secretKey); + var id = result.TryGetValue("id", out var idObj) && idObj is JsonElement idEl ? idEl.GetString() : "unknown"; + Console.WriteLine($"{GREEN}Cloned to {args.SnapshotCloneType ?? "session"}: {id}{RESET}"); + return; + } + + Console.Error.WriteLine($"{RED}Error: Use --list, --info, --delete, --lock, --unlock, or --clone{RESET}"); + Environment.Exit(1); +} + +void CmdImage(Args args) +{ + var (publicKey, secretKey) = GetApiKeys(args.ApiKey); + + if (args.ImageList) + { + var result = ApiRequest("/images", HttpMethod.Get, null, publicKey, secretKey); + if (result.TryGetValue("images", out var images) && images is JsonElement imagesEl) + { + var imageList = imagesEl.EnumerateArray().ToList(); + if (imageList.Count == 0) { Console.WriteLine("No images"); return; } + Console.WriteLine($"{"ID",-40} {"Name",-20} {"Visibility",-12} {"Status",-10} {"Created"}"); + foreach (var img in imageList) + Console.WriteLine($"{GetStr(img, "id"),-40} {GetStr(img, "name"),-20} {GetStr(img, "visibility"),-12} {GetStr(img, "status"),-10} {GetStr(img, "created_at")}"); + } + return; + } + + if (args.ImageInfo != null) + { + var result = ApiRequest($"/images/{args.ImageInfo}", HttpMethod.Get, null, publicKey, secretKey); + Console.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true })); + return; + } + + if (args.ImageDelete != null) + { + ApiRequest($"/images/{args.ImageDelete}", HttpMethod.Delete, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Image deleted: {args.ImageDelete}{RESET}"); + return; + } + + if (args.ImageLock != null) + { + ApiRequest($"/images/{args.ImageLock}/lock", HttpMethod.Post, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Image locked: {args.ImageLock}{RESET}"); + return; + } + + if (args.ImageUnlock != null) + { + ApiRequest($"/images/{args.ImageUnlock}/unlock", HttpMethod.Post, null, publicKey, secretKey); + Console.WriteLine($"{GREEN}Image unlocked: {args.ImageUnlock}{RESET}"); + return; + } + + if (args.ImagePublish != null) + { + var payload = new Dictionary { ["source_id"] = args.ImagePublish }; + if (args.ImageSourceType != null) payload["source_type"] = args.ImageSourceType; + if (args.ServiceName != null) payload["name"] = args.ServiceName; + + var result = ApiRequest("/images", HttpMethod.Post, payload, publicKey, secretKey); + var id = result.TryGetValue("id", out var idObj) && idObj is JsonElement idEl ? idEl.GetString() : "unknown"; + Console.WriteLine($"{GREEN}Image published: {id}{RESET}"); + return; + } + + if (args.ImageVisibility != null) + { + var payload = new Dictionary { ["visibility"] = args.ImageVisibilityMode ?? "private" }; + ApiRequest($"/images/{args.ImageVisibility}", new HttpMethod("PATCH"), payload, publicKey, secretKey); + Console.WriteLine($"{GREEN}Image visibility set to {args.ImageVisibilityMode}: {args.ImageVisibility}{RESET}"); + return; + } + + if (args.ImageSpawn != null) + { + var payload = new Dictionary(); + if (args.ServiceName != null) payload["name"] = args.ServiceName; + if (args.ServicePorts != null) payload["ports"] = args.ServicePorts.Split(',').Select(p => int.Parse(p.Trim())).ToList(); + if (args.Network != null) payload["network"] = args.Network; + + var result = ApiRequest($"/images/{args.ImageSpawn}/spawn", HttpMethod.Post, payload, publicKey, secretKey); + var id = result.TryGetValue("id", out var idObj) && idObj is JsonElement idEl ? idEl.GetString() : "unknown"; + Console.WriteLine($"{GREEN}Service spawned: {id}{RESET}"); + if (result.TryGetValue("url", out var url) && url is JsonElement urlEl) Console.WriteLine($"URL: {urlEl.GetString()}"); + return; + } + + if (args.ImageClone != null) + { + var payload = new Dictionary(); + if (args.ServiceName != null) payload["name"] = args.ServiceName; + + var result = ApiRequest($"/images/{args.ImageClone}/clone", HttpMethod.Post, payload, publicKey, secretKey); + var id = result.TryGetValue("id", out var idObj) && idObj is JsonElement idEl ? idEl.GetString() : "unknown"; + Console.WriteLine($"{GREEN}Image cloned: {id}{RESET}"); + return; + } + + Console.Error.WriteLine($"{RED}Error: Use --list, --info, --delete, --lock, --unlock, --publish, --visibility, --spawn, or --clone{RESET}"); + Environment.Exit(1); +} + +void CmdLanguages(Args args) +{ + var (publicKey, secretKey) = GetApiKeys(args.ApiKey); + + // Check cache first + var cacheDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unsandbox"); + var cachePath = Path.Combine(cacheDir, "languages.json"); + var cacheMaxAge = TimeSpan.FromHours(1); + + List? languages = null; + + if (File.Exists(cachePath)) + { + var cacheAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(cachePath); + if (cacheAge < cacheMaxAge) + { + try + { + var cacheContent = File.ReadAllText(cachePath); + languages = JsonSerializer.Deserialize>(cacheContent); + } + catch { /* Cache corrupted, fetch fresh */ } + } + } + + if (languages == null) + { + var result = ApiRequest("/languages", HttpMethod.Get, null, publicKey, secretKey); + if (result.TryGetValue("languages", out var langsObj) && langsObj is JsonElement langsEl) + { + languages = langsEl.EnumerateArray().Select(l => l.GetString() ?? "").Where(l => !string.IsNullOrEmpty(l)).ToList(); + + // Save to cache + try + { + Directory.CreateDirectory(cacheDir); + File.WriteAllText(cachePath, JsonSerializer.Serialize(languages)); + } + catch { /* Cache write failed, continue anyway */ } + } + else + { + Console.Error.WriteLine($"{RED}Error: Failed to fetch languages{RESET}"); + Environment.Exit(1); + return; + } + } + + if (args.LanguagesJson) + Console.WriteLine(JsonSerializer.Serialize(languages)); + else + foreach (var lang in languages) + Console.WriteLine(lang); +} + +Dictionary ApiRequest(string endpoint, HttpMethod method, Dictionary? data, string publicKey, string secretKey) { var body = data != null ? JsonSerializer.Serialize(data, jsonOptions) : ""; @@ -445,8 +745,10 @@ async Task> ApiRequestAsync(string endpoint, HttpMeth request.Headers.Add("Authorization", $"Bearer {publicKey}"); } - var response = await httpClient.SendAsync(request); - var responseBody = await response.Content.ReadAsStringAsync(); + // Synchronous HTTP call + var response = httpClient.Send(request); + using var reader = new StreamReader(response.Content.ReadAsStream()); + var responseBody = reader.ReadToEnd(); if (!response.IsSuccessStatusCode) { @@ -472,7 +774,7 @@ async Task> ApiRequestAsync(string endpoint, HttpMeth } } -async Task ServiceEnvSetAsync(string serviceId, string envContent, string publicKey, string secretKey) +bool ServiceEnvSet(string serviceId, string envContent, string publicKey, string secretKey) { if (envContent.Length > 65536) { Console.Error.WriteLine($"{RED}Error: Env content exceeds maximum size of 64KB{RESET}"); return false; } @@ -489,7 +791,7 @@ async Task ServiceEnvSetAsync(string serviceId, string envContent, string request.Headers.Add("X-Timestamp", timestamp.ToString()); request.Headers.Add("X-Signature", signature); - var response = await httpClient.SendAsync(request); + var response = httpClient.Send(request); return response.IsSuccessStatusCode; } catch { return false; } @@ -544,6 +846,9 @@ Args ParseArgs(string[] args) else if (arg == "--version") result.ShowVersion = true; else if (arg == "session") result.Command = "session"; else if (arg == "service") result.Command = "service"; + else if (arg == "snapshot") result.Command = "snapshot"; + else if (arg == "image") result.Command = "image"; + else if (arg == "languages") result.Command = "languages"; else if (arg == "key") result.Command = "key"; else if (arg == "env" && result.Command == "service") { @@ -561,19 +866,68 @@ Args ParseArgs(string[] args) else if (arg == "-f" || arg == "--files") result.Files.Add(args[++i]); else if (arg == "-a" || arg == "--artifacts") result.Artifacts = true; else if (arg == "-o" || arg == "--output-dir") result.OutputDir = args[++i]; - else if (arg == "-l" || arg == "--list") { if (result.Command == "session") result.SessionList = true; else if (result.Command == "service") result.ServiceList = true; } + else if (arg == "-l" || arg == "--list") + { + if (result.Command == "session") result.SessionList = true; + else if (result.Command == "service") result.ServiceList = true; + else if (result.Command == "snapshot") result.SnapshotList = true; + else if (result.Command == "image") result.ImageList = true; + } else if (arg == "-s" || arg == "--shell") result.SessionShell = args[++i]; else if (arg == "--kill") result.SessionKill = args[++i]; else if (arg == "--name") result.ServiceName = args[++i]; + else if (arg == "--snapshot-name") result.SnapshotName = args[++i]; + else if (arg == "--hot") result.SnapshotHot = true; else if (arg == "--ports") result.ServicePorts = args[++i]; else if (arg == "--type") result.ServiceType = args[++i]; else if (arg == "--bootstrap") result.ServiceBootstrap = args[++i]; - else if (arg == "--info") result.ServiceInfo = args[++i]; + else if (arg == "--bootstrap-file") result.ServiceBootstrapFile = args[++i]; + else if (arg == "--info") + { + var val = args[++i]; + if (result.Command == "service") result.ServiceInfo = val; + else if (result.Command == "snapshot") result.SnapshotInfo = val; + else if (result.Command == "image") result.ImageInfo = val; + } else if (arg == "--logs") result.ServiceLogs = args[++i]; else if (arg == "--tail") result.ServiceTail = args[++i]; - else if (arg == "--freeze") result.ServiceSleep = args[++i]; - else if (arg == "--unfreeze") result.ServiceWake = args[++i]; + else if (arg == "--freeze") + { + var val = args[++i]; + if (result.Command == "session") result.SessionFreeze = val; + else result.ServiceSleep = val; + } + else if (arg == "--unfreeze") + { + var val = args[++i]; + if (result.Command == "session") result.SessionUnfreeze = val; + else result.ServiceWake = val; + } + else if (arg == "--boost") result.SessionBoost = args[++i]; + else if (arg == "--unboost") result.SessionUnboost = args[++i]; + else if (arg == "--snapshot") + { + var val = args[++i]; + if (result.Command == "session") result.SessionSnapshot = val; + else if (result.Command == "service") result.ServiceSnapshot = val; + } else if (arg == "--destroy") result.ServiceDestroy = args[++i]; + else if (arg == "--lock") + { + var val = args[++i]; + if (result.Command == "service") result.ServiceLock = val; + else if (result.Command == "snapshot") result.SnapshotLock = val; + else if (result.Command == "image") result.ImageLock = val; + } + else if (arg == "--unlock") + { + var val = args[++i]; + if (result.Command == "service") result.ServiceUnlock = val; + else if (result.Command == "snapshot") result.SnapshotUnlock = val; + else if (result.Command == "image") result.ImageUnlock = val; + } + else if (arg == "--resize") result.ServiceResize = args[++i]; + else if (arg == "--redeploy") result.ServiceRedeploy = args[++i]; else if (arg == "--execute") result.ServiceExecute = args[++i]; else if (arg == "--command") result.ServiceCommand = args[++i]; else if (arg == "--dump-bootstrap") result.ServiceDumpBootstrap = args[++i]; @@ -582,6 +936,28 @@ Args ParseArgs(string[] args) else if (arg == "--unfreeze-on-demand-enabled") result.ServiceUnfreezeOnDemandEnabled = args[++i].ToLower() == "true"; else if (arg == "--with-unfreeze-on-demand") result.ServiceCreateUnfreezeOnDemand = true; else if (arg == "--extend") result.KeyExtend = true; + else if (arg == "--delete") + { + var val = args[++i]; + if (result.Command == "snapshot") result.SnapshotDelete = val; + else if (result.Command == "image") result.ImageDelete = val; + } + else if (arg == "--clone") + { + var val = args[++i]; + if (result.Command == "snapshot") result.SnapshotClone = val; + else if (result.Command == "image") result.ImageClone = val; + } + else if (arg == "--clone-type") result.SnapshotCloneType = args[++i]; + else if (arg == "--publish") result.ImagePublish = args[++i]; + else if (arg == "--source-type") result.ImageSourceType = args[++i]; + else if (arg == "--visibility") + { + result.ImageVisibility = args[++i]; + if (i + 1 < args.Length && !args[i + 1].StartsWith("-")) result.ImageVisibilityMode = args[++i]; + } + else if (arg == "--spawn") result.ImageSpawn = args[++i]; + else if (arg == "--json") result.LanguagesJson = true; else if (!arg.StartsWith("-")) result.SourceFile = arg; } return result; @@ -589,12 +965,15 @@ Args ParseArgs(string[] args) void PrintHelp() { - Console.WriteLine($@"un {VERSION} (.NET 10) - Unsandbox CLI + Console.WriteLine($@"un {VERSION} (.NET 10 sync) - Unsandbox CLI Usage: dotnet run -- [options] dotnet run -- session [options] dotnet run -- service [options] dotnet run -- service env [options] + dotnet run -- snapshot [options] + dotnet run -- image [options] + dotnet run -- languages [options] dotnet run -- key [options] Execute options: @@ -610,6 +989,13 @@ Session options: --list List active sessions --shell NAME Shell/REPL to use --kill ID Terminate session + --freeze ID Freeze session + --unfreeze ID Unfreeze session + --boost ID Boost session resources + --unboost ID Remove session boost + --snapshot ID Create snapshot from session + --snapshot-name Name for snapshot + --hot Live snapshot (no freeze) Service options: --list List services @@ -617,11 +1003,17 @@ Service options: --ports PORTS Comma-separated ports --type TYPE Service type (minecraft/mumble/teamspeak/source/tcp/udp) --bootstrap CMD Bootstrap command + --bootstrap-file FILE Bootstrap from file --info ID Get service details --logs ID Get all logs --tail ID Get last 9000 lines --freeze ID Freeze service --unfreeze ID Unfreeze service + --lock ID Prevent deletion + --unlock ID Allow deletion + --resize ID Resize (use with -v) + --redeploy ID Re-run bootstrap + --snapshot ID Create snapshot from service --unfreeze-on-demand ID Set unfreeze-on-demand for service --unfreeze-on-demand-enabled BOOL Enable/disable (default: true) --with-unfreeze-on-demand Enable unfreeze-on-demand when creating service @@ -639,6 +1031,32 @@ Service env commands: env export ID Export vault contents env delete ID Delete vault +Snapshot options: + --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 to session/service + --clone-type TYPE Clone type: session or service + --name NAME Name for cloned resource + --ports PORTS Ports for cloned service + +Image options: + --list List all images + --info ID Get image details + --delete ID Delete image + --lock ID Prevent deletion + --unlock ID Allow deletion + --publish ID Publish from service/snapshot + --source-type TYPE Source type: service or snapshot + --visibility ID MODE Set visibility (private/unlisted/public) + --spawn ID Spawn new service from image + --clone ID Clone image + +Languages options: + --json Output as JSON array + Key options: --extend Open browser to extend expired key @@ -655,8 +1073,10 @@ class Args public List Env = new(), Files = new(); public bool Artifacts, SessionList, ServiceList; public string? SessionShell, SessionKill; - public string? ServiceName, ServicePorts, ServiceBootstrap, ServiceType; + public string? SessionFreeze, SessionUnfreeze, SessionBoost, SessionUnboost, SessionSnapshot; + public string? ServiceName, ServicePorts, ServiceBootstrap, ServiceBootstrapFile, ServiceType; public string? ServiceInfo, ServiceLogs, ServiceTail, ServiceSleep, ServiceWake, ServiceDestroy; + public string? ServiceLock, ServiceUnlock, ServiceResize, ServiceRedeploy, ServiceSnapshot; public string? ServiceExecute, ServiceCommand; public string? ServiceDumpBootstrap, ServiceDumpFile; public string? ServiceUnfreezeOnDemand; @@ -664,4 +1084,13 @@ class Args public bool ServiceCreateUnfreezeOnDemand; public string? EnvFile, EnvAction, EnvTarget; public bool KeyExtend; + public bool SnapshotList; + public string? SnapshotInfo, SnapshotDelete, SnapshotLock, SnapshotUnlock, SnapshotClone; + public string? SnapshotCloneType, SnapshotName; + public bool SnapshotHot; + public bool ImageList; + public string? ImageInfo, ImageDelete, ImageLock, ImageUnlock; + public string? ImagePublish, ImageSourceType, ImageVisibility, ImageVisibilityMode; + public string? ImageSpawn, ImageClone; + public bool LanguagesJson; }