un-inception/clients/elixir/sync/src/un.ex
russell@unturf.com 13f15c8abc Add --account N flag and fix credential priority in Erlang, Elixir, OCaml, F#, Haskell SDKs
Correct 5-tier credential priority across all five implementations:
  1. Explicit -p/-k flags (function arguments)
  2. --account N -> accounts.csv row N (bypasses env vars)
  3. UNSANDBOX_PUBLIC_KEY / UNSANDBOX_SECRET_KEY env vars
  4. ~/.unsandbox/accounts.csv row 0 (or UNSANDBOX_ACCOUNT env)
  5. ./accounts.csv row 0

Erlang: add load_credentials_from_csv/2, extract_account_arg/3,
  process-dict-based account_index, dispatch/1 helper, fix get_api_keys/0.

Elixir: add load_credentials_from_csv/2, extract_account_arg/3,
  Process.put/get-based account_index, dispatch/1 helper, fix get_api_keys/0.

OCaml: add cli_account_index ref, parse_accounts_csv, load_csv_at,
  strip_account_arg, fix get_credentials priority ordering.

F#: add AccountIndex field to Args, loadCredentialsFromCsv, fix getApiKeys
  signature to accept accountIndex, wire --account N in parseArgs.

Haskell: add cliAccountIndex IORef (unsafePerformIO), loadCredentialsFromCsv,
  stripAccountArg, fix getApiKeys priority ordering.
2026-03-23 15:14:36 -04:00

2419 lines
80 KiB
Elixir
Executable file

