#!/usr/bin/env elixir # 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 # un.ex - Unsandbox CLI client in Elixir # # Full-featured CLI matching un.py capabilities: # - Execute code with env vars, input files, artifacts # - Interactive sessions with shell/REPL support # - Persistent services with domains and ports # # Usage: # chmod +x un.ex # export UNSANDBOX_API_KEY="your_key_here" # ./un.ex [options] # ./un.ex session [options] # ./un.ex service [options] # # Uses curl for HTTP (no external dependencies) defmodule Un do @blue "\e[34m" @red "\e[31m" @green "\e[32m" @yellow "\e[33m" @reset "\e[0m" @portal_base "https://unsandbox.com" @ext_map %{ ".ex" => "elixir", ".exs" => "elixir", ".erl" => "erlang", ".py" => "python", ".js" => "javascript", ".ts" => "typescript", ".rb" => "ruby", ".go" => "go", ".rs" => "rust", ".c" => "c", ".cpp" => "cpp", ".cc" => "cpp", ".java" => "java", ".kt" => "kotlin", ".cs" => "csharp", ".fs" => "fsharp", ".hs" => "haskell", ".ml" => "ocaml", ".clj" => "clojure", ".scm" => "scheme", ".lisp" => "commonlisp", ".jl" => "julia", ".r" => "r", ".cr" => "crystal", ".d" => "d", ".nim" => "nim", ".zig" => "zig", ".v" => "v", ".dart" => "dart", ".groovy" => "groovy", ".scala" => "scala", ".sh" => "bash", ".pl" => "perl", ".lua" => "lua", ".php" => "php", ".f90" => "fortran", ".cob" => "cobol", ".pro" => "prolog", ".forth" => "forth", ".tcl" => "tcl", ".raku" => "raku" } def main([]), do: print_usage() def main(["session" | rest]), do: session_command(rest) def main(["service" | rest]), do: service_command(rest) def main(["snapshot" | rest]), do: snapshot_command(rest) def main(["key" | rest]), do: key_command(rest) def main(args), do: execute_command(args) defp print_usage do IO.puts("Usage: un.ex [options] ") IO.puts(" un.ex session [options]") IO.puts(" un.ex service [options]") IO.puts(" un.ex service env ") IO.puts(" un.ex snapshot [options]") IO.puts(" un.ex key [--extend]") IO.puts("") IO.puts("Service options: --name, --ports, --bootstrap, -e KEY=VALUE, --env-file FILE") IO.puts("Service env commands: status, set, export, delete") System.halt(1) end # Execute command defp execute_command(args) do api_key = get_api_key() {file, opts} = parse_exec_args(args) if is_nil(file) do IO.puts(:stderr, "Error: No source file specified") System.halt(1) end ext = Path.extname(file) language = Map.get(@ext_map, ext) if is_nil(language) do IO.puts(:stderr, "Error: Unknown extension: #{ext}") System.halt(1) end case File.read(file) do {:ok, code} -> json = build_execute_json(language, code, opts) response = curl_post(api_key, "/execute", json) IO.puts(response) {:error, reason} -> IO.puts(:stderr, "Error reading file: #{reason}") System.halt(1) end end # Session command defp session_command(["--list" | _]) do api_key = get_api_key() response = curl_get(api_key, "/sessions") IO.puts(response) end defp session_command(["--kill", session_id | _]) do api_key = get_api_key() curl_delete(api_key, "/sessions/#{session_id}") IO.puts("#{@green}Session terminated: #{session_id}#{@reset}") end defp session_command(["--snapshot", session_id | rest]) do api_key = get_api_key() name = get_opt(rest, "--snapshot-name", nil, nil) hot = "--hot" in rest name_json = if name, do: ",\"name\":\"#{escape_json(name)}\"", else: "" hot_json = if hot, do: ",\"hot\":true", else: "" json = "{#{String.slice(name_json <> hot_json, 1..-1)}}" response = curl_post(api_key, "/sessions/#{session_id}/snapshot", json) IO.puts("#{@green}Snapshot created#{@reset}") IO.puts(response) end defp session_command(["--restore", snapshot_id | _rest]) do # --restore takes snapshot ID directly, calls /snapshots/:id/restore api_key = get_api_key() response = curl_post(api_key, "/snapshots/#{snapshot_id}/restore", "{}") IO.puts("#{@green}Session restored from snapshot#{@reset}") IO.puts(response) end defp session_command(args) do validate_session_args(args) api_key = get_api_key() shell = get_opt(args, "--shell", "-s", "bash") network = get_opt(args, "-n", nil, nil) vcpu = get_opt(args, "-v", nil, nil) input_files = get_all_opts(args, "-f") network_json = if network, do: ",\"network\":\"#{network}\"", else: "" vcpu_json = if vcpu, do: ",\"vcpu\":#{vcpu}", else: "" input_files_json = build_input_files_json(input_files) json = "{\"shell\":\"#{shell}\"#{network_json}#{vcpu_json}#{input_files_json}}" response = curl_post(api_key, "/sessions", json) IO.puts("#{@yellow}Session created (WebSocket required)#{@reset}") IO.puts(response) end defp validate_session_args([]), do: :ok defp validate_session_args(["--shell", _ | rest]), do: validate_session_args(rest) defp validate_session_args(["-s", _ | rest]), do: validate_session_args(rest) defp validate_session_args(["-f", _ | rest]), do: validate_session_args(rest) defp validate_session_args(["-n", _ | rest]), do: validate_session_args(rest) defp validate_session_args(["-v", _ | rest]), do: validate_session_args(rest) defp validate_session_args(["--snapshot", _ | rest]), do: validate_session_args(rest) defp validate_session_args(["--restore", _ | rest]), do: validate_session_args(rest) defp validate_session_args(["--from", _ | rest]), do: validate_session_args(rest) defp validate_session_args(["--snapshot-name", _ | rest]), do: validate_session_args(rest) defp validate_session_args(["--hot" | rest]), do: validate_session_args(rest) defp validate_session_args([arg | _]) do if String.starts_with?(arg, "-") do IO.puts(:stderr, "Unknown option: #{arg}") IO.puts(:stderr, "Usage: un.ex session [options]") System.halt(1) else validate_session_args([]) end end # Service command defp service_command(["--list" | _]) do api_key = get_api_key() response = curl_get(api_key, "/services") IO.puts(response) end defp service_command(["--info", service_id | _]) do api_key = get_api_key() response = curl_get(api_key, "/services/#{service_id}") IO.puts(response) end defp service_command(["--logs", service_id | _]) do api_key = get_api_key() response = curl_get(api_key, "/services/#{service_id}/logs") IO.puts(response) end defp service_command(["--freeze", service_id | _]) do api_key = get_api_key() curl_post(api_key, "/services/#{service_id}/freeze", "{}") IO.puts("#{@green}Service frozen: #{service_id}#{@reset}") end defp service_command(["--unfreeze", service_id | _]) do api_key = get_api_key() curl_post(api_key, "/services/#{service_id}/unfreeze", "{}") IO.puts("#{@green}Service unfreezing: #{service_id}#{@reset}") end defp service_command(["--destroy", service_id | _]) do api_key = get_api_key() curl_delete(api_key, "/services/#{service_id}") IO.puts("#{@green}Service destroyed: #{service_id}#{@reset}") end defp service_command(["--resize", service_id | rest]) do vcpu = get_opt(rest, "--vcpu", "-v", nil) if is_nil(vcpu) do IO.puts(:stderr, "#{@red}Error: --resize requires --vcpu N#{@reset}") System.halt(1) end vcpu_int = String.to_integer(vcpu) if vcpu_int < 1 or vcpu_int > 8 do IO.puts(:stderr, "#{@red}Error: --vcpu must be between 1 and 8#{@reset}") System.halt(1) end api_key = get_api_key() json = "{\"vcpu\":#{vcpu_int}}" curl_patch(api_key, "/services/#{service_id}", json) ram = vcpu_int * 2 IO.puts("#{@green}Service resized to #{vcpu_int} vCPU, #{ram} GB RAM#{@reset}") end defp service_command(["--snapshot", service_id | rest]) do api_key = get_api_key() name = get_opt(rest, "--snapshot-name", nil, nil) hot = "--hot" in rest name_json = if name, do: ",\"name\":\"#{escape_json(name)}\"", else: "" hot_json = if hot, do: ",\"hot\":true", else: "" json = "{#{String.slice(name_json <> hot_json, 1..-1)}}" response = curl_post(api_key, "/services/#{service_id}/snapshot", json) IO.puts("#{@green}Snapshot created#{@reset}") IO.puts(response) end defp service_command(["--restore", snapshot_id | _rest]) do # --restore takes snapshot ID directly, calls /snapshots/:id/restore api_key = get_api_key() response = curl_post(api_key, "/snapshots/#{snapshot_id}/restore", "{}") IO.puts("#{@green}Service restored from snapshot#{@reset}") IO.puts(response) end defp service_command(["--execute", service_id, "--command", command | _]) do api_key = get_api_key() json = "{\"command\":\"#{escape_json(command)}\"}" response = curl_post(api_key, "/services/#{service_id}/execute", json) case extract_json_value(response, "stdout") do nil -> :ok stdout -> IO.write("#{@blue}#{stdout}#{@reset}") end end defp service_command(["--dump-bootstrap", service_id, file | _]) do api_key = get_api_key() IO.puts(:stderr, "Fetching bootstrap script from #{service_id}...") json = "{\"command\":\"cat /tmp/bootstrap.sh\"}" response = curl_post(api_key, "/services/#{service_id}/execute", json) case extract_json_value(response, "stdout") do nil -> IO.puts(:stderr, "#{@red}Error: Failed to fetch bootstrap (service not running or no bootstrap file)#{@reset}") System.halt(1) script -> File.write!(file, script) System.cmd("chmod", ["755", file]) IO.puts("Bootstrap saved to #{file}") end end defp service_command(["--dump-bootstrap", service_id | _]) do api_key = get_api_key() IO.puts(:stderr, "Fetching bootstrap script from #{service_id}...") json = "{\"command\":\"cat /tmp/bootstrap.sh\"}" response = curl_post(api_key, "/services/#{service_id}/execute", json) case extract_json_value(response, "stdout") do nil -> IO.puts(:stderr, "#{@red}Error: Failed to fetch bootstrap (service not running or no bootstrap file)#{@reset}") System.halt(1) script -> IO.write(script) end end defp service_command(["env", "status", service_id | _]) do response = service_env_status(service_id) has_vault = extract_json_value(response, "has_vault") == "true" if has_vault do IO.puts("#{@green}Vault: configured#{@reset}") env_count = extract_json_value(response, "env_count") if env_count, do: IO.puts("Variables: #{env_count}") updated_at = extract_json_value(response, "updated_at") if updated_at, do: IO.puts("Updated: #{updated_at}") else IO.puts("#{@yellow}Vault: not configured#{@reset}") end end defp service_command(["env", "set", service_id | rest]) do envs = get_all_opts(rest, "-e") env_file = get_opt(rest, "--env-file", nil, nil) if Enum.empty?(envs) and is_nil(env_file) do IO.puts(:stderr, "#{@red}Error: service env set requires -e or --env-file#{@reset}") System.halt(1) end env_content = build_env_content(envs, env_file) if service_env_set(service_id, env_content) do IO.puts("#{@green}Vault updated for service #{service_id}#{@reset}") else IO.puts(:stderr, "#{@red}Error: Failed to update vault#{@reset}") System.halt(1) end end defp service_command(["env", "export", service_id | _]) do response = service_env_export(service_id) content = extract_json_value(response, "content") if content, do: IO.write(content) end defp service_command(["env", "delete", service_id | _]) do if service_env_delete(service_id) do IO.puts("#{@green}Vault deleted for service #{service_id}#{@reset}") else IO.puts(:stderr, "#{@red}Error: Failed to delete vault#{@reset}") System.halt(1) end end defp service_command(["env" | _]) do IO.puts(:stderr, "#{@red}Error: Usage: un.ex service env #{@reset}") System.halt(1) end defp service_command(args) do name = get_opt(args, "--name", nil, nil) if is_nil(name) do IO.puts(:stderr, "Error: --name required to create service") System.halt(1) end api_key = get_api_key() ports = get_opt(args, "--ports", nil, nil) bootstrap = get_opt(args, "--bootstrap", nil, nil) bootstrap_file = get_opt(args, "--bootstrap-file", nil, nil) network = get_opt(args, "-n", nil, nil) vcpu = get_opt(args, "-v", nil, nil) service_type = get_opt(args, "--type", nil, nil) input_files = get_all_opts(args, "-f") envs = get_all_opts(args, "-e") env_file = get_opt(args, "--env-file", nil, nil) ports_json = if ports, do: ",\"ports\":[#{ports}]", else: "" bootstrap_json = if bootstrap, do: ",\"bootstrap\":\"#{escape_json(bootstrap)}\"", else: "" bootstrap_content_json = if bootstrap_file do case File.read(bootstrap_file) do {:ok, content} -> ",\"bootstrap_content\":\"#{escape_json(content)}\"" {:error, _} -> IO.puts(:stderr, "#{@red}Error: Bootstrap file not found: #{bootstrap_file}#{@reset}") System.halt(1) end else "" end network_json = if network, do: ",\"network\":\"#{network}\"", else: "" vcpu_json = if vcpu, do: ",\"vcpu\":#{vcpu}", else: "" type_json = if service_type, do: ",\"service_type\":\"#{service_type}\"", else: "" input_files_json = build_input_files_json(input_files) json = "{\"name\":\"#{name}\"#{ports_json}#{bootstrap_json}#{bootstrap_content_json}#{network_json}#{vcpu_json}#{type_json}#{input_files_json}}" response = curl_post(api_key, "/services", json) IO.puts("#{@green}Service created#{@reset}") IO.puts(response) # Auto-set vault if env vars were provided service_id = extract_json_value(response, "id") if service_id and (not Enum.empty?(envs) or env_file) do env_content = build_env_content(envs, env_file) if String.length(env_content) > 0 do if service_env_set(service_id, env_content) do IO.puts("#{@green}Vault configured with environment variables#{@reset}") else IO.puts("#{@yellow}Warning: Failed to set vault#{@reset}") end end end end # Snapshot command defp snapshot_command(["--list" | _]) do snapshot_command(["-l"]) end defp snapshot_command(["-l" | _]) do api_key = get_api_key() response = curl_get(api_key, "/snapshots") IO.puts(response) end defp snapshot_command(["--info", snapshot_id | _]) do api_key = get_api_key() response = curl_get(api_key, "/snapshots/#{snapshot_id}") IO.puts(response) end defp snapshot_command(["--delete", snapshot_id | _]) do api_key = get_api_key() curl_delete(api_key, "/snapshots/#{snapshot_id}") IO.puts("#{@green}Snapshot deleted: #{snapshot_id}#{@reset}") end defp snapshot_command(["--clone", snapshot_id | rest]) do api_key = get_api_key() clone_type = get_opt(rest, "--type", nil, nil) name = get_opt(rest, "--name", nil, nil) shell = get_opt(rest, "--shell", nil, nil) ports = get_opt(rest, "--ports", nil, nil) if !clone_type do IO.puts(:stderr, "#{@red}Error: --type required (session or service)#{@reset}") System.halt(1) end type_json = "\"type\":\"#{clone_type}\"" name_json = if name, do: ",\"name\":\"#{escape_json(name)}\"", else: "" shell_json = if shell, do: ",\"shell\":\"#{shell}\"", else: "" ports_json = if ports, do: ",\"ports\":[#{ports}]", else: "" json = "{#{type_json}#{name_json}#{shell_json}#{ports_json}}" response = curl_post(api_key, "/snapshots/#{snapshot_id}/clone", json) IO.puts("#{@green}Created from snapshot#{@reset}") IO.puts(response) end defp snapshot_command(_) do IO.puts(:stderr, "Error: Use --list, --info ID, --delete ID, or --clone ID --type TYPE") System.halt(1) end # Key command defp key_command(args) do api_key = get_api_key() if "--extend" in args do validate_key(api_key, extend: true) else validate_key(api_key, extend: false) end end defp validate_key(api_key, extend: extend) do json = "{}" response = portal_curl_post(api_key, "/keys/validate", json) # Try to use Jason if available, otherwise fall back to manual parsing try do case Jason.decode(response) do {:ok, data} -> display_key_info(data, extend) {:error, _} -> # Fallback if Jason is not available, parse manually display_key_info_manual(response, extend) end rescue UndefinedFunctionError -> # If Jason module doesn't exist, use manual parsing display_key_info_manual(response, extend) end end defp display_key_info(data, extend) do status = Map.get(data, "status") public_key = Map.get(data, "public_key") tier = Map.get(data, "tier") expires_at = Map.get(data, "expires_at") time_remaining = Map.get(data, "time_remaining") rate_limit = Map.get(data, "rate_limit") burst = Map.get(data, "burst") concurrency = Map.get(data, "concurrency") case status do "valid" -> IO.puts("#{@green}Valid#{@reset}") IO.puts("Public Key: #{public_key}") IO.puts("Tier: #{tier}") IO.puts("Status: #{status}") IO.puts("Expires: #{expires_at}") if time_remaining, do: IO.puts("Time Remaining: #{time_remaining}") if rate_limit, do: IO.puts("Rate Limit: #{rate_limit}") if burst, do: IO.puts("Burst: #{burst}") if concurrency, do: IO.puts("Concurrency: #{concurrency}") if extend do open_browser("#{@portal_base}/keys/extend?pk=#{public_key}") end "expired" -> IO.puts("#{@red}Expired#{@reset}") IO.puts("Public Key: #{public_key}") IO.puts("Tier: #{tier}") IO.puts("Expired: #{expires_at}") IO.puts("#{@yellow}To renew: Visit #{@portal_base}/keys/extend#{@reset}") if extend do open_browser("#{@portal_base}/keys/extend?pk=#{public_key}") end "invalid" -> IO.puts("#{@red}Invalid#{@reset}") _ -> IO.puts("#{@red}Unknown status: #{status}#{@reset}") end end defp display_key_info_manual(response, extend) do # Simple manual parsing for JSON response status = extract_json_value(response, "status") public_key = extract_json_value(response, "public_key") tier = extract_json_value(response, "tier") expires_at = extract_json_value(response, "expires_at") time_remaining = extract_json_value(response, "time_remaining") rate_limit = extract_json_value(response, "rate_limit") burst = extract_json_value(response, "burst") concurrency = extract_json_value(response, "concurrency") case status do "valid" -> IO.puts("#{@green}Valid#{@reset}") IO.puts("Public Key: #{public_key}") IO.puts("Tier: #{tier}") IO.puts("Status: #{status}") IO.puts("Expires: #{expires_at}") if time_remaining, do: IO.puts("Time Remaining: #{time_remaining}") if rate_limit, do: IO.puts("Rate Limit: #{rate_limit}") if burst, do: IO.puts("Burst: #{burst}") if concurrency, do: IO.puts("Concurrency: #{concurrency}") if extend do open_browser("#{@portal_base}/keys/extend?pk=#{public_key}") end "expired" -> IO.puts("#{@red}Expired#{@reset}") IO.puts("Public Key: #{public_key}") IO.puts("Tier: #{tier}") IO.puts("Expired: #{expires_at}") IO.puts("#{@yellow}To renew: Visit #{@portal_base}/keys/extend#{@reset}") if extend do open_browser("#{@portal_base}/keys/extend?pk=#{public_key}") end "invalid" -> IO.puts("#{@red}Invalid#{@reset}") _ -> IO.puts("#{@red}Unknown status: #{status}#{@reset}") IO.puts(response) end end defp extract_json_value(json_str, key) do case Regex.run(~r/"#{key}"\s*:\s*"([^"]*)"/, json_str) do [_, value] -> value _ -> nil end end defp open_browser(url) do IO.puts("#{@blue}Opening browser: #{url}#{@reset}") case :os.type() do {:unix, :linux} -> System.cmd("xdg-open", [url], stderr_to_stdout: true) {:unix, :darwin} -> System.cmd("open", [url], stderr_to_stdout: true) {:win32, _} -> System.cmd("cmd", ["/c", "start", url], stderr_to_stdout: true) _ -> IO.puts("#{@yellow}Please open manually: #{url}#{@reset}") end end # Helpers defp get_api_keys do public_key = System.get_env("UNSANDBOX_PUBLIC_KEY") secret_key = System.get_env("UNSANDBOX_SECRET_KEY") # Fall back to UNSANDBOX_API_KEY for backwards compatibility api_key = System.get_env("UNSANDBOX_API_KEY") cond do public_key && secret_key -> {public_key, secret_key} api_key -> {api_key, nil} true -> IO.puts(:stderr, "Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set (or UNSANDBOX_API_KEY for backwards compat)") System.halt(1) end end defp get_api_key do {public_key, _} = get_api_keys() public_key end defp hmac_sha256(secret, message) do :crypto.mac(:hmac, :sha256, secret, message) |> Base.encode16(case: :lower) end defp make_signature(secret_key, timestamp, method, path, body) do message = "#{timestamp}:#{method}:#{path}:#{body}" hmac_sha256(secret_key, message) end defp escape_json(s) do s |> String.replace("\\", "\\\\") |> String.replace("\"", "\\\"") |> String.replace("\n", "\\n") |> String.replace("\r", "\\r") |> String.replace("\t", "\\t") end defp read_and_base64(filepath) do case File.read(filepath) do {:ok, content} -> Base.encode64(content) {:error, _} -> "" end end defp build_input_files_json([]), do: "" defp build_input_files_json(files) do file_jsons = files |> Enum.map(fn f -> b64 = read_and_base64(f) basename = Path.basename(f) "{\"filename\":\"#{escape_json(basename)}\",\"content\":\"#{b64}\"}" end) |> Enum.join(",") ",\"input_files\":[#{file_jsons}]" end defp build_execute_json(language, code, _opts) do "{\"language\":\"#{language}\",\"code\":\"#{escape_json(code)}\"}" end defp curl_post(api_key, endpoint, json) do tmp_file = "/tmp/un_ex_#{:rand.uniform(999999)}.json" File.write!(tmp_file, json) {public_key, secret_key} = get_api_keys() headers = build_auth_headers(public_key, secret_key, "POST", endpoint, json) args = [ "-s", "-X", "POST", "https://api.unsandbox.com#{endpoint}", "-H", "Content-Type: application/json" ] ++ headers ++ ["-d", "@#{tmp_file}"] {output, _exit} = System.cmd("curl", args, stderr_to_stdout: true) File.rm(tmp_file) check_clock_drift(output) output end defp build_auth_headers(public_key, secret_key, method, path, body) do if secret_key do timestamp = System.system_time(:second) |> Integer.to_string() signature = make_signature(secret_key, timestamp, method, path, body) [ "-H", "Authorization: Bearer #{public_key}", "-H", "X-Timestamp: #{timestamp}", "-H", "X-Signature: #{signature}" ] else # Backwards compatibility: use simple bearer token ["-H", "Authorization: Bearer #{public_key}"] end end defp portal_curl_post(api_key, endpoint, json) do tmp_file = "/tmp/un_ex_#{:rand.uniform(999999)}.json" File.write!(tmp_file, json) {public_key, secret_key} = get_api_keys() headers = build_auth_headers(public_key, secret_key, "POST", endpoint, json) args = [ "-s", "-X", "POST", "#{@portal_base}#{endpoint}", "-H", "Content-Type: application/json" ] ++ headers ++ ["-d", "@#{tmp_file}"] {output, _exit} = System.cmd("curl", args, stderr_to_stdout: true) File.rm(tmp_file) check_clock_drift(output) output end defp curl_get(api_key, endpoint) do {public_key, secret_key} = get_api_keys() headers = build_auth_headers(public_key, secret_key, "GET", endpoint, "") args = [ "-s", "https://api.unsandbox.com#{endpoint}" ] ++ headers {output, _exit} = System.cmd("curl", args, stderr_to_stdout: true) check_clock_drift(output) output end defp curl_delete(api_key, endpoint) do {public_key, secret_key} = get_api_keys() headers = build_auth_headers(public_key, secret_key, "DELETE", endpoint, "") args = [ "-s", "-X", "DELETE", "https://api.unsandbox.com#{endpoint}" ] ++ headers {output, _exit} = System.cmd("curl", args, stderr_to_stdout: true) check_clock_drift(output) output end defp curl_patch(api_key, endpoint, json) do tmp_file = "/tmp/un_ex_#{:rand.uniform(999999)}.json" File.write!(tmp_file, json) {public_key, secret_key} = get_api_keys() headers = build_auth_headers(public_key, secret_key, "PATCH", endpoint, json) args = [ "-s", "-X", "PATCH", "https://api.unsandbox.com#{endpoint}", "-H", "Content-Type: application/json" ] ++ headers ++ ["-d", "@#{tmp_file}"] {output, _exit} = System.cmd("curl", args, stderr_to_stdout: true) File.rm(tmp_file) check_clock_drift(output) output end defp curl_put_text(endpoint, body) do tmp_file = "/tmp/un_ex_#{:rand.uniform(999999)}.txt" File.write!(tmp_file, body) {public_key, secret_key} = get_api_keys() headers = build_auth_headers(public_key, secret_key, "PUT", endpoint, body) args = [ "-s", "-o", "/dev/null", "-w", "%{http_code}", "-X", "PUT", "https://api.unsandbox.com#{endpoint}", "-H", "Content-Type: text/plain" ] ++ headers ++ ["-d", "@#{tmp_file}"] {output, _exit} = System.cmd("curl", args, stderr_to_stdout: true) File.rm(tmp_file) status_code = String.trim(output) |> String.to_integer() status_code >= 200 and status_code < 300 end @max_env_content_size 65536 defp read_env_file(path) do case File.read(path) do {:ok, content} -> content {:error, _} -> IO.puts(:stderr, "#{@red}Error: Env file not found: #{path}#{@reset}") System.halt(1) end end defp build_env_content(envs, env_file) do file_lines = if env_file do content = read_env_file(env_file) content |> String.split("\n") |> Enum.map(&String.trim/1) |> Enum.filter(fn line -> String.length(line) > 0 and not String.starts_with?(line, "#") end) else [] end (envs ++ file_lines) |> Enum.join("\n") end defp service_env_status(service_id) do api_key = get_api_key() curl_get(api_key, "/services/#{service_id}/env") end defp service_env_set(service_id, env_content) do if String.length(env_content) > @max_env_content_size do IO.puts(:stderr, "#{@red}Error: Env content exceeds maximum size of 64KB#{@reset}") false else curl_put_text("/services/#{service_id}/env", env_content) end end defp service_env_export(service_id) do api_key = get_api_key() curl_post(api_key, "/services/#{service_id}/env/export", "{}") end defp service_env_delete(service_id) do api_key = get_api_key() curl_delete(api_key, "/services/#{service_id}/env") true end defp parse_exec_args(args) do parse_exec_args(args, nil, %{}) end defp parse_exec_args([], file, opts), do: {file, opts} defp parse_exec_args([arg | rest], file, opts) do cond do String.starts_with?(arg, "-") -> parse_exec_args(rest, file, opts) is_nil(file) -> parse_exec_args(rest, arg, opts) true -> parse_exec_args(rest, file, opts) end end defp get_opt([], _long, _short, default), do: default defp get_opt([arg, value | rest], long, short, _default) when arg == long or arg == short do value end defp get_opt([_arg | rest], long, short, default) do get_opt(rest, long, short, default) end defp get_all_opts(args, flag), do: get_all_opts(args, flag, []) defp get_all_opts([], _flag, acc), do: Enum.reverse(acc) defp get_all_opts([arg, value | rest], flag, acc) when arg == flag do get_all_opts(rest, flag, [value | acc]) end defp get_all_opts([_arg | rest], flag, acc) do get_all_opts(rest, flag, acc) end defp check_clock_drift(response) do response_lower = String.downcase(response) # Check if response contains "timestamp" and error indicators has_timestamp = String.contains?(response_lower, "timestamp") has_error = String.contains?(response_lower, "401") or String.contains?(response_lower, "expired") or String.contains?(response_lower, "invalid") if has_timestamp and has_error do IO.puts(:stderr, "#{@red}Error: Request timestamp expired (must be within 5 minutes of server time)#{@reset}") IO.puts(:stderr, "#{@yellow}Your computer's clock may have drifted.") IO.puts(:stderr, "Check your system time and sync with NTP if needed:") IO.puts(:stderr, " Linux: sudo ntpdate -s time.nist.gov") IO.puts(:stderr, " macOS: sudo sntp -sS time.apple.com") IO.puts(:stderr, " Windows: w32tm /resync#{@reset}") System.halt(1) end end end Un.main(System.argv())