#!/usr/bin/env elixir
# This is free 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, & 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.
# 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] <source_file>
# ./un.ex session [options]
# ./un.ex service [options]
#
# Uses curl for HTTP (no external dependencies)
defmodule Un do
@moduledoc """
unsandbox.com Elixir SDK - Full API with execution, sessions, services, snapshots, and images.
## Library Usage
# Execute code synchronously
result = Un.execute("python", "print(42)")
IO.puts(result.stdout)
# List sessions
sessions = Un.session_list()
# Create a service
service_id = Un.service_create("myapp", ports: "8080")
## Authentication
Credentials are loaded in priority order:
1. Function arguments (public_key, secret_key)
2. --account N -> accounts.csv row N (bypasses env vars)
3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
4. ~/.unsandbox/accounts.csv row 0 (or UNSANDBOX_ACCOUNT env)
5. ./accounts.csv row 0
"""
@blue "\e[34m"
@red "\e[31m"
@green "\e[32m"
@yellow "\e[33m"
@reset "\e[0m"
@api_base "https://api.unsandbox.com"
@portal_base "https://unsandbox.com"
@languages_cache_ttl 3600
@version "4.2.0"
# ============================================================================
# Types
# ============================================================================
@type result :: %{
success: boolean(),
stdout: String.t(),
stderr: String.t(),
exit_code: integer(),
job_id: String.t() | nil,
language: String.t() | nil,
execution_time: float() | nil
}
@type job :: %{
id: String.t(),
status: String.t(),
language: String.t() | nil,
created_at: integer() | nil,
completed_at: integer() | nil
}
@type session :: %{
id: String.t(),
status: String.t(),
container_name: String.t() | nil,
network_mode: String.t() | nil,
vcpu: integer() | nil,
created_at: integer() | nil
}
@type service :: %{
id: String.t(),
name: String.t(),
status: String.t(),
ports: String.t() | nil,
domains: String.t() | nil,
vcpu: integer() | nil,
locked: boolean(),
unfreeze_on_demand: boolean(),
created_at: integer() | nil
}
@type snapshot :: %{
id: String.t(),
name: String.t() | nil,
type: String.t(),
source_id: String.t(),
hot: boolean(),
locked: boolean(),
created_at: integer() | nil,
size_bytes: integer() | nil
}
@type image :: %{
id: String.t(),
name: String.t() | nil,
description: String.t() | nil,
visibility: String.t(),
source_type: String.t(),
source_id: String.t(),
locked: boolean(),
created_at: integer() | nil,
size_bytes: integer() | nil
}
@type key_info :: %{
valid: boolean(),
tier: String.t() | nil,
rate_limit_per_minute: integer() | nil,
concurrency_limit: integer() | nil,
expires_at: integer() | nil
}
@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"
}
# ============================================================================
# Utility Functions
# ============================================================================
@doc """
Return the SDK version.
"""
@spec version() :: String.t()
def version, do: @version
@doc """
Check API health.
Returns true if API is healthy, false otherwise.
"""
@spec health_check() :: boolean()
def health_check do
try do
{output, 0} = System.cmd("curl", ["-s", "-o", "/dev/null", "-w", "%{http_code}", "#{@api_base}/health"])
String.trim(output) == "200"
rescue
_ -> false
end
end
@doc """
Generate HMAC-SHA256 signature for a message.
"""
@spec hmac_sign(String.t(), String.t()) :: String.t()
def hmac_sign(secret_key, message) do
hmac_sha256(secret_key, message)
end
@doc """
Detect language from filename extension.
"""
@spec detect_language(String.t()) :: String.t() | nil
def detect_language(filename) do
ext = Path.extname(filename) |> String.downcase()
Map.get(@ext_map, ext)
end
# ============================================================================
# Execution Functions (8)
# ============================================================================
@doc """
Execute code synchronously.
## Options
* `:network` - Network mode ("zerotrust" or "semitrusted")
* `:vcpu` - Number of vCPUs (1-8)
* `:ttl` - Time to live in seconds
* `:env` - Environment variables as keyword list
* `:input_files` - List of file paths to include
* `:return_artifacts` - Return compiled artifacts
* `:public_key` - API public key (optional)
* `:secret_key` - API secret key (optional)
## Examples
result = Un.execute("python", "print('Hello World')")
IO.puts(result.stdout)
"""
@spec execute(String.t(), String.t(), keyword()) :: result()
def execute(language, code, opts \\ []) do
json = build_execute_json_full(language, code, opts)
response = api_post("/execute", json, opts)
parse_result(response)
end
@doc """
Execute code asynchronously, returning a job ID.
"""
@spec execute_async(String.t(), String.t(), keyword()) :: String.t() | nil
def execute_async(language, code, opts \\ []) do
json = build_execute_json_full(language, code, opts)
response = api_post("/execute/async", json, opts)
extract_json_value(response, "job_id")
end
@doc """
Wait for a job to complete and return the result.
"""
@spec wait_job(String.t(), keyword()) :: result()
def wait_job(job_id, opts \\ []) do
poll_delays = [300, 450, 700, 900, 650, 1600, 2000]
max_polls = Keyword.get(opts, :max_polls, 100)
do_wait_job(job_id, poll_delays, 0, max_polls, opts)
end
defp do_wait_job(job_id, poll_delays, poll_count, max_polls, opts) when poll_count >= max_polls do
%{success: false, stdout: "", stderr: "Max polls exceeded", exit_code: 1, job_id: job_id, language: nil, execution_time: nil}
end
defp do_wait_job(job_id, poll_delays, poll_count, max_polls, opts) do
delay_idx = min(poll_count, length(poll_delays) - 1)
delay = Enum.at(poll_delays, delay_idx)
Process.sleep(delay)
job = get_job(job_id, opts)
case job.status do
status when status in ["completed", "failed", "timeout", "cancelled"] ->
response = api_get("/jobs/#{job_id}", opts)
parse_result(response)
_ ->
do_wait_job(job_id, poll_delays, poll_count + 1, max_polls, opts)
end
end
@doc """
Get job status and details.
"""
@spec get_job(String.t(), keyword()) :: job()
def get_job(job_id, opts \\ []) do
response = api_get("/jobs/#{job_id}", opts)
%{
id: job_id,
status: extract_json_value(response, "status") || "unknown",
language: extract_json_value(response, "language"),
created_at: extract_json_int(response, "created_at"),
completed_at: extract_json_int(response, "completed_at")
}
end
@doc """
Cancel a running job.
"""
@spec cancel_job(String.t(), keyword()) :: boolean()
def cancel_job(job_id, opts \\ []) do
response = api_delete("/jobs/#{job_id}", opts)
not String.contains?(response, "\"error\"")
end
@doc """
List all active jobs.
"""
@spec list_jobs(keyword()) :: String.t()
def list_jobs(opts \\ []) do
api_get("/jobs", opts)
end
@doc """
Get list of supported languages.
"""
@spec get_languages(keyword()) :: [String.t()]
def get_languages(opts \\ []) do
case load_languages_cache() do
nil ->
response = api_get("/languages", opts)
langs = extract_json_array(response, "languages")
save_languages_cache(langs)
langs
cached ->
cached
end
end
# ============================================================================
# Session Functions (9)
# ============================================================================
@doc """
List all sessions.
"""
@spec session_list(keyword()) :: String.t()
def session_list(opts \\ []), do: api_get("/sessions", opts)
@doc """
Get session details.
"""
@spec session_get(String.t(), keyword()) :: session()
def session_get(session_id, opts \\ []) do
response = api_get("/sessions/#{session_id}", opts)
%{
id: session_id,
status: extract_json_value(response, "status") || "unknown",
container_name: extract_json_value(response, "container_name"),
network_mode: extract_json_value(response, "network_mode"),
vcpu: extract_json_int(response, "vcpu"),
created_at: extract_json_int(response, "created_at")
}
end
@doc """
Create a new session.
## Options
* `:shell` - Shell to use (default "bash")
* `:network` - Network mode
* `:vcpu` - Number of vCPUs
* `:input_files` - List of file paths
"""
@spec session_create(keyword()) :: session()
def session_create(opts \\ []) do
shell = Keyword.get(opts, :shell, "bash")
network = Keyword.get(opts, :network)
vcpu = Keyword.get(opts, :vcpu)
input_files = Keyword.get(opts, :input_files, [])
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 = api_post("/sessions", json, opts)
%{
id: extract_json_value(response, "id") || "",
status: extract_json_value(response, "status") || "created",
container_name: extract_json_value(response, "container_name"),
network_mode: extract_json_value(response, "network_mode"),
vcpu: extract_json_int(response, "vcpu"),
created_at: extract_json_int(response, "created_at")
}
end
@doc """
Destroy a session.
"""
@spec session_destroy(String.t(), keyword()) :: boolean()
def session_destroy(session_id, opts \\ []) do
response = api_delete("/sessions/#{session_id}", opts)
not String.contains?(response, "\"error\"")
end
@doc """
Freeze a session.
"""
@spec session_freeze(String.t(), keyword()) :: boolean()
def session_freeze(session_id, opts \\ []) do
response = api_post("/sessions/#{session_id}/freeze", "{}", opts)
not String.contains?(response, "\"error\"")
end
@doc """
Unfreeze a session.
"""
@spec session_unfreeze(String.t(), keyword()) :: boolean()
def session_unfreeze(session_id, opts \\ []) do
response = api_post("/sessions/#{session_id}/unfreeze", "{}", opts)
not String.contains?(response, "\"error\"")
end
@doc """
Boost session resources (increase vCPU).
"""
@spec session_boost(String.t(), integer(), keyword()) :: boolean()
def session_boost(session_id, vcpu, opts \\ []) do
json = "{\"vcpu\":#{vcpu}}"
response = api_patch("/sessions/#{session_id}", json, opts)
not String.contains?(response, "\"error\"")
end
@doc """
Unboost session (reset to default resources).
"""
@spec session_unboost(String.t(), keyword()) :: boolean()
def session_unboost(session_id, opts \\ []) do
json = "{\"vcpu\":1}"
response = api_patch("/sessions/#{session_id}", json, opts)
not String.contains?(response, "\"error\"")
end
@doc """
Execute a command in a session.
"""
@spec session_execute(String.t(), String.t(), keyword()) :: result()
def session_execute(session_id, command, opts \\ []) do
json = "{\"command\":\"#{escape_json(command)}\"}"
response = api_post("/sessions/#{session_id}/execute", json, opts)
parse_result(response)
end
# ============================================================================
# Service Functions (17)
# ============================================================================
@doc """
List all services.
"""
@spec service_list(keyword()) :: String.t()
def service_list(opts \\ []), do: api_get("/services", opts)
@doc """
Get service details.
"""
@spec service_get(String.t(), keyword()) :: service()
def service_get(service_id, opts \\ []) do
response = api_get("/services/#{service_id}", opts)
%{
id: service_id,
name: extract_json_value(response, "name") || "",
status: extract_json_value(response, "status") || "unknown",
ports: extract_json_value(response, "ports"),
domains: extract_json_value(response, "domains"),
vcpu: extract_json_int(response, "vcpu"),
locked: extract_json_value(response, "locked") == "true",
unfreeze_on_demand: extract_json_value(response, "unfreeze_on_demand") == "true",
created_at: extract_json_int(response, "created_at")
}
end
@doc """
Create a new service.
## Options
* `:ports` - Ports to expose (e.g., "8080" or "80,443")
* `:domains` - Custom domains
* `:bootstrap` - Bootstrap script content
* `:network` - Network mode
* `:vcpu` - Number of vCPUs
* `:input_files` - List of file paths
"""
@spec service_create(String.t(), keyword()) :: String.t() | nil
def service_create(name, opts \\ []) do
ports = Keyword.get(opts, :ports)
domains = Keyword.get(opts, :domains)
bootstrap = Keyword.get(opts, :bootstrap)
network = Keyword.get(opts, :network)
vcpu = Keyword.get(opts, :vcpu)
input_files = Keyword.get(opts, :input_files, [])
ports_json = if ports, do: ",\"ports\":[#{ports}]", else: ""
domains_json = if domains, do: ",\"domains\":\"#{escape_json(domains)}\"", else: ""
bootstrap_json = if bootstrap, do: ",\"bootstrap\":\"#{escape_json(bootstrap)}\"", else: ""
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 = "{\"name\":\"#{escape_json(name)}\"#{ports_json}#{domains_json}#{bootstrap_json}#{network_json}#{vcpu_json}#{input_files_json}}"
response = api_post("/services", json, opts)
extract_json_value(response, "id")
end
@doc """
Destroy a service.
"""
@spec service_destroy(String.t(), keyword()) :: boolean()
def service_destroy(service_id, opts \\ []) do
response = api_delete("/services/#{service_id}", opts)
not String.contains?(response, "\"error\"")
end
@doc """
Freeze a service.
"""
@spec service_freeze(String.t(), keyword()) :: boolean()
def service_freeze(service_id, opts \\ []) do
response = api_post("/services/#{service_id}/freeze", "{}", opts)
not String.contains?(response, "\"error\"")
end
@doc """
Unfreeze a service.
"""
@spec service_unfreeze(String.t(), keyword()) :: boolean()
def service_unfreeze(service_id, opts \\ []) do
response = api_post("/services/#{service_id}/unfreeze", "{}", opts)
not String.contains?(response, "\"error\"")
end
@doc """
Lock a service to prevent deletion.
"""
@spec service_lock(String.t(), keyword()) :: boolean()
def service_lock(service_id, opts \\ []) do
response = api_post("/services/#{service_id}/lock", "{}", opts)
not String.contains?(response, "\"error\"")
end
@doc """
Unlock a service.
"""
@spec service_unlock(String.t(), keyword()) :: boolean()
def service_unlock(service_id, opts \\ []) do
response = api_post("/services/#{service_id}/unlock", "{}", opts)
not String.contains?(response, "\"error\"")
end
@doc """
Set unfreeze-on-demand for a service.
"""
@spec service_set_unfreeze_on_demand(String.t(), boolean(), keyword()) :: boolean()
def service_set_unfreeze_on_demand(service_id, enabled, opts \\ []) do
json = "{\"unfreeze_on_demand\":#{enabled}}"
response = api_patch("/services/#{service_id}", json, opts)
not String.contains?(response, "\"error\"")
end
@doc """
Redeploy a service with optional new bootstrap.
"""
@spec service_redeploy(String.t(), String.t() | nil, keyword()) :: boolean()
def service_redeploy(service_id, bootstrap \\ nil, opts \\ []) do
bootstrap_json = if bootstrap, do: "\"bootstrap\":\"#{escape_json(bootstrap)}\"", else: ""
json = "{#{bootstrap_json}}"
response = api_post("/services/#{service_id}/redeploy", json, opts)
not String.contains?(response, "\"error\"")
end
@doc """
Get service bootstrap logs.
"""
@spec service_logs(String.t(), keyword()) :: String.t()
def service_logs(service_id, opts \\ []) do
all_logs = Keyword.get(opts, :all_logs, false)
endpoint = if all_logs, do: "/services/#{service_id}/logs?all=true", else: "/services/#{service_id}/logs"
api_get(endpoint, opts)
end
@doc """
Execute a command in a service.
"""
@spec service_execute(String.t(), String.t(), keyword()) :: result()
def service_execute(service_id, command, opts \\ []) do
timeout_ms = Keyword.get(opts, :timeout_ms)
timeout_json = if timeout_ms, do: ",\"timeout_ms\":#{timeout_ms}", else: ""
json = "{\"command\":\"#{escape_json(command)}\"#{timeout_json}}"
response = api_post("/services/#{service_id}/execute", json, opts)
parse_result(response)
end
@doc """
Get service environment vault.
"""
@spec service_env_get(String.t(), keyword()) :: String.t()
def service_env_get(service_id, opts \\ []) do
api_get("/services/#{service_id}/env", opts)
end
@doc """
Set service environment vault.
"""
@spec service_env_set(String.t(), String.t(), keyword()) :: boolean()
def service_env_set(service_id, env_content, opts \\ []) do
api_put_text("/services/#{service_id}/env", env_content, opts)
end
@doc """
Delete service environment vault.
"""
@spec service_env_delete(String.t(), keyword()) :: boolean()
def service_env_delete(service_id, opts \\ []) do
response = api_delete("/services/#{service_id}/env", opts)
not String.contains?(response, "\"error\"")
end
@doc """
Export service environment vault.
"""
@spec service_env_export(String.t(), keyword()) :: String.t()
def service_env_export(service_id, opts \\ []) do
response = api_post("/services/#{service_id}/env/export", "{}", opts)
extract_json_value(response, "content") || ""
end
@doc """
Resize a service (change vCPU).
"""
@spec service_resize(String.t(), integer(), keyword()) :: boolean()
def service_resize(service_id, vcpu, opts \\ []) do
json = "{\"vcpu\":#{vcpu}}"
response = api_patch("/services/#{service_id}", json, opts)
not String.contains?(response, "\"error\"")
end
# ============================================================================
# Snapshot Functions (9)
# ============================================================================
@doc """
List all snapshots.
"""
@spec snapshot_list(keyword()) :: String.t()
def snapshot_list(opts \\ []), do: api_get("/snapshots", opts)
@doc """
Get snapshot details.
"""
@spec snapshot_get(String.t(), keyword()) :: snapshot()
def snapshot_get(snapshot_id, opts \\ []) do
response = api_get("/snapshots/#{snapshot_id}", opts)
%{
id: snapshot_id,
name: extract_json_value(response, "name"),
type: extract_json_value(response, "type") || "unknown",
source_id: extract_json_value(response, "source_id") || "",
hot: extract_json_value(response, "hot") == "true",
locked: extract_json_value(response, "locked") == "true",
created_at: extract_json_int(response, "created_at"),
size_bytes: extract_json_int(response, "size_bytes")
}
end
@doc """
Create a snapshot of a session.
"""
@spec snapshot_session(String.t(), keyword()) :: String.t() | nil
def snapshot_session(session_id, opts \\ []) do
name = Keyword.get(opts, :name)
hot = Keyword.get(opts, :hot, false)
name_json = if name, do: "\"name\":\"#{escape_json(name)}\",", else: ""
json = "{#{name_json}\"hot\":#{hot}}"
response = api_post("/sessions/#{session_id}/snapshot", json, opts)
extract_json_value(response, "id")
end
@doc """
Create a snapshot of a service.
"""
@spec snapshot_service(String.t(), keyword()) :: String.t() | nil
def snapshot_service(service_id, opts \\ []) do
name = Keyword.get(opts, :name)
hot = Keyword.get(opts, :hot, false)
name_json = if name, do: "\"name\":\"#{escape_json(name)}\",", else: ""
json = "{#{name_json}\"hot\":#{hot}}"
response = api_post("/services/#{service_id}/snapshot", json, opts)
extract_json_value(response, "id")
end
@doc """
Restore from a snapshot.
"""
@spec snapshot_restore(String.t(), keyword()) :: String.t() | nil
def snapshot_restore(snapshot_id, opts \\ []) do
response = api_post("/snapshots/#{snapshot_id}/restore", "{}", opts)
extract_json_value(response, "id")
end
@doc """
Delete a snapshot.
"""
@spec snapshot_delete(String.t(), keyword()) :: boolean()
def snapshot_delete(snapshot_id, opts \\ []) do
response = api_delete("/snapshots/#{snapshot_id}", opts)
not String.contains?(response, "\"error\"")
end
@doc """
Lock a snapshot to prevent deletion.
"""
@spec snapshot_lock(String.t(), keyword()) :: boolean()
def snapshot_lock(snapshot_id, opts \\ []) do
response = api_post("/snapshots/#{snapshot_id}/lock", "{}", opts)
not String.contains?(response, "\"error\"")
end
@doc """
Unlock a snapshot.
"""
@spec snapshot_unlock(String.t(), keyword()) :: boolean()
def snapshot_unlock(snapshot_id, opts \\ []) do
response = api_post("/snapshots/#{snapshot_id}/unlock", "{}", opts)
not String.contains?(response, "\"error\"")
end
@doc """
Clone a snapshot to create a new session or service.
## Options
* `:type` - "session" or "service" (required)
* `:name` - Name for cloned service
* `:ports` - Ports for cloned service
* `:shell` - Shell for cloned session
"""
@spec snapshot_clone(String.t(), keyword()) :: String.t() | nil
def snapshot_clone(snapshot_id, opts \\ []) do
clone_type = Keyword.get(opts, :type)
name = Keyword.get(opts, :name)
ports = Keyword.get(opts, :ports)
shell = Keyword.get(opts, :shell)
type_json = "\"type\":\"#{clone_type}\""
name_json = if name, do: ",\"name\":\"#{escape_json(name)}\"", else: ""
ports_json = if ports, do: ",\"ports\":[#{ports}]", else: ""
shell_json = if shell, do: ",\"shell\":\"#{shell}\"", else: ""
json = "{#{type_json}#{name_json}#{ports_json}#{shell_json}}"
response = api_post("/snapshots/#{snapshot_id}/clone", json, opts)
extract_json_value(response, "id")
end
# ============================================================================
# Image Functions (13)
# ============================================================================
@doc """
List images.
## Options
* `:filter` - "owned", "shared", "public", or nil for all
"""
@spec image_list(keyword()) :: String.t()
def image_list(opts \\ []) do
filter = Keyword.get(opts, :filter)
endpoint = if filter, do: "/images?filter=#{filter}", else: "/images"
api_get(endpoint, opts)
end
@doc """
Get image details.
"""
@spec image_get(String.t(), keyword()) :: image()
def image_get(image_id, opts \\ []) do
response = api_get("/images/#{image_id}", opts)
%{
id: image_id,
name: extract_json_value(response, "name"),
description: extract_json_value(response, "description"),
visibility: extract_json_value(response, "visibility") || "private",
source_type: extract_json_value(response, "source_type") || "",
source_id: extract_json_value(response, "source_id") || "",
locked: extract_json_value(response, "locked") == "true",
created_at: extract_json_int(response, "created_at"),
size_bytes: extract_json_int(response, "size_bytes")
}
end
@doc """
Publish an image from a service or snapshot.
## Options
* `:name` - Image name
* `:description` - Image description
"""
@spec image_publish(String.t(), String.t(), keyword()) :: String.t() | nil
def image_publish(source_type, source_id, opts \\ []) do
name = Keyword.get(opts, :name)
description = Keyword.get(opts, :description)
name_json = if name, do: ",\"name\":\"#{escape_json(name)}\"", else: ""
desc_json = if description, do: ",\"description\":\"#{escape_json(description)}\"", else: ""
json = "{\"source_type\":\"#{source_type}\",\"source_id\":\"#{source_id}\"#{name_json}#{desc_json}}"
response = api_post("/images/publish", json, opts)
extract_json_value(response, "id")
end
@doc """
Delete an image.
"""
@spec image_delete(String.t(), keyword()) :: boolean()
def image_delete(image_id, opts \\ []) do
response = api_delete("/images/#{image_id}", opts)
not String.contains?(response, "\"error\"")
end
@doc """
Lock an image to prevent deletion.
"""
@spec image_lock(String.t(), keyword()) :: boolean()
def image_lock(image_id, opts \\ []) do
response = api_post("/images/#{image_id}/lock", "{}", opts)
not String.contains?(response, "\"error\"")
end
@doc """
Unlock an image.
"""
@spec image_unlock(String.t(), keyword()) :: boolean()
def image_unlock(image_id, opts \\ []) do
response = api_post("/images/#{image_id}/unlock", "{}", opts)
not String.contains?(response, "\"error\"")
end
@doc """
Set image visibility.
"""
@spec image_set_visibility(String.t(), String.t(), keyword()) :: boolean()
def image_set_visibility(image_id, visibility, opts \\ []) do
json = "{\"visibility\":\"#{visibility}\"}"
response = api_post("/images/#{image_id}/visibility", json, opts)
not String.contains?(response, "\"error\"")
end
@doc """
Grant access to an image for another API key.
"""
@spec image_grant_access(String.t(), String.t(), keyword()) :: boolean()
def image_grant_access(image_id, trusted_api_key, opts \\ []) do
json = "{\"api_key\":\"#{trusted_api_key}\"}"
response = api_post("/images/#{image_id}/access/grant", json, opts)
not String.contains?(response, "\"error\"")
end
@doc """
Revoke access to an image from another API key.
"""
@spec image_revoke_access(String.t(), String.t(), keyword()) :: boolean()
def image_revoke_access(image_id, trusted_api_key, opts \\ []) do
json = "{\"api_key\":\"#{trusted_api_key}\"}"
response = api_post("/images/#{image_id}/access/revoke", json, opts)
not String.contains?(response, "\"error\"")
end
@doc """
List trusted API keys for an image.
"""
@spec image_list_trusted(String.t(), keyword()) :: [String.t()]
def image_list_trusted(image_id, opts \\ []) do
response = api_get("/images/#{image_id}/access", opts)
extract_json_array(response, "trusted_keys")
end
@doc """
Transfer image ownership to another API key.
"""
@spec image_transfer(String.t(), String.t(), keyword()) :: boolean()
def image_transfer(image_id, to_api_key, opts \\ []) do
json = "{\"to_api_key\":\"#{to_api_key}\"}"
response = api_post("/images/#{image_id}/transfer", json, opts)
not String.contains?(response, "\"error\"")
end
@doc """
Spawn a new service from an image.
## Options
* `:name` - Service name
* `:ports` - Ports to expose
* `:bootstrap` - Bootstrap command
* `:network` - Network mode
"""
@spec image_spawn(String.t(), keyword()) :: String.t() | nil
def image_spawn(image_id, opts \\ []) do
name = Keyword.get(opts, :name)
ports = Keyword.get(opts, :ports)
bootstrap = Keyword.get(opts, :bootstrap)
network = Keyword.get(opts, :network)
name_json = if name, do: "\"name\":\"#{escape_json(name)}\"", else: ""
ports_json = if ports, do: "#{if name, do: ",", else: ""}\"ports\":[#{ports}]", else: ""
bootstrap_json = if bootstrap, do: ",\"bootstrap\":\"#{escape_json(bootstrap)}\"", else: ""
network_json = if network, do: ",\"network\":\"#{network}\"", else: ""
json = "{#{name_json}#{ports_json}#{bootstrap_json}#{network_json}}"
response = api_post("/images/#{image_id}/spawn", json, opts)
extract_json_value(response, "id")
end
@doc """
Clone an image.
## Options
* `:name` - Name for cloned image
* `:description` - Description for cloned image
"""
@spec image_clone(String.t(), keyword()) :: String.t() | nil
def image_clone(image_id, opts \\ []) do
name = Keyword.get(opts, :name)
description = Keyword.get(opts, :description)
name_json = if name, do: "\"name\":\"#{escape_json(name)}\"", else: ""
desc_json = if description, do: "#{if name, do: ",", else: ""}\"description\":\"#{escape_json(description)}\"", else: ""
json = "{#{name_json}#{desc_json}}"
response = api_post("/images/#{image_id}/clone", json, opts)
extract_json_value(response, "id")
end
# ============================================================================
# PaaS Logs Functions (2)
# ============================================================================
@doc """
Fetch batch logs from portal.
## Options
* `:source` - "all", "api", "portal", "pool/cammy", "pool/ai"
* `:lines` - Number of lines (1-10000)
* `:since` - Time window ("1m", "5m", "1h", "1d")
* `:grep` - Filter pattern
"""
@spec logs_fetch(keyword()) :: String.t()
def logs_fetch(opts \\ []) do
source = Keyword.get(opts, :source, "all")
lines = Keyword.get(opts, :lines, 100)
since = Keyword.get(opts, :since, "1h")
grep = Keyword.get(opts, :grep)
grep_param = if grep, do: "&grep=#{URI.encode(grep)}", else: ""
api_get("/logs?source=#{source}&lines=#{lines}&since=#{since}#{grep_param}", opts)
end
@doc """
Stream logs via SSE. This is a blocking operation that calls the callback for each log line.
Note: Full SSE streaming requires WebSocket support; this implementation provides basic fetch.
"""
@spec logs_stream(keyword(), (String.t(), String.t() -> any())) :: :ok
def logs_stream(opts \\ [], callback) do
# For Elixir without external deps, we can't do true SSE streaming
# Instead, we poll with a short interval
source = Keyword.get(opts, :source, "all")
grep = Keyword.get(opts, :grep)
interval = Keyword.get(opts, :interval, 5000)
grep_param = if grep, do: "&grep=#{URI.encode(grep)}", else: ""
Stream.repeatedly(fn ->
response = api_get("/logs?source=#{source}&lines=50&since=10s#{grep_param}", opts)
callback.(source, response)
Process.sleep(interval)
end)
|> Stream.run()
:ok
end
# ============================================================================
# Key Validation (1)
# ============================================================================
@doc """
Validate API keys and get account information.
"""
@spec validate_keys(keyword()) :: key_info()
def validate_keys(opts \\ []) do
response = portal_post("/keys/validate", "{}", opts)
%{
valid: extract_json_value(response, "status") == "valid",
tier: extract_json_value(response, "tier"),
rate_limit_per_minute: extract_json_int(response, "rate_per_minute"),
concurrency_limit: extract_json_int(response, "concurrency"),
expires_at: extract_json_int(response, "expires_at")
}
end
# ============================================================================
# Private API Functions
# ============================================================================
defp api_get(endpoint, opts) do
{public_key, secret_key} = get_api_keys_from_opts(opts)
headers = build_auth_headers(public_key, secret_key, "GET", endpoint, "")
args = ["-s", "#{@api_base}#{endpoint}"] ++ headers
{output, _exit} = System.cmd("curl", args, stderr_to_stdout: true)
check_clock_drift(output)
output
end
defp api_post(endpoint, json, opts) do
tmp_file = "/tmp/un_ex_#{:rand.uniform(999999)}.json"
File.write!(tmp_file, json)
{public_key, secret_key} = get_api_keys_from_opts(opts)
headers = build_auth_headers(public_key, secret_key, "POST", endpoint, json)
args = ["-s", "-X", "POST", "#{@api_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 api_delete(endpoint, opts) do
{public_key, secret_key} = get_api_keys_from_opts(opts)
headers = build_auth_headers(public_key, secret_key, "DELETE", endpoint, "")
args = ["-s", "-X", "DELETE", "#{@api_base}#{endpoint}"] ++ headers
{output, _exit} = System.cmd("curl", args, stderr_to_stdout: true)
check_clock_drift(output)
output
end
defp api_patch(endpoint, json, opts) do
tmp_file = "/tmp/un_ex_#{:rand.uniform(999999)}.json"
File.write!(tmp_file, json)
{public_key, secret_key} = get_api_keys_from_opts(opts)
headers = build_auth_headers(public_key, secret_key, "PATCH", endpoint, json)
args = ["-s", "-X", "PATCH", "#{@api_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 api_put_text(endpoint, body, opts) do
tmp_file = "/tmp/un_ex_#{:rand.uniform(999999)}.txt"
File.write!(tmp_file, body)
{public_key, secret_key} = get_api_keys_from_opts(opts)
headers = build_auth_headers(public_key, secret_key, "PUT", endpoint, body)
args = ["-s", "-o", "/dev/null", "-w", "%{http_code}", "-X", "PUT", "#{@api_base}#{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
defp portal_post(endpoint, json, opts) do
tmp_file = "/tmp/un_ex_#{:rand.uniform(999999)}.json"
File.write!(tmp_file, json)
{public_key, secret_key} = get_api_keys_from_opts(opts)
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 get_api_keys_from_opts(opts) do
public_key = Keyword.get(opts, :public_key)
secret_key = Keyword.get(opts, :secret_key)
if public_key && secret_key do
{public_key, secret_key}
else
get_api_keys()
end
end
defp build_execute_json_full(language, code, opts) do
network = Keyword.get(opts, :network)
vcpu = Keyword.get(opts, :vcpu)
ttl = Keyword.get(opts, :ttl)
env = Keyword.get(opts, :env, [])
input_files = Keyword.get(opts, :input_files, [])
return_artifacts = Keyword.get(opts, :return_artifacts, false)
network_json = if network, do: ",\"network\":\"#{network}\"", else: ""
vcpu_json = if vcpu, do: ",\"vcpu\":#{vcpu}", else: ""
ttl_json = if ttl, do: ",\"ttl\":#{ttl}", else: ""
env_json = if env != [], do: ",\"env\":{" <> Enum.map_join(env, ",", fn {k, v} -> "\"#{k}\":\"#{escape_json(v)}\"" end) <> "}", else: ""
input_files_json = build_input_files_json(input_files)
artifacts_json = if return_artifacts, do: ",\"return_artifacts\":true", else: ""
"{\"language\":\"#{language}\",\"code\":\"#{escape_json(code)}\"#{network_json}#{vcpu_json}#{ttl_json}#{env_json}#{input_files_json}#{artifacts_json}}"
end
defp parse_result(response) do
%{
success: extract_json_int(response, "exit_code") == 0,
stdout: extract_json_value(response, "stdout") || "",
stderr: extract_json_value(response, "stderr") || "",
exit_code: extract_json_int(response, "exit_code") || 0,
job_id: extract_json_value(response, "job_id"),
language: extract_json_value(response, "language"),
execution_time: nil
}
end
defp extract_json_int(json_str, key) do
case Regex.run(~r/"#{key}"\s*:\s*(-?\d+)/, json_str) do
[_, value] -> String.to_integer(value)
_ -> nil
end
end
# ============================================================================
# CLI Entry Point
# ============================================================================
def main(raw_args) do
{account_index, args} = extract_account_arg(raw_args, nil, [])
if account_index != nil do
Process.put(:account_index, account_index)
end
dispatch(args)
end
defp dispatch([]), do: print_usage()
defp dispatch(["session" | rest]), do: session_command(rest)
defp dispatch(["service" | rest]), do: service_command(rest)
defp dispatch(["snapshot" | rest]), do: snapshot_command(rest)
defp dispatch(["image" | rest]), do: image_command(rest)
defp dispatch(["key" | rest]), do: key_command(rest)
defp dispatch(["languages" | rest]), do: languages_command(rest)
defp dispatch(args), do: execute_command(args)
defp extract_account_arg([], acc, rest_acc), do: {acc, Enum.reverse(rest_acc)}
defp extract_account_arg(["--account", n_str | rest], _acc, rest_acc) do
n = case Integer.parse(n_str) do
{n, ""} -> n
_ ->
IO.puts(:stderr, "Error: --account requires an integer argument")
System.halt(1)
end
extract_account_arg(rest, n, rest_acc)
end
defp extract_account_arg([arg | rest], acc, rest_acc) do
extract_account_arg(rest, acc, [arg | rest_acc])
end
defp print_usage do
IO.puts("Usage: un.ex [--account N] [options] <source_file>")
IO.puts(" un.ex [--account N] session [options]")
IO.puts(" un.ex [--account N] service [options]")
IO.puts(" un.ex [--account N] service env <action> <service_id>")
IO.puts(" un.ex [--account N] snapshot [options]")
IO.puts(" un.ex [--account N] image [options]")
IO.puts(" un.ex [--account N] key [--extend]")
IO.puts(" un.ex languages [--json]")
IO.puts("")
IO.puts("Global options:")
IO.puts(" --account N Use accounts.csv row N (bypasses env vars)")
IO.puts("")
IO.puts("Service options: --name, --ports, --bootstrap, -e KEY=VALUE, --env-file FILE")
IO.puts(" --set-unfreeze-on-demand ID true|false")
IO.puts("Service env commands: status, set, export, delete")
IO.puts("Image options: --list, --info ID, --delete ID, --lock ID, --unlock ID,")
IO.puts(" --publish ID --source-type TYPE, --visibility ID MODE,")
IO.puts(" --spawn ID, --clone ID, --name NAME, --ports PORTS")
IO.puts("Languages options: --json (output as JSON array)")
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()
case curl_delete_with_sudo(api_key, "/services/#{service_id}") do
{:ok, _, _} ->
IO.puts("#{@green}Service destroyed: #{service_id}#{@reset}")
{:ok, _} ->
IO.puts("#{@green}Service destroyed: #{service_id}#{@reset}")
{:error, :cancelled} ->
System.halt(1)
{:error, msg} ->
IO.puts(:stderr, "#{@red}Error: #{msg}#{@reset}")
System.halt(1)
end
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(["--set-unfreeze-on-demand", service_id, enabled | _]) do
api_key = get_api_key()
enabled_bool = String.downcase(enabled) in ["true", "1", "yes", "on"]
json = "{\"unfreeze_on_demand\":#{enabled_bool}}"
curl_patch(api_key, "/services/#{service_id}", json)
IO.puts("#{@green}Service unfreeze_on_demand set to #{enabled_bool}: #{service_id}#{@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 <status|set|export|delete> <service_id>#{@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()
case curl_delete_with_sudo(api_key, "/snapshots/#{snapshot_id}") do
{:ok, _, _} ->
IO.puts("#{@green}Snapshot deleted: #{snapshot_id}#{@reset}")
{:ok, _} ->
IO.puts("#{@green}Snapshot deleted: #{snapshot_id}#{@reset}")
{:error, :cancelled} ->
System.halt(1)
{:error, msg} ->
IO.puts(:stderr, "#{@red}Error: #{msg}#{@reset}")
System.halt(1)
end
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
# Image command
defp image_command(["--list" | _]) do
image_command(["-l"])
end
defp image_command(["-l" | _]) do
api_key = get_api_key()
response = curl_get(api_key, "/images")
IO.puts(response)
end
defp image_command(["--info", image_id | _]) do
api_key = get_api_key()
response = curl_get(api_key, "/images/#{image_id}")
IO.puts(response)
end
defp image_command(["--delete", image_id | _]) do
api_key = get_api_key()
case curl_delete_with_sudo(api_key, "/images/#{image_id}") do
{:ok, _, _} ->
IO.puts("#{@green}Image deleted: #{image_id}#{@reset}")
{:ok, _} ->
IO.puts("#{@green}Image deleted: #{image_id}#{@reset}")
{:error, :cancelled} ->
System.halt(1)
{:error, msg} ->
IO.puts(:stderr, "#{@red}Error: #{msg}#{@reset}")
System.halt(1)
end
end
defp image_command(["--lock", image_id | _]) do
api_key = get_api_key()
curl_post(api_key, "/images/#{image_id}/lock", "{}")
IO.puts("#{@green}Image locked: #{image_id}#{@reset}")
end
defp image_command(["--unlock", image_id | _]) do
api_key = get_api_key()
case curl_post_with_sudo(api_key, "/images/#{image_id}/unlock", "{}") do
{:ok, _, _} ->
IO.puts("#{@green}Image unlocked: #{image_id}#{@reset}")
{:ok, _} ->
IO.puts("#{@green}Image unlocked: #{image_id}#{@reset}")
{:error, :cancelled} ->
System.halt(1)
{:error, msg} ->
IO.puts(:stderr, "#{@red}Error: #{msg}#{@reset}")
System.halt(1)
end
end
defp image_command(["--publish", source_id | rest]) do
source_type = get_opt(rest, "--source-type", nil, nil)
if is_nil(source_type) do
IO.puts(:stderr, "#{@red}Error: --source-type required (service or snapshot)#{@reset}")
System.halt(1)
end
api_key = get_api_key()
name = get_opt(rest, "--name", nil, nil)
name_json = if name, do: ",\"name\":\"#{escape_json(name)}\"", else: ""
json = "{\"source_type\":\"#{source_type}\",\"source_id\":\"#{source_id}\"#{name_json}}"
response = curl_post(api_key, "/images/publish", json)
IO.puts("#{@green}Image published#{@reset}")
IO.puts(response)
end
defp image_command(["--visibility", image_id, mode | _]) do
api_key = get_api_key()
json = "{\"visibility\":\"#{mode}\"}"
curl_post(api_key, "/images/#{image_id}/visibility", json)
IO.puts("#{@green}Image visibility set to #{mode}#{@reset}")
end
defp image_command(["--spawn", image_id | rest]) do
api_key = get_api_key()
name = get_opt(rest, "--name", nil, nil)
ports = get_opt(rest, "--ports", nil, nil)
name_json = if name, do: "\"name\":\"#{escape_json(name)}\"", else: ""
ports_json = if ports, do: "\"ports\":[#{ports}]", else: ""
parts = [name_json, ports_json] |> Enum.filter(&(&1 != "")) |> Enum.join(",")
json = "{#{parts}}"
response = curl_post(api_key, "/images/#{image_id}/spawn", json)
IO.puts("#{@green}Service spawned from image#{@reset}")
IO.puts(response)
end
defp image_command(["--clone", image_id | rest]) do
api_key = get_api_key()
name = get_opt(rest, "--name", nil, nil)
json = if name, do: "{\"name\":\"#{escape_json(name)}\"}", else: "{}"
response = curl_post(api_key, "/images/#{image_id}/clone", json)
IO.puts("#{@green}Image cloned#{@reset}")
IO.puts(response)
end
defp image_command(_) do
IO.puts(:stderr, "Error: Use --list, --info ID, --delete ID, --lock ID, --unlock ID, --publish ID, --visibility ID MODE, --spawn ID, or --clone ID")
System.halt(1)
end
# Languages cache functions
defp get_languages_cache_path do
home = System.get_env("HOME") || "."
Path.join([home, ".unsandbox", "languages.json"])
end
defp load_languages_cache do
cache_path = get_languages_cache_path()
case File.read(cache_path) do
{:ok, content} ->
case Jason.decode(content) do
{:ok, data} ->
timestamp = Map.get(data, "timestamp", 0)
now = System.system_time(:second)
if now - timestamp < @languages_cache_ttl do
Map.get(data, "languages", [])
else
nil
end
{:error, _} ->
# Fallback to manual JSON parsing for environments without Jason
timestamp = extract_json_number(content, "timestamp")
now = System.system_time(:second)
if timestamp != 0 and now - timestamp < @languages_cache_ttl do
extract_json_array(content, "languages")
else
nil
end
end
{:error, _} ->
nil
end
rescue
UndefinedFunctionError ->
# Jason not available, use manual parsing
cache_path = get_languages_cache_path()
case File.read(cache_path) do
{:ok, content} ->
timestamp = extract_json_number(content, "timestamp")
now = System.system_time(:second)
if timestamp != 0 and now - timestamp < @languages_cache_ttl do
extract_json_array(content, "languages")
else
nil
end
{:error, _} ->
nil
end
end
defp save_languages_cache(languages) do
cache_path = get_languages_cache_path()
cache_dir = Path.dirname(cache_path)
# Ensure directory exists
File.mkdir_p(cache_dir)
timestamp = System.system_time(:second)
languages_json = "[" <> Enum.map_join(languages, ",", &("\"#{&1}\"")) <> "]"
json = "{\"languages\":#{languages_json},\"timestamp\":#{timestamp}}"
File.write(cache_path, json)
end
defp extract_json_number(json_str, key) do
case Regex.run(~r/"#{key}"\s*:\s*(\d+)/, json_str) do
[_, value] -> String.to_integer(value)
_ -> 0
end
end
# Languages command
defp languages_command(args) do
json_output = "--json" in args
# Try to load from cache first
languages =
case load_languages_cache() do
nil ->
# Cache miss or expired, fetch from API
api_key = get_api_key()
response = curl_get(api_key, "/languages")
langs = extract_json_array(response, "languages")
save_languages_cache(langs)
langs
cached_languages ->
cached_languages
end
if json_output do
# Output as JSON array
json_str = "[" <> Enum.map_join(languages, ",", &("\"#{&1}\"")) <> "]"
IO.puts(json_str)
else
# Output one language per line
Enum.each(languages, &IO.puts/1)
end
end
defp extract_json_array(json_str, key) do
case Regex.run(~r/"#{key}"\s*:\s*\[([^\]]*)\]/, json_str) do
[_, array_content] ->
Regex.scan(~r/"([^"]*)"/, array_content)
|> Enum.map(fn [_, val] -> val end)
_ -> []
end
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 load_credentials_from_csv(csv_path, account_index) do
case File.read(csv_path) do
{:ok, content} ->
accounts =
content
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.filter(fn line -> line != "" and not String.starts_with?(line, "#") end)
|> Enum.flat_map(fn line ->
case String.split(line, ",") do
[pk, sk | _] ->
pk = String.trim(pk)
sk = String.trim(sk)
if String.length(pk) > 8 and String.length(sk) > 8 do
[{pk, sk}]
else
[]
end
_ -> []
end
end)
case Enum.at(accounts, account_index) do
nil -> :error
creds -> {:ok, creds}
end
_ -> :error
end
end
defp get_api_keys do
home = System.get_env("HOME") || "."
home_csv = Path.join([home, ".unsandbox", "accounts.csv"])
# Priority 1: --account N (stored in process dict by main/1)
case Process.get(:account_index) do
nil ->
# Priority 2: environment variables
public_key = System.get_env("UNSANDBOX_PUBLIC_KEY")
secret_key = System.get_env("UNSANDBOX_SECRET_KEY")
api_key = System.get_env("UNSANDBOX_API_KEY")
cond do
public_key && secret_key ->
{public_key, secret_key}
api_key ->
{api_key, nil}
true ->
# Priority 3: ~/.unsandbox/accounts.csv (or UNSANDBOX_ACCOUNT index)
default_index =
case System.get_env("UNSANDBOX_ACCOUNT") do
nil -> 0
s -> case Integer.parse(s) do {n, ""} -> n; _ -> 0 end
end
case load_credentials_from_csv(home_csv, default_index) do
{:ok, {pk, sk}} -> {pk, sk}
:error ->
# Priority 4: ./accounts.csv
case load_credentials_from_csv("accounts.csv", default_index) do
{:ok, {pk, sk}} -> {pk, sk}
:error ->
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
end
account_index ->
# Priority 1: --account N -> accounts.csv
case load_credentials_from_csv(home_csv, account_index) do
{:ok, {pk, sk}} -> {pk, sk}
:error ->
case load_credentials_from_csv("accounts.csv", account_index) do
{:ok, {pk, sk}} -> {pk, sk}
:error ->
IO.puts(:stderr, "Error: No credentials found for account index #{account_index} in accounts.csv")
System.halt(1)
end
end
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
# Handle 428 sudo OTP challenge - prompts user for OTP and retries the request
defp handle_sudo_challenge(response, method, endpoint, body) do
challenge_id = extract_json_value(response, "challenge_id")
IO.puts(:stderr, "#{@yellow}Confirmation required. Check your email for a one-time code.#{@reset}")
IO.write(:stderr, "Enter OTP: ")
otp = IO.gets("") |> String.trim()
if otp == "" do
IO.puts(:stderr, "#{@red}Error: Operation cancelled#{@reset}")
{:error, :cancelled}
else
# Retry the request with sudo headers
{public_key, secret_key} = get_api_keys()
body_str = body || ""
headers = build_auth_headers(public_key, secret_key, method, endpoint, body_str)
# Add sudo headers
sudo_headers = ["-H", "X-Sudo-OTP: #{otp}"]
sudo_headers = if challenge_id do
sudo_headers ++ ["-H", "X-Sudo-Challenge: #{challenge_id}"]
else
sudo_headers
end
args = case method do
"DELETE" ->
["-s", "-X", "DELETE", "https://api.unsandbox.com#{endpoint}"] ++ headers ++ sudo_headers
"POST" ->
tmp_file = "/tmp/un_ex_#{:rand.uniform(999999)}.json"
File.write!(tmp_file, body_str)
result = ["-s", "-X", "POST", "https://api.unsandbox.com#{endpoint}",
"-H", "Content-Type: application/json"] ++ headers ++ sudo_headers ++ ["-d", "@#{tmp_file}"]
result
_ ->
["-s", "https://api.unsandbox.com#{endpoint}"] ++ headers ++ sudo_headers
end
{output, exit_code} = System.cmd("curl", args, stderr_to_stdout: true)
# Clean up temp file for POST requests
if method == "POST" do
tmp_file = "/tmp/un_ex_#{:rand.uniform(999999)}.json"
File.rm(tmp_file)
end
if exit_code == 0 and not String.contains?(output, "\"error\"") do
{:ok, output}
else
{:error, output}
end
end
end
# Curl with 428 handling for destructive operations
defp curl_delete_with_sudo(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", "-w", "\n%{http_code}",
"https://api.unsandbox.com#{endpoint}"] ++ headers
{output, _exit} = System.cmd("curl", args, stderr_to_stdout: true)
# Split response body and status code
lines = String.split(output, "\n")
{body_lines, [status_code]} = Enum.split(lines, -1)
body = Enum.join(body_lines, "\n")
http_code = String.to_integer(String.trim(status_code))
check_clock_drift(body)
if http_code == 428 do
handle_sudo_challenge(body, "DELETE", endpoint, nil)
else
{:ok, body, http_code}
end
end
defp curl_post_with_sudo(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", "-w", "\n%{http_code}",
"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)
# Split response body and status code
lines = String.split(output, "\n")
{body_lines, [status_code]} = Enum.split(lines, -1)
body = Enum.join(body_lines, "\n")
http_code = String.to_integer(String.trim(status_code))
check_clock_drift(body)
if http_code == 428 do
handle_sudo_challenge(body, "POST", endpoint, json)
else
{:ok, body, http_code}
end
end
end
Un.main(System.argv())