CLI
Cliente de linha de comando rápido para execução de código e sessões interativas. Mais de 42 linguagens, mais de 30 shells/REPLs.
Documentação Oficial OpenAPI Swagger ↗Início Rápido — Erlang
# Download + setup
curl -O https://git.unturf.com/engineering/unturf/un-inception/-/raw/main/clients/erlang/sync/src/un.erl && chmod +x un.erl && ln -sf un.erl un
export UNSANDBOX_PUBLIC_KEY="unsb-pk-xxxx-xxxx-xxxx-xxxx"
export UNSANDBOX_SECRET_KEY="unsb-sk-xxxxx-xxxxx-xxxxx-xxxxx"
# Run code
./un script.erlang
Baixar
Guia de Instalação →Características:
- 42+ languages - Python, JS, Go, Rust, C++, Java...
- Sessions - 30+ shells/REPLs, tmux persistence
- Files - Upload files, collect artifacts
- Services - Persistent containers with domains
- Snapshots - Point-in-time backups
- Images - Publish, share, transfer
Início Rápido de Integração ⚡
Adicione superpoderes unsandbox ao seu app Erlang existente:
curl -O https://git.unturf.com/engineering/unturf/un-inception/-/raw/main/clients/erlang/sync/src/un.erl
# Option A: Environment variables
export UNSANDBOX_PUBLIC_KEY="unsb-pk-xxxx-xxxx-xxxx-xxxx"
export UNSANDBOX_SECRET_KEY="unsb-sk-xxxxx-xxxxx-xxxxx-xxxxx"
# Option B: Config file (persistent)
mkdir -p ~/.unsandbox
echo "unsb-pk-xxxx-xxxx-xxxx-xxxx,unsb-sk-xxxxx-xxxxx-xxxxx-xxxxx" > ~/.unsandbox/accounts.csv
%% In your Erlang app:
-module(myapp).
-export([main/0]).
main() ->
Result = un:execute_code("erlang", "io:format(\"Hello from Erlang running on unsandbox!~n\")."),
io:format("~s", [maps:get(<<"stdout">>, Result)]). % Hello from Erlang running on unsandbox!
escript myapp.erl
a287403babaf001d2d40ff11503164ac
SHA256: 9c9a6b0f25fe5838da2f7c514045fbc9b7cccfb684af6d4b20ed489830532616
%% 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
#!/usr/bin/env escript
%%% @doc unsandbox.com Erlang SDK
%%%
%%% Full API with execution, sessions, services, snapshots, and images.
%%%
%%% Library Usage:
%%% ```
%%% %% Execute code synchronously
%%% Result = un:execute("python", "print(42)"),
%%% io:format("~s~n", [maps:get(stdout, Result)]).
%%%
%%% %% List sessions
%%% Sessions = un:session_list().
%%%
%%% %% Create a service
%%% ServiceId = un:service_create("myapp", #{ports => "8080"}).
%%% ```
%%%
%%% Authentication Priority:
%%% 1. Function arguments (PublicKey, SecretKey)
%%% 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
-define(API_BASE, "https://api.unsandbox.com").
-define(PORTAL_BASE, "https://unsandbox.com").
-define(VERSION, "4.2.0").
-define(LANGUAGES_CACHE_TTL, 3600).
%% ============================================================================
%% Utility Functions
%% ============================================================================
%% @doc Return the SDK version.
version() -> ?VERSION.
%% @doc Check API health.
health_check() ->
Cmd = "curl -s -o /dev/null -w '%{http_code}' " ++ ?API_BASE ++ "/health",
Result = os:cmd(Cmd),
string:trim(Result) == "200".
%% @doc Generate HMAC-SHA256 signature for a message.
hmac_sign(SecretKey, Message) ->
hmac_sha256(SecretKey, Message).
%% @doc Detect language from filename extension.
detect_language(Filename) ->
Ext = filename:extension(Filename),
ext_to_lang(Ext).
%% ============================================================================
%% Execution Functions (8)
%% ============================================================================
%% @doc Execute code synchronously.
execute(Language, Code) ->
execute(Language, Code, #{}).
execute(Language, Code, Opts) ->
Json = build_execute_json_full(Language, Code, Opts),
Response = api_post("/execute", Json, Opts),
parse_result(Response).
%% @doc Execute code asynchronously, returning a job ID.
execute_async(Language, Code) ->
execute_async(Language, Code, #{}).
execute_async(Language, Code, Opts) ->
Json = build_execute_json_full(Language, Code, Opts),
Response = api_post("/execute/async", Json, Opts),
extract_json_field(Response, "job_id").
%% @doc Wait for a job to complete and return the result.
wait_job(JobId) ->
wait_job(JobId, #{}).
wait_job(JobId, Opts) ->
MaxPolls = maps:get(max_polls, Opts, 100),
PollDelays = [300, 450, 700, 900, 650, 1600, 2000],
do_wait_job(JobId, PollDelays, 0, MaxPolls, Opts).
do_wait_job(JobId, _PollDelays, PollCount, MaxPolls, _Opts) when PollCount >= MaxPolls ->
#{success => false, stdout => "", stderr => "Max polls exceeded", exit_code => 1, job_id => JobId};
do_wait_job(JobId, PollDelays, PollCount, MaxPolls, Opts) ->
DelayIdx = min(PollCount, length(PollDelays) - 1),
Delay = lists:nth(DelayIdx + 1, PollDelays),
timer:sleep(Delay),
Job = get_job(JobId, Opts),
Status = maps:get(status, Job, "unknown"),
case lists:member(Status, ["completed", "failed", "timeout", "cancelled"]) of
true ->
Response = api_get("/jobs/" ++ JobId, Opts),
parse_result(Response);
false ->
do_wait_job(JobId, PollDelays, PollCount + 1, MaxPolls, Opts)
end.
%% @doc Get job status and details.
get_job(JobId) ->
get_job(JobId, #{}).
get_job(JobId, Opts) ->
Response = api_get("/jobs/" ++ JobId, Opts),
#{
id => JobId,
status => case extract_json_field(Response, "status") of "" -> "unknown"; S -> S end,
language => extract_json_field(Response, "language"),
created_at => extract_json_number(Response, "created_at"),
completed_at => extract_json_number(Response, "completed_at")
}.
%% @doc Cancel a running job.
cancel_job(JobId) ->
cancel_job(JobId, #{}).
cancel_job(JobId, Opts) ->
Response = api_delete("/jobs/" ++ JobId, Opts),
not_contains_error(Response).
%% @doc List all active jobs.
list_jobs() ->
list_jobs(#{}).
list_jobs(Opts) ->
api_get("/jobs", Opts).
%% @doc Get list of supported languages.
get_languages() ->
get_languages(#{}).
get_languages(Opts) ->
case load_languages_cache() of
undefined ->
Response = api_get("/languages", Opts),
Langs = extract_json_array(Response, "languages"),
save_languages_cache(Langs),
Langs;
CachedLanguages ->
CachedLanguages
end.
%% ============================================================================
%% Session Functions (9)
%% ============================================================================
%% @doc List all sessions.
session_list() -> session_list(#{}).
session_list(Opts) -> api_get("/sessions", Opts).
%% @doc Get session details.
session_get(SessionId) -> session_get(SessionId, #{}).
session_get(SessionId, Opts) ->
Response = api_get("/sessions/" ++ SessionId, Opts),
#{
id => SessionId,
status => case extract_json_field(Response, "status") of "" -> "unknown"; S -> S end,
container_name => extract_json_field(Response, "container_name"),
network_mode => extract_json_field(Response, "network_mode"),
vcpu => extract_json_number(Response, "vcpu"),
created_at => extract_json_number(Response, "created_at")
}.
%% @doc Create a new session.
session_create() -> session_create(#{}).
session_create(Opts) ->
Shell = maps:get(shell, Opts, "bash"),
Network = maps:get(network, Opts, undefined),
Vcpu = maps:get(vcpu, Opts, undefined),
NetworkJson = case Network of undefined -> ""; N -> ",\"network\":\"" ++ N ++ "\"" end,
VcpuJson = case Vcpu of undefined -> ""; V -> ",\"vcpu\":" ++ integer_to_list(V) end,
Json = "{\"shell\":\"" ++ Shell ++ "\"" ++ NetworkJson ++ VcpuJson ++ "}",
Response = api_post("/sessions", Json, Opts),
extract_json_field(Response, "id").
%% @doc Destroy a session.
session_destroy(SessionId) -> session_destroy(SessionId, #{}).
session_destroy(SessionId, Opts) ->
Response = api_delete("/sessions/" ++ SessionId, Opts),
not_contains_error(Response).
%% @doc Freeze a session.
session_freeze(SessionId) -> session_freeze(SessionId, #{}).
session_freeze(SessionId, Opts) ->
Response = api_post("/sessions/" ++ SessionId ++ "/freeze", "{}", Opts),
not_contains_error(Response).
%% @doc Unfreeze a session.
session_unfreeze(SessionId) -> session_unfreeze(SessionId, #{}).
session_unfreeze(SessionId, Opts) ->
Response = api_post("/sessions/" ++ SessionId ++ "/unfreeze", "{}", Opts),
not_contains_error(Response).
%% @doc Boost session resources.
session_boost(SessionId, Vcpu) -> session_boost(SessionId, Vcpu, #{}).
session_boost(SessionId, Vcpu, Opts) ->
Json = "{\"vcpu\":" ++ integer_to_list(Vcpu) ++ "}",
Response = api_patch("/sessions/" ++ SessionId, Json, Opts),
not_contains_error(Response).
%% @doc Unboost session.
session_unboost(SessionId) -> session_unboost(SessionId, #{}).
session_unboost(SessionId, Opts) ->
Response = api_patch("/sessions/" ++ SessionId, "{\"vcpu\":1}", Opts),
not_contains_error(Response).
%% @doc Execute a command in a session.
session_execute(SessionId, Command) -> session_execute(SessionId, Command, #{}).
session_execute(SessionId, Command, Opts) ->
Json = "{\"command\":\"" ++ escape_json(Command) ++ "\"}",
Response = api_post("/sessions/" ++ SessionId ++ "/execute", Json, Opts),
parse_result(Response).
%% ============================================================================
%% Service Functions (17)
%% ============================================================================
%% @doc List all services.
service_list() -> service_list(#{}).
service_list(Opts) -> api_get("/services", Opts).
%% @doc Get service details.
service_get(ServiceId) -> service_get(ServiceId, #{}).
service_get(ServiceId, Opts) ->
Response = api_get("/services/" ++ ServiceId, Opts),
#{
id => ServiceId,
name => extract_json_field(Response, "name"),
status => case extract_json_field(Response, "status") of "" -> "unknown"; S -> S end,
ports => extract_json_field(Response, "ports"),
domains => extract_json_field(Response, "domains"),
vcpu => extract_json_number(Response, "vcpu"),
locked => extract_json_field(Response, "locked") == "true",
unfreeze_on_demand => extract_json_field(Response, "unfreeze_on_demand") == "true",
created_at => extract_json_number(Response, "created_at")
}.
%% @doc Create a new service.
service_create(Name) -> service_create(Name, #{}).
service_create(Name, Opts) ->
Ports = maps:get(ports, Opts, undefined),
Bootstrap = maps:get(bootstrap, Opts, undefined),
Network = maps:get(network, Opts, undefined),
Vcpu = maps:get(vcpu, Opts, undefined),
PortsJson = case Ports of undefined -> ""; P -> ",\"ports\":[" ++ P ++ "]" end,
BootstrapJson = case Bootstrap of undefined -> ""; B -> ",\"bootstrap\":\"" ++ escape_json(B) ++ "\"" end,
NetworkJson = case Network of undefined -> ""; N -> ",\"network\":\"" ++ N ++ "\"" end,
VcpuJson = case Vcpu of undefined -> ""; V -> ",\"vcpu\":" ++ integer_to_list(V) end,
Json = "{\"name\":\"" ++ escape_json(Name) ++ "\"" ++ PortsJson ++ BootstrapJson ++ NetworkJson ++ VcpuJson ++ "}",
Response = api_post("/services", Json, Opts),
extract_json_field(Response, "id").
%% @doc Destroy a service.
service_destroy(ServiceId) -> service_destroy(ServiceId, #{}).
service_destroy(ServiceId, Opts) ->
Response = api_delete("/services/" ++ ServiceId, Opts),
not_contains_error(Response).
%% @doc Freeze a service.
service_freeze(ServiceId) -> service_freeze(ServiceId, #{}).
service_freeze(ServiceId, Opts) ->
Response = api_post("/services/" ++ ServiceId ++ "/freeze", "{}", Opts),
not_contains_error(Response).
%% @doc Unfreeze a service.
service_unfreeze(ServiceId) -> service_unfreeze(ServiceId, #{}).
service_unfreeze(ServiceId, Opts) ->
Response = api_post("/services/" ++ ServiceId ++ "/unfreeze", "{}", Opts),
not_contains_error(Response).
%% @doc Lock a service.
service_lock(ServiceId) -> service_lock(ServiceId, #{}).
service_lock(ServiceId, Opts) ->
Response = api_post("/services/" ++ ServiceId ++ "/lock", "{}", Opts),
not_contains_error(Response).
%% @doc Unlock a service.
service_unlock(ServiceId) -> service_unlock(ServiceId, #{}).
service_unlock(ServiceId, Opts) ->
Response = api_post("/services/" ++ ServiceId ++ "/unlock", "{}", Opts),
not_contains_error(Response).
%% @doc Set unfreeze-on-demand for a service.
service_set_unfreeze_on_demand(ServiceId, Enabled) -> service_set_unfreeze_on_demand(ServiceId, Enabled, #{}).
service_set_unfreeze_on_demand(ServiceId, Enabled, Opts) ->
EnabledStr = if Enabled -> "true"; true -> "false" end,
Json = "{\"unfreeze_on_demand\":" ++ EnabledStr ++ "}",
Response = api_patch("/services/" ++ ServiceId, Json, Opts),
not_contains_error(Response).
%% @doc Redeploy a service.
service_redeploy(ServiceId) -> service_redeploy(ServiceId, undefined, #{}).
service_redeploy(ServiceId, Bootstrap) -> service_redeploy(ServiceId, Bootstrap, #{}).
service_redeploy(ServiceId, Bootstrap, Opts) ->
BootstrapJson = case Bootstrap of undefined -> ""; B -> "\"bootstrap\":\"" ++ escape_json(B) ++ "\"" end,
Json = "{" ++ BootstrapJson ++ "}",
Response = api_post("/services/" ++ ServiceId ++ "/redeploy", Json, Opts),
not_contains_error(Response).
%% @doc Get service logs.
service_logs(ServiceId) -> service_logs(ServiceId, #{}).
service_logs(ServiceId, Opts) ->
AllLogs = maps:get(all_logs, Opts, false),
Endpoint = if AllLogs -> "/services/" ++ ServiceId ++ "/logs?all=true"; true -> "/services/" ++ ServiceId ++ "/logs" end,
api_get(Endpoint, Opts).
%% @doc Execute a command in a service.
service_execute(ServiceId, Command) -> service_execute(ServiceId, Command, #{}).
service_execute(ServiceId, Command, Opts) ->
TimeoutMs = maps:get(timeout_ms, Opts, undefined),
TimeoutJson = case TimeoutMs of undefined -> ""; T -> ",\"timeout_ms\":" ++ integer_to_list(T) end,
Json = "{\"command\":\"" ++ escape_json(Command) ++ "\"" ++ TimeoutJson ++ "}",
Response = api_post("/services/" ++ ServiceId ++ "/execute", Json, Opts),
parse_result(Response).
%% @doc Get service environment vault.
service_env_get(ServiceId) -> service_env_get(ServiceId, #{}).
service_env_get(ServiceId, Opts) ->
api_get("/services/" ++ ServiceId ++ "/env", Opts).
%% @doc Set service environment vault.
service_env_set(ServiceId, EnvContent) -> service_env_set(ServiceId, EnvContent, #{}).
service_env_set(ServiceId, EnvContent, Opts) ->
api_put_text("/services/" ++ ServiceId ++ "/env", EnvContent, Opts).
%% @doc Delete service environment vault.
service_env_delete(ServiceId) -> service_env_delete(ServiceId, #{}).
service_env_delete(ServiceId, Opts) ->
Response = api_delete("/services/" ++ ServiceId ++ "/env", Opts),
not_contains_error(Response).
%% @doc Export service environment vault.
service_env_export(ServiceId) -> service_env_export(ServiceId, #{}).
service_env_export(ServiceId, Opts) ->
Response = api_post("/services/" ++ ServiceId ++ "/env/export", "{}", Opts),
extract_json_field(Response, "content").
%% @doc Resize a service.
service_resize(ServiceId, Vcpu) -> service_resize(ServiceId, Vcpu, #{}).
service_resize(ServiceId, Vcpu, Opts) ->
Json = "{\"vcpu\":" ++ integer_to_list(Vcpu) ++ "}",
Response = api_patch("/services/" ++ ServiceId, Json, Opts),
not_contains_error(Response).
%% ============================================================================
%% Snapshot Functions (9)
%% ============================================================================
%% @doc List all snapshots.
snapshot_list() -> snapshot_list(#{}).
snapshot_list(Opts) -> api_get("/snapshots", Opts).
%% @doc Get snapshot details.
snapshot_get(SnapshotId) -> snapshot_get(SnapshotId, #{}).
snapshot_get(SnapshotId, Opts) ->
Response = api_get("/snapshots/" ++ SnapshotId, Opts),
#{
id => SnapshotId,
name => extract_json_field(Response, "name"),
type => case extract_json_field(Response, "type") of "" -> "unknown"; T -> T end,
source_id => extract_json_field(Response, "source_id"),
hot => extract_json_field(Response, "hot") == "true",
locked => extract_json_field(Response, "locked") == "true",
created_at => extract_json_number(Response, "created_at"),
size_bytes => extract_json_number(Response, "size_bytes")
}.
%% @doc Create a snapshot of a session.
snapshot_session(SessionId) -> snapshot_session(SessionId, #{}).
snapshot_session(SessionId, Opts) ->
Name = maps:get(name, Opts, undefined),
Hot = maps:get(hot, Opts, false),
NameJson = case Name of undefined -> ""; N -> "\"name\":\"" ++ escape_json(N) ++ "\"," end,
HotJson = if Hot -> "\"hot\":true"; true -> "\"hot\":false" end,
Json = "{" ++ NameJson ++ HotJson ++ "}",
Response = api_post("/sessions/" ++ SessionId ++ "/snapshot", Json, Opts),
extract_json_field(Response, "id").
%% @doc Create a snapshot of a service.
snapshot_service(ServiceId) -> snapshot_service(ServiceId, #{}).
snapshot_service(ServiceId, Opts) ->
Name = maps:get(name, Opts, undefined),
Hot = maps:get(hot, Opts, false),
NameJson = case Name of undefined -> ""; N -> "\"name\":\"" ++ escape_json(N) ++ "\"," end,
HotJson = if Hot -> "\"hot\":true"; true -> "\"hot\":false" end,
Json = "{" ++ NameJson ++ HotJson ++ "}",
Response = api_post("/services/" ++ ServiceId ++ "/snapshot", Json, Opts),
extract_json_field(Response, "id").
%% @doc Restore from a snapshot.
snapshot_restore(SnapshotId) -> snapshot_restore(SnapshotId, #{}).
snapshot_restore(SnapshotId, Opts) ->
Response = api_post("/snapshots/" ++ SnapshotId ++ "/restore", "{}", Opts),
extract_json_field(Response, "id").
%% @doc Delete a snapshot.
snapshot_delete(SnapshotId) -> snapshot_delete(SnapshotId, #{}).
snapshot_delete(SnapshotId, Opts) ->
Response = api_delete("/snapshots/" ++ SnapshotId, Opts),
not_contains_error(Response).
%% @doc Lock a snapshot.
snapshot_lock(SnapshotId) -> snapshot_lock(SnapshotId, #{}).
snapshot_lock(SnapshotId, Opts) ->
Response = api_post("/snapshots/" ++ SnapshotId ++ "/lock", "{}", Opts),
not_contains_error(Response).
%% @doc Unlock a snapshot.
snapshot_unlock(SnapshotId) -> snapshot_unlock(SnapshotId, #{}).
snapshot_unlock(SnapshotId, Opts) ->
Response = api_post("/snapshots/" ++ SnapshotId ++ "/unlock", "{}", Opts),
not_contains_error(Response).
%% @doc Clone a snapshot to create a new session or service.
snapshot_clone(SnapshotId, Opts) ->
CloneType = maps:get(type, Opts),
Name = maps:get(name, Opts, undefined),
Ports = maps:get(ports, Opts, undefined),
Shell = maps:get(shell, Opts, undefined),
TypeJson = "\"type\":\"" ++ CloneType ++ "\"",
NameJson = case Name of undefined -> ""; N -> ",\"name\":\"" ++ escape_json(N) ++ "\"" end,
PortsJson = case Ports of undefined -> ""; P -> ",\"ports\":[" ++ P ++ "]" end,
ShellJson = case Shell of undefined -> ""; S -> ",\"shell\":\"" ++ S ++ "\"" end,
Json = "{" ++ TypeJson ++ NameJson ++ PortsJson ++ ShellJson ++ "}",
Response = api_post("/snapshots/" ++ SnapshotId ++ "/clone", Json, Opts),
extract_json_field(Response, "id").
%% ============================================================================
%% Image Functions (13)
%% ============================================================================
%% @doc List images.
image_list() -> image_list(#{}).
image_list(Opts) ->
Filter = maps:get(filter, Opts, undefined),
Endpoint = case Filter of undefined -> "/images"; F -> "/images?filter=" ++ F end,
api_get(Endpoint, Opts).
%% @doc Get image details.
image_get(ImageId) -> image_get(ImageId, #{}).
image_get(ImageId, Opts) ->
Response = api_get("/images/" ++ ImageId, Opts),
#{
id => ImageId,
name => extract_json_field(Response, "name"),
description => extract_json_field(Response, "description"),
visibility => case extract_json_field(Response, "visibility") of "" -> "private"; V -> V end,
source_type => extract_json_field(Response, "source_type"),
source_id => extract_json_field(Response, "source_id"),
locked => extract_json_field(Response, "locked") == "true",
created_at => extract_json_number(Response, "created_at"),
size_bytes => extract_json_number(Response, "size_bytes")
}.
%% @doc Publish an image.
image_publish(SourceType, SourceId) -> image_publish(SourceType, SourceId, #{}).
image_publish(SourceType, SourceId, Opts) ->
Name = maps:get(name, Opts, undefined),
Description = maps:get(description, Opts, undefined),
NameJson = case Name of undefined -> ""; N -> ",\"name\":\"" ++ escape_json(N) ++ "\"" end,
DescJson = case Description of undefined -> ""; D -> ",\"description\":\"" ++ escape_json(D) ++ "\"" end,
Json = "{\"source_type\":\"" ++ SourceType ++ "\",\"source_id\":\"" ++ SourceId ++ "\"" ++ NameJson ++ DescJson ++ "}",
Response = api_post("/images/publish", Json, Opts),
extract_json_field(Response, "id").
%% @doc Delete an image.
image_delete(ImageId) -> image_delete(ImageId, #{}).
image_delete(ImageId, Opts) ->
Response = api_delete("/images/" ++ ImageId, Opts),
not_contains_error(Response).
%% @doc Lock an image.
image_lock(ImageId) -> image_lock(ImageId, #{}).
image_lock(ImageId, Opts) ->
Response = api_post("/images/" ++ ImageId ++ "/lock", "{}", Opts),
not_contains_error(Response).
%% @doc Unlock an image.
image_unlock(ImageId) -> image_unlock(ImageId, #{}).
image_unlock(ImageId, Opts) ->
Response = api_post("/images/" ++ ImageId ++ "/unlock", "{}", Opts),
not_contains_error(Response).
%% @doc Set image visibility.
image_set_visibility(ImageId, Visibility) -> image_set_visibility(ImageId, Visibility, #{}).
image_set_visibility(ImageId, Visibility, Opts) ->
Json = "{\"visibility\":\"" ++ Visibility ++ "\"}",
Response = api_post("/images/" ++ ImageId ++ "/visibility", Json, Opts),
not_contains_error(Response).
%% @doc Grant access to an image.
image_grant_access(ImageId, TrustedApiKey) -> image_grant_access(ImageId, TrustedApiKey, #{}).
image_grant_access(ImageId, TrustedApiKey, Opts) ->
Json = "{\"api_key\":\"" ++ TrustedApiKey ++ "\"}",
Response = api_post("/images/" ++ ImageId ++ "/access/grant", Json, Opts),
not_contains_error(Response).
%% @doc Revoke access to an image.
image_revoke_access(ImageId, TrustedApiKey) -> image_revoke_access(ImageId, TrustedApiKey, #{}).
image_revoke_access(ImageId, TrustedApiKey, Opts) ->
Json = "{\"api_key\":\"" ++ TrustedApiKey ++ "\"}",
Response = api_post("/images/" ++ ImageId ++ "/access/revoke", Json, Opts),
not_contains_error(Response).
%% @doc List trusted API keys for an image.
image_list_trusted(ImageId) -> image_list_trusted(ImageId, #{}).
image_list_trusted(ImageId, Opts) ->
Response = api_get("/images/" ++ ImageId ++ "/access", Opts),
extract_json_array(Response, "trusted_keys").
%% @doc Transfer image ownership.
image_transfer(ImageId, ToApiKey) -> image_transfer(ImageId, ToApiKey, #{}).
image_transfer(ImageId, ToApiKey, Opts) ->
Json = "{\"to_api_key\":\"" ++ ToApiKey ++ "\"}",
Response = api_post("/images/" ++ ImageId ++ "/transfer", Json, Opts),
not_contains_error(Response).
%% @doc Spawn a service from an image.
image_spawn(ImageId) -> image_spawn(ImageId, #{}).
image_spawn(ImageId, Opts) ->
Name = maps:get(name, Opts, undefined),
Ports = maps:get(ports, Opts, undefined),
Bootstrap = maps:get(bootstrap, Opts, undefined),
Network = maps:get(network, Opts, undefined),
NameJson = case Name of undefined -> ""; N -> "\"name\":\"" ++ escape_json(N) ++ "\"" end,
PortsJson = case Ports of undefined -> ""; P -> (if Name =/= undefined -> ","; true -> "" end) ++ "\"ports\":[" ++ P ++ "]" end,
BootstrapJson = case Bootstrap of undefined -> ""; B -> ",\"bootstrap\":\"" ++ escape_json(B) ++ "\"" end,
NetworkJson = case Network of undefined -> ""; Nn -> ",\"network\":\"" ++ Nn ++ "\"" end,
Json = "{" ++ NameJson ++ PortsJson ++ BootstrapJson ++ NetworkJson ++ "}",
Response = api_post("/images/" ++ ImageId ++ "/spawn", Json, Opts),
extract_json_field(Response, "id").
%% @doc Clone an image.
image_clone(ImageId) -> image_clone(ImageId, #{}).
image_clone(ImageId, Opts) ->
Name = maps:get(name, Opts, undefined),
Description = maps:get(description, Opts, undefined),
NameJson = case Name of undefined -> ""; N -> "\"name\":\"" ++ escape_json(N) ++ "\"" end,
DescJson = case Description of undefined -> ""; D -> (if Name =/= undefined -> ","; true -> "" end) ++ "\"description\":\"" ++ escape_json(D) ++ "\"" end,
Json = "{" ++ NameJson ++ DescJson ++ "}",
Response = api_post("/images/" ++ ImageId ++ "/clone", Json, Opts),
extract_json_field(Response, "id").
%% ============================================================================
%% PaaS Logs Functions (2)
%% ============================================================================
%% @doc Fetch batch logs from portal.
logs_fetch() -> logs_fetch(#{}).
logs_fetch(Opts) ->
Source = maps:get(source, Opts, "all"),
Lines = maps:get(lines, Opts, 100),
Since = maps:get(since, Opts, "1h"),
Grep = maps:get(grep, Opts, undefined),
GrepParam = case Grep of undefined -> ""; G -> "&grep=" ++ http_uri:encode(G) end,
api_get("/logs?source=" ++ Source ++ "&lines=" ++ integer_to_list(Lines) ++ "&since=" ++ Since ++ GrepParam, Opts).
%% @doc Stream logs (simplified polling implementation).
logs_stream(Callback) -> logs_stream(Callback, #{}).
logs_stream(Callback, Opts) ->
Source = maps:get(source, Opts, "all"),
Grep = maps:get(grep, Opts, undefined),
Interval = maps:get(interval, Opts, 5000),
GrepParam = case Grep of undefined -> ""; G -> "&grep=" ++ http_uri:encode(G) end,
logs_stream_loop(Source, GrepParam, Callback, Interval, Opts).
logs_stream_loop(Source, GrepParam, Callback, Interval, Opts) ->
Response = api_get("/logs?source=" ++ Source ++ "&lines=50&since=10s" ++ GrepParam, Opts),
Callback(Source, Response),
timer:sleep(Interval),
logs_stream_loop(Source, GrepParam, Callback, Interval, Opts).
%% ============================================================================
%% Key Validation (1)
%% ============================================================================
%% @doc Validate API keys.
validate_keys() -> validate_keys(#{}).
validate_keys(Opts) ->
Response = portal_post("/keys/validate", "{}", Opts),
#{
valid => extract_json_field(Response, "status") == "valid",
tier => extract_json_field(Response, "tier"),
rate_limit_per_minute => extract_json_number(Response, "rate_per_minute"),
concurrency_limit => extract_json_number(Response, "concurrency"),
expires_at => extract_json_number(Response, "expires_at")
}.
%% ============================================================================
%% Private API Functions
%% ============================================================================
api_get(Endpoint, Opts) ->
{PublicKey, SecretKey} = get_api_keys_from_opts(Opts),
AuthHeaders = build_auth_headers(PublicKey, SecretKey, "GET", Endpoint, ""),
Cmd = "curl -s " ++ ?API_BASE ++ Endpoint ++ AuthHeaders,
Result = os:cmd(Cmd),
check_clock_drift_error(Result),
Result.
api_post(Endpoint, Json, Opts) ->
{PublicKey, SecretKey} = get_api_keys_from_opts(Opts),
TmpFile = write_temp_file(Json),
AuthHeaders = build_auth_headers(PublicKey, SecretKey, "POST", Endpoint, Json),
Cmd = "curl -s -X POST " ++ ?API_BASE ++ Endpoint ++ " -H 'Content-Type: application/json'" ++ AuthHeaders ++ " -d @" ++ TmpFile,
Result = os:cmd(Cmd),
file:delete(TmpFile),
check_clock_drift_error(Result),
Result.
api_delete(Endpoint, Opts) ->
{PublicKey, SecretKey} = get_api_keys_from_opts(Opts),
AuthHeaders = build_auth_headers(PublicKey, SecretKey, "DELETE", Endpoint, ""),
Cmd = "curl -s -X DELETE " ++ ?API_BASE ++ Endpoint ++ AuthHeaders,
Result = os:cmd(Cmd),
check_clock_drift_error(Result),
Result.
api_patch(Endpoint, Json, Opts) ->
{PublicKey, SecretKey} = get_api_keys_from_opts(Opts),
TmpFile = write_temp_file(Json),
AuthHeaders = build_auth_headers(PublicKey, SecretKey, "PATCH", Endpoint, Json),
Cmd = "curl -s -X PATCH " ++ ?API_BASE ++ Endpoint ++ " -H 'Content-Type: application/json'" ++ AuthHeaders ++ " -d @" ++ TmpFile,
Result = os:cmd(Cmd),
file:delete(TmpFile),
check_clock_drift_error(Result),
Result.
api_put_text(Endpoint, Body, Opts) ->
{PublicKey, SecretKey} = get_api_keys_from_opts(Opts),
TmpFile = write_temp_file(Body),
AuthHeaders = build_auth_headers(PublicKey, SecretKey, "PUT", Endpoint, Body),
Cmd = "curl -s -o /dev/null -w '%{http_code}' -X PUT " ++ ?API_BASE ++ Endpoint ++ " -H 'Content-Type: text/plain'" ++ AuthHeaders ++ " -d @" ++ TmpFile,
Result = os:cmd(Cmd),
file:delete(TmpFile),
StatusCode = list_to_integer(string:trim(Result)),
StatusCode >= 200 andalso StatusCode < 300.
portal_post(Endpoint, Json, Opts) ->
{PublicKey, SecretKey} = get_api_keys_from_opts(Opts),
TmpFile = write_temp_file(Json),
AuthHeaders = build_auth_headers(PublicKey, SecretKey, "POST", Endpoint, Json),
Cmd = "curl -s -X POST " ++ ?PORTAL_BASE ++ Endpoint ++ " -H 'Content-Type: application/json'" ++ AuthHeaders ++ " -d @" ++ TmpFile,
Result = os:cmd(Cmd),
file:delete(TmpFile),
check_clock_drift_error(Result),
Result.
get_api_keys_from_opts(Opts) ->
case {maps:get(public_key, Opts, undefined), maps:get(secret_key, Opts, undefined)} of
{Pk, Sk} when Pk =/= undefined, Sk =/= undefined -> {Pk, Sk};
_ -> get_api_keys()
end.
build_execute_json_full(Language, Code, Opts) ->
Network = maps:get(network, Opts, undefined),
Vcpu = maps:get(vcpu, Opts, undefined),
Ttl = maps:get(ttl, Opts, undefined),
ReturnArtifacts = maps:get(return_artifacts, Opts, false),
NetworkJson = case Network of undefined -> ""; N -> ",\"network\":\"" ++ N ++ "\"" end,
VcpuJson = case Vcpu of undefined -> ""; V -> ",\"vcpu\":" ++ integer_to_list(V) end,
TtlJson = case Ttl of undefined -> ""; T -> ",\"ttl\":" ++ integer_to_list(T) end,
ArtifactsJson = if ReturnArtifacts -> ",\"return_artifacts\":true"; true -> "" end,
"{\"language\":\"" ++ Language ++ "\",\"code\":\"" ++ escape_json(Code) ++ "\"" ++ NetworkJson ++ VcpuJson ++ TtlJson ++ ArtifactsJson ++ "}".
parse_result(Response) ->
ExitCode = case extract_json_number(Response, "exit_code") of 0 -> 0; N when is_integer(N) -> N; _ -> 0 end,
#{
success => ExitCode == 0,
stdout => case extract_json_field(Response, "stdout") of "" -> ""; S -> S end,
stderr => case extract_json_field(Response, "stderr") of "" -> ""; S -> S end,
exit_code => ExitCode,
job_id => extract_json_field(Response, "job_id"),
language => extract_json_field(Response, "language"),
execution_time => undefined
}.
not_contains_error(Response) ->
string:str(Response, "\"error\"") == 0.
%% ============================================================================
%% CLI Entry Point
%% ============================================================================
main(RawArgs) ->
%% Strip --account N from args and store index in process dict before dispatch
{AccountIndex, Args} = extract_account_arg(RawArgs, undefined, []),
case AccountIndex of
undefined -> ok;
N -> erlang:put(account_index, N)
end,
dispatch(Args).
dispatch([]) ->
io:format("Usage: un.erl [--account N] [options] <source_file>~n"),
io:format(" un.erl [--account N] session [options]~n"),
io:format(" un.erl [--account N] service [options]~n"),
io:format(" un.erl [--account N] snapshot [options]~n"),
io:format(" un.erl [--account N] image [options]~n"),
io:format(" un.erl [--account N] key [options]~n"),
io:format(" un.erl languages [--json]~n"),
io:format("~nGlobal options:~n"),
io:format(" --account N Use accounts.csv row N (bypasses env vars)~n"),
halt(1);
dispatch(["session" | Rest]) ->
session_command(Rest);
dispatch(["service" | Rest]) ->
service_command(Rest);
dispatch(["snapshot" | Rest]) ->
snapshot_command(Rest);
dispatch(["image" | Rest]) ->
image_command(Rest);
dispatch(["key" | Rest]) ->
key_command(Rest);
dispatch(["languages" | Rest]) ->
languages_command(Rest);
dispatch(Args) ->
execute_command(Args).
%% Strip --account N from argument list, return {Index | undefined, RestArgs}
extract_account_arg([], Acc, RestAcc) ->
{Acc, lists:reverse(RestAcc)};
extract_account_arg(["--account", NStr | Rest], _Acc, RestAcc) ->
N = try list_to_integer(NStr) catch _:_ ->
io:format("Error: --account requires an integer argument~n"),
halt(1)
end,
extract_account_arg(Rest, N, RestAcc);
extract_account_arg([Arg | Rest], Acc, RestAcc) ->
extract_account_arg(Rest, Acc, [Arg | RestAcc]).
%% Execute command
execute_command(Args) ->
{File, _Opts} = parse_exec_args(Args, #{file => undefined}),
case File of
undefined ->
io:format("Error: No source file specified~n"),
halt(1);
_ ->
ApiKey = get_api_key(),
Ext = filename:extension(File),
case ext_to_lang(Ext) of
{ok, Language} ->
case file:read_file(File) of
{ok, CodeBin} ->
Code = binary_to_list(CodeBin),
Json = build_json(Language, Code),
TmpFile = write_temp_file(Json),
Response = curl_post(ApiKey, "/execute", TmpFile),
file:delete(TmpFile),
io:format("~s~n", [Response]);
{error, Reason} ->
io:format("Error reading file: ~p~n", [Reason]),
halt(1)
end;
{error, Ext} ->
io:format("Error: Unknown extension: ~s~n", [Ext]),
halt(1)
end
end.
%% Session command
session_command(["--list" | _]) ->
ApiKey = get_api_key(),
Response = curl_get(ApiKey, "/sessions"),
io:format("~s~n", [Response]);
session_command(["--kill", SessionId | _]) ->
ApiKey = get_api_key(),
_ = curl_delete(ApiKey, "/sessions/" ++ SessionId),
io:format("\033[32mSession terminated: ~s\033[0m~n", [SessionId]);
session_command(Args) ->
validate_session_args(Args),
ApiKey = get_api_key(),
Shell = get_shell_opt(Args, "bash"),
InputFiles = get_input_files(Args),
InputFilesJson = build_input_files_json(InputFiles),
Json = "{\"shell\":\"" ++ Shell ++ "\"" ++ InputFilesJson ++ "}",
TmpFile = write_temp_file(Json),
Response = curl_post(ApiKey, "/sessions", TmpFile),
file:delete(TmpFile),
io:format("\033[33mSession created (WebSocket required)\033[0m~n"),
io:format("~s~n", [Response]).
%% Session snapshot commands
session_command(["--snapshot", SessionId | Rest]) ->
ApiKey = get_api_key(),
Name = get_snapshot_name(Rest),
Hot = has_hot_flag(Rest),
Json = build_snapshot_json(Name, Hot),
TmpFile = write_temp_file(Json),
Response = curl_post(ApiKey, "/sessions/" ++ SessionId ++ "/snapshot", TmpFile),
file:delete(TmpFile),
io:format("\033[32mSnapshot created\033[0m~n"),
io:format("~s~n", [Response]);
session_command(["--restore", SnapshotId | _Rest]) ->
% --restore takes snapshot ID directly, calls /snapshots/:id/restore
ApiKey = get_api_key(),
TmpFile = write_temp_file("{}"),
Response = curl_post(ApiKey, "/snapshots/" ++ SnapshotId ++ "/restore", TmpFile),
file:delete(TmpFile),
io:format("\033[32mSession restored from snapshot\033[0m~n"),
io:format("~s~n", [Response]);
validate_session_args([]) -> ok;
validate_session_args(["--shell", _ | Rest]) -> validate_session_args(Rest);
validate_session_args(["-s", _ | Rest]) -> validate_session_args(Rest);
validate_session_args(["-f", _ | Rest]) -> validate_session_args(Rest);
validate_session_args(["-n", _ | Rest]) -> validate_session_args(Rest);
validate_session_args(["-v", _ | Rest]) -> validate_session_args(Rest);
validate_session_args(["--snapshot", _ | Rest]) -> validate_session_args(Rest);
validate_session_args(["--restore", _ | Rest]) -> validate_session_args(Rest);
validate_session_args(["--from", _ | Rest]) -> validate_session_args(Rest);
validate_session_args(["--snapshot-name", _ | Rest]) -> validate_session_args(Rest);
validate_session_args(["--hot" | Rest]) -> validate_session_args(Rest);
validate_session_args([Arg | _]) ->
case Arg of
[$- | _] ->
io:format(standard_error, "Unknown option: ~s~n", [Arg]),
io:format(standard_error, "Usage: un.erl session [options]~n", []),
halt(1);
_ ->
validate_session_args([])
end.
get_snapshot_name([]) -> undefined;
get_snapshot_name(["--snapshot-name", Name | _]) -> Name;
get_snapshot_name([_ | Rest]) -> get_snapshot_name(Rest).
get_from_snapshot([]) -> undefined;
get_from_snapshot(["--from", SnapshotId | _]) -> SnapshotId;
get_from_snapshot([_ | Rest]) -> get_from_snapshot(Rest).
has_hot_flag([]) -> false;
has_hot_flag(["--hot" | _]) -> true;
has_hot_flag([_ | Rest]) -> has_hot_flag(Rest).
build_snapshot_json(undefined, false) -> "{}";
build_snapshot_json(undefined, true) -> "{\"hot\":true}";
build_snapshot_json(Name, false) -> "{\"name\":\"" ++ escape_json(Name) ++ "\"}";
build_snapshot_json(Name, true) -> "{\"name\":\"" ++ escape_json(Name) ++ "\",\"hot\":true}".
%% Service command
service_command(["--list" | _]) ->
ApiKey = get_api_key(),
Response = curl_get(ApiKey, "/services"),
io:format("~s~n", [Response]);
service_command(["--info", ServiceId | _]) ->
ApiKey = get_api_key(),
Response = curl_get(ApiKey, "/services/" ++ ServiceId),
io:format("~s~n", [Response]);
service_command(["--logs", ServiceId | _]) ->
ApiKey = get_api_key(),
Response = curl_get(ApiKey, "/services/" ++ ServiceId ++ "/logs"),
io:format("~s~n", [Response]);
service_command(["--freeze", ServiceId | _]) ->
ApiKey = get_api_key(),
TmpFile = write_temp_file("{}"),
_ = curl_post(ApiKey, "/services/" ++ ServiceId ++ "/freeze", TmpFile),
file:delete(TmpFile),
io:format("\033[32mService frozen: ~s\033[0m~n", [ServiceId]);
service_command(["--unfreeze", ServiceId | _]) ->
ApiKey = get_api_key(),
TmpFile = write_temp_file("{}"),
_ = curl_post(ApiKey, "/services/" ++ ServiceId ++ "/unfreeze", TmpFile),
file:delete(TmpFile),
io:format("\033[32mService unfreezing: ~s\033[0m~n", [ServiceId]);
service_command(["--destroy", ServiceId | _]) ->
ApiKey = get_api_key(),
case curl_delete_with_sudo(ApiKey, "/services/" ++ ServiceId) of
{ok, _, _} ->
io:format("\033[32mService destroyed: ~s\033[0m~n", [ServiceId]);
{ok, _} ->
io:format("\033[32mService destroyed: ~s\033[0m~n", [ServiceId]);
{error, cancelled} ->
halt(1);
{error, Msg} ->
io:format(standard_error, "\033[31mError: ~s\033[0m~n", [Msg]),
halt(1)
end;
service_command(["--resize", ServiceId, "--vcpu", VcpuStr | _]) ->
service_resize(ServiceId, VcpuStr);
service_command(["--resize", ServiceId, "-v", VcpuStr | _]) ->
service_resize(ServiceId, VcpuStr);
service_command(["--set-unfreeze-on-demand", ServiceId, EnabledStr | _]) ->
service_set_unfreeze_on_demand(ServiceId, EnabledStr);
service_command(["--execute", ServiceId, "--command", Command | _]) ->
ApiKey = get_api_key(),
Json = "{\"command\":\"" ++ escape_json(Command) ++ "\"}",
TmpFile = write_temp_file(Json),
Response = curl_post(ApiKey, "/services/" ++ ServiceId ++ "/execute", TmpFile),
file:delete(TmpFile),
case extract_json_field(Response, "stdout") of
"" -> ok;
Stdout -> io:format("\033[34m~s\033[0m", [Stdout])
end;
service_command(["--dump-bootstrap", ServiceId, File | _]) ->
ApiKey = get_api_key(),
io:format(standard_error, "Fetching bootstrap script from ~s...~n", [ServiceId]),
Json = "{\"command\":\"cat /tmp/bootstrap.sh\"}",
TmpFile = write_temp_file(Json),
Response = curl_post(ApiKey, "/services/" ++ ServiceId ++ "/execute", TmpFile),
file:delete(TmpFile),
case extract_json_field(Response, "stdout") of
"" ->
io:format(standard_error, "\033[31mError: Failed to fetch bootstrap (service not running or no bootstrap file)\033[0m~n"),
halt(1);
Script ->
file:write_file(File, Script),
os:cmd("chmod 755 " ++ File),
io:format("Bootstrap saved to ~s~n", [File])
end;
service_command(["--dump-bootstrap", ServiceId | _]) ->
ApiKey = get_api_key(),
io:format(standard_error, "Fetching bootstrap script from ~s...~n", [ServiceId]),
Json = "{\"command\":\"cat /tmp/bootstrap.sh\"}",
TmpFile = write_temp_file(Json),
Response = curl_post(ApiKey, "/services/" ++ ServiceId ++ "/execute", TmpFile),
file:delete(TmpFile),
case extract_json_field(Response, "stdout") of
"" ->
io:format(standard_error, "\033[31mError: Failed to fetch bootstrap (service not running or no bootstrap file)\033[0m~n"),
halt(1);
Script ->
io:format("~s", [Script])
end;
%% Service snapshot commands
service_command(["--snapshot", ServiceId | Rest]) ->
ApiKey = get_api_key(),
Name = get_snapshot_name(Rest),
Hot = has_hot_flag(Rest),
Json = build_snapshot_json(Name, Hot),
TmpFile = write_temp_file(Json),
Response = curl_post(ApiKey, "/services/" ++ ServiceId ++ "/snapshot", TmpFile),
file:delete(TmpFile),
io:format("\033[32mSnapshot created\033[0m~n"),
io:format("~s~n", [Response]);
service_command(["--restore", SnapshotId | _Rest]) ->
% --restore takes snapshot ID directly, calls /snapshots/:id/restore
ApiKey = get_api_key(),
TmpFile = write_temp_file("{}"),
Response = curl_post(ApiKey, "/snapshots/" ++ SnapshotId ++ "/restore", TmpFile),
file:delete(TmpFile),
io:format("\033[32mService restored from snapshot\033[0m~n"),
io:format("~s~n", [Response]);
%% Service env vault subcommand: service env <action> <id> [options]
service_command(["env", "status", ServiceId | _]) ->
service_env_status(ServiceId);
service_command(["env", "set", ServiceId | Rest]) ->
EnvVars = get_env_vars(Rest),
EnvFile = get_env_file(Rest),
Content = build_env_content(EnvVars, EnvFile),
case Content of
"" ->
io:format(standard_error, "Error: No environment variables specified. Use -e KEY=VALUE or --env-file FILE~n", []),
halt(1);
_ ->
service_env_set(ServiceId, Content)
end;
service_command(["env", "export", ServiceId | _]) ->
service_env_export(ServiceId);
service_command(["env", "delete", ServiceId | _]) ->
service_env_delete(ServiceId);
service_command(["env" | _]) ->
io:format(standard_error, "Usage: un.erl service env <status|set|export|delete> <service_id> [options]~n", []),
halt(1);
service_command(Args) ->
case get_service_name(Args) of
undefined ->
io:format("Error: --name required to create service~n"),
halt(1);
Name ->
ApiKey = get_api_key(),
Ports = get_service_ports(Args),
Bootstrap = get_service_bootstrap(Args),
BootstrapFile = get_service_bootstrap_file(Args),
Type = get_service_type(Args),
InputFiles = get_input_files(Args),
EnvVars = get_env_vars(Args),
EnvFile = get_env_file(Args),
PortsJson = case Ports of
undefined -> "";
P -> ",\"ports\":[" ++ P ++ "]"
end,
BootstrapJson = case Bootstrap of
undefined -> "";
B -> ",\"bootstrap\":\"" ++ escape_json(B) ++ "\""
end,
BootstrapContentJson = case BootstrapFile of
undefined -> "";
BF ->
case file:read_file(BF) of
{ok, ContentBin} ->
Content = binary_to_list(ContentBin),
",\"bootstrap_content\":\"" ++ escape_json(Content) ++ "\"";
{error, _} ->
io:format(standard_error, "\033[31mError: Bootstrap file not found: ~s\033[0m~n", [BF]),
halt(1)
end
end,
TypeJson = case Type of
undefined -> "";
T -> ",\"service_type\":\"" ++ T ++ "\""
end,
InputFilesJson = build_input_files_json(InputFiles),
Json = "{\"name\":\"" ++ Name ++ "\"" ++ PortsJson ++ BootstrapJson ++ BootstrapContentJson ++ TypeJson ++ InputFilesJson ++ "}",
TmpFile = write_temp_file(Json),
Response = curl_post(ApiKey, "/services", TmpFile),
file:delete(TmpFile),
io:format("\033[32mService created\033[0m~n"),
io:format("~s~n", [Response]),
%% Auto-vault: set env vars if provided
EnvContent = build_env_content(EnvVars, EnvFile),
case EnvContent of
"" -> ok;
_ ->
ServiceId = extract_json_field(Response, "id"),
case ServiceId of
"" -> ok;
_ ->
io:format("Setting vault for ~s...~n", [ServiceId]),
service_env_set(ServiceId, EnvContent)
end
end
end.
%% Snapshot command
snapshot_command(["--list" | _]) ->
snapshot_command(["-l"]);
snapshot_command(["-l" | _]) ->
ApiKey = get_api_key(),
Response = curl_get(ApiKey, "/snapshots"),
io:format("~s~n", [Response]);
snapshot_command(["--info", SnapshotId | _]) ->
ApiKey = get_api_key(),
Response = curl_get(ApiKey, "/snapshots/" ++ SnapshotId),
io:format("~s~n", [Response]);
snapshot_command(["--delete", SnapshotId | _]) ->
ApiKey = get_api_key(),
case curl_delete_with_sudo(ApiKey, "/snapshots/" ++ SnapshotId) of
{ok, _, _} ->
io:format("\033[32mSnapshot deleted: ~s\033[0m~n", [SnapshotId]);
{ok, _} ->
io:format("\033[32mSnapshot deleted: ~s\033[0m~n", [SnapshotId]);
{error, cancelled} ->
halt(1);
{error, Msg} ->
io:format(standard_error, "\033[31mError: ~s\033[0m~n", [Msg]),
halt(1)
end;
snapshot_command(["--clone", SnapshotId | Rest]) ->
ApiKey = get_api_key(),
Type = get_clone_type(Rest),
Name = get_clone_name(Rest),
Shell = get_clone_shell(Rest),
Ports = get_clone_ports(Rest),
Json = build_clone_json(Type, Name, Shell, Ports),
TmpFile = write_temp_file(Json),
Response = curl_post(ApiKey, "/snapshots/" ++ SnapshotId ++ "/clone", TmpFile),
file:delete(TmpFile),
io:format("\033[32mCreated from snapshot\033[0m~n"),
io:format("~s~n", [Response]);
snapshot_command(_) ->
io:format(standard_error, "Error: Use --list, --info ID, --delete ID, or --clone ID --type TYPE~n", []),
halt(1).
get_clone_type([]) -> undefined;
get_clone_type(["--type", Type | _]) -> Type;
get_clone_type([_ | Rest]) -> get_clone_type(Rest).
get_clone_name([]) -> undefined;
get_clone_name(["--name", Name | _]) -> Name;
get_clone_name([_ | Rest]) -> get_clone_name(Rest).
get_clone_shell([]) -> undefined;
get_clone_shell(["--shell", Shell | _]) -> Shell;
get_clone_shell([_ | Rest]) -> get_clone_shell(Rest).
get_clone_ports([]) -> undefined;
get_clone_ports(["--ports", Ports | _]) -> Ports;
get_clone_ports([_ | Rest]) -> get_clone_ports(Rest).
build_clone_json(undefined, _, _, _) ->
io:format(standard_error, "\033[31mError: --type required (session or service)\033[0m~n"),
halt(1);
build_clone_json(Type, Name, Shell, Ports) ->
TypeJson = "{\"type\":\"" ++ Type ++ "\"",
NameJson = case Name of
undefined -> "";
N -> ",\"name\":\"" ++ escape_json(N) ++ "\""
end,
ShellJson = case Shell of
undefined -> "";
S -> ",\"shell\":\"" ++ S ++ "\""
end,
PortsJson = case Ports of
undefined -> "";
P -> ",\"ports\":[" ++ P ++ "]"
end,
TypeJson ++ NameJson ++ ShellJson ++ PortsJson ++ "}".
%% Image command
image_command(["--list" | _]) ->
image_command(["-l"]);
image_command(["-l" | _]) ->
{PublicKey, SecretKey} = get_api_keys(),
Result = api_request("/images", "GET", "", PublicKey, SecretKey),
io:format("~s~n", [Result]),
halt(0);
image_command(["--info", ImageId | _]) ->
{PublicKey, SecretKey} = get_api_keys(),
Result = api_request("/images/" ++ ImageId, "GET", "", PublicKey, SecretKey),
io:format("~s~n", [Result]),
halt(0);
image_command(["--delete", ImageId | _]) ->
ApiKey = get_api_key(),
case curl_delete_with_sudo(ApiKey, "/images/" ++ ImageId) of
{ok, _, _} ->
io:format("\033[32mImage deleted: ~s\033[0m~n", [ImageId]),
halt(0);
{ok, _} ->
io:format("\033[32mImage deleted: ~s\033[0m~n", [ImageId]),
halt(0);
{error, cancelled} ->
halt(1);
{error, Msg} ->
io:format(standard_error, "\033[31mError: ~s\033[0m~n", [Msg]),
halt(1)
end;
image_command(["--lock", ImageId | _]) ->
{PublicKey, SecretKey} = get_api_keys(),
api_request("/images/" ++ ImageId ++ "/lock", "POST", "", PublicKey, SecretKey),
io:format("\033[32mImage locked: ~s\033[0m~n", [ImageId]),
halt(0);
image_command(["--unlock", ImageId | _]) ->
ApiKey = get_api_key(),
case curl_post_with_sudo(ApiKey, "/images/" ++ ImageId ++ "/unlock", "{}") of
{ok, _, _} ->
io:format("\033[32mImage unlocked: ~s\033[0m~n", [ImageId]),
halt(0);
{ok, _} ->
io:format("\033[32mImage unlocked: ~s\033[0m~n", [ImageId]),
halt(0);
{error, cancelled} ->
halt(1);
{error, Msg} ->
io:format(standard_error, "\033[31mError: ~s\033[0m~n", [Msg]),
halt(1)
end;
image_command(["--publish", SourceId | Rest]) ->
SourceType = get_image_source_type(Rest),
case SourceType of
undefined ->
io:format(standard_error, "\033[31mError: --source-type required (service or snapshot)\033[0m~n", []),
halt(1);
_ ->
{PublicKey, SecretKey} = get_api_keys(),
Name = get_image_name(Rest),
Payload = build_publish_json(SourceType, SourceId, Name),
Result = api_request("/images/publish", "POST", Payload, PublicKey, SecretKey),
io:format("\033[32mImage published\033[0m~n"),
io:format("~s~n", [Result]),
halt(0)
end;
image_command(["--visibility", ImageId, Mode | _]) ->
{PublicKey, SecretKey} = get_api_keys(),
Payload = "{\"visibility\":\"" ++ Mode ++ "\"}",
api_request("/images/" ++ ImageId ++ "/visibility", "POST", Payload, PublicKey, SecretKey),
io:format("\033[32mImage visibility set to ~s\033[0m~n", [Mode]),
halt(0);
image_command(["--spawn", ImageId | Rest]) ->
{PublicKey, SecretKey} = get_api_keys(),
Name = get_image_name(Rest),
Ports = get_image_ports(Rest),
Payload = build_spawn_json(Name, Ports),
Result = api_request("/images/" ++ ImageId ++ "/spawn", "POST", Payload, PublicKey, SecretKey),
io:format("\033[32mService spawned from image\033[0m~n"),
io:format("~s~n", [Result]),
halt(0);
image_command(["--clone", ImageId | Rest]) ->
{PublicKey, SecretKey} = get_api_keys(),
Name = get_image_name(Rest),
Payload = case Name of
undefined -> "{}";
N -> "{\"name\":\"" ++ escape_json(N) ++ "\"}"
end,
Result = api_request("/images/" ++ ImageId ++ "/clone", "POST", Payload, PublicKey, SecretKey),
io:format("\033[32mImage cloned\033[0m~n"),
io:format("~s~n", [Result]),
halt(0);
image_command(_) ->
io:format(standard_error, "Error: Use --list, --info ID, --delete ID, --lock ID, --unlock ID, --publish ID, --visibility ID MODE, --spawn ID, or --clone ID~n", []),
halt(1).
get_image_source_type([]) -> undefined;
get_image_source_type(["--source-type", Type | _]) -> Type;
get_image_source_type([_ | Rest]) -> get_image_source_type(Rest).
get_image_name([]) -> undefined;
get_image_name(["--name", Name | _]) -> Name;
get_image_name([_ | Rest]) -> get_image_name(Rest).
get_image_ports([]) -> undefined;
get_image_ports(["--ports", Ports | _]) -> Ports;
get_image_ports([_ | Rest]) -> get_image_ports(Rest).
build_publish_json(SourceType, SourceId, Name) ->
Base = "{\"source_type\":\"" ++ SourceType ++ "\",\"source_id\":\"" ++ SourceId ++ "\"",
NameJson = case Name of
undefined -> "";
N -> ",\"name\":\"" ++ escape_json(N) ++ "\""
end,
Base ++ NameJson ++ "}".
build_spawn_json(Name, Ports) ->
Parts = [],
Parts1 = case Name of
undefined -> Parts;
N -> ["\"name\":\"" ++ escape_json(N) ++ "\"" | Parts]
end,
Parts2 = case Ports of
undefined -> Parts1;
P -> ["\"ports\":[" ++ P ++ "]" | Parts1]
end,
"{" ++ string:join(lists:reverse(Parts2), ",") ++ "}".
%% Languages cache TTL (1 hour in seconds)
-define(LANGUAGES_CACHE_TTL, 3600).
%% Get languages cache path
get_languages_cache_path() ->
Home = os:getenv("HOME"),
case Home of
false -> "/tmp/.unsandbox/languages.json";
_ -> Home ++ "/.unsandbox/languages.json"
end.
%% Load languages from cache
load_languages_cache() ->
CachePath = get_languages_cache_path(),
case file:read_file(CachePath) of
{ok, Bin} ->
Content = binary_to_list(Bin),
Timestamp = extract_json_number(Content, "timestamp"),
Now = erlang:system_time(second),
if
Timestamp > 0 andalso (Now - Timestamp) < ?LANGUAGES_CACHE_TTL ->
extract_json_array(Content, "languages");
true ->
undefined
end;
{error, _} ->
undefined
end.
%% Save languages to cache
save_languages_cache(Languages) ->
CachePath = get_languages_cache_path(),
CacheDir = filename:dirname(CachePath),
filelib:ensure_dir(CachePath),
file:make_dir(CacheDir),
Timestamp = erlang:system_time(second),
LanguagesJson = "[" ++ string:join(["\"" ++ L ++ "\"" || L <- Languages], ",") ++ "]",
Json = "{\"languages\":" ++ LanguagesJson ++ ",\"timestamp\":" ++ integer_to_list(Timestamp) ++ "}",
file:write_file(CachePath, Json).
%% Extract JSON number field
extract_json_number(Json, Field) ->
Pattern = "\"" ++ Field ++ "\":",
case string:str(Json, Pattern) of
0 -> 0;
Pos ->
Start = Pos + length(Pattern),
Rest = lists:nthtail(Start - 1, Json),
extract_number(Rest)
end.
extract_number(Str) ->
extract_number(Str, []).
extract_number([], Acc) ->
case Acc of
[] -> 0;
_ -> list_to_integer(lists:reverse(Acc))
end;
extract_number([C | Rest], Acc) when C >= $0, C =< $9 ->
extract_number(Rest, [C | Acc]);
extract_number(_, Acc) ->
case Acc of
[] -> 0;
_ -> list_to_integer(lists:reverse(Acc))
end.
%% Languages command
languages_command(Args) ->
JsonOutput = lists:member("--json", Args),
%% Try to load from cache first
Languages = case load_languages_cache() of
undefined ->
%% Cache miss or expired, fetch from API
ApiKey = get_api_key(),
Response = curl_get(ApiKey, "/languages"),
Langs = extract_json_array(Response, "languages"),
save_languages_cache(Langs),
Langs;
CachedLanguages ->
CachedLanguages
end,
if
JsonOutput ->
%% Output raw JSON array
case Languages of
[] -> io:format("[]~n");
_ -> io:format("[~s]~n", [string:join(["\"" ++ L ++ "\"" || L <- Languages], ",")])
end;
true ->
%% Output one language per line
case Languages of
[] -> ok;
_ -> [io:format("~s~n", [L]) || L <- Languages]
end
end.
%% Key command
key_command(Args) ->
ApiKey = get_api_key(),
case has_extend_flag(Args) of
true ->
validate_and_extend_key(ApiKey);
false ->
validate_key(ApiKey)
end.
validate_key(ApiKey) ->
Response = curl_post_portal(ApiKey, "/keys/validate", "{}"),
parse_and_display_key_status(Response, false).
validate_and_extend_key(ApiKey) ->
Response = curl_post_portal(ApiKey, "/keys/validate", "{}"),
parse_and_display_key_status(Response, true).
parse_and_display_key_status(Response, ShouldExtend) ->
%% Parse JSON response (simple extraction for fields we need)
Status = extract_json_field(Response, "status"),
PublicKey = extract_json_field(Response, "public_key"),
Tier = extract_json_field(Response, "tier"),
ExpiresAt = extract_json_field(Response, "expires_at"),
TimeRemaining = extract_json_field(Response, "time_remaining"),
RateLimit = extract_json_field(Response, "rate_limit"),
Burst = extract_json_field(Response, "burst"),
Concurrency = extract_json_field(Response, "concurrency"),
case Status of
"valid" ->
io:format("\033[32mValid\033[0m~n"),
io:format("Public Key: ~s~n", [PublicKey]),
io:format("Tier: ~s~n", [Tier]),
io:format("Status: ~s~n", [Status]),
io:format("Expires: ~s~n", [ExpiresAt]),
if TimeRemaining =/= "" -> io:format("Time Remaining: ~s~n", [TimeRemaining]); true -> ok end,
if RateLimit =/= "" -> io:format("Rate Limit: ~s~n", [RateLimit]); true -> ok end,
if Burst =/= "" -> io:format("Burst: ~s~n", [Burst]); true -> ok end,
if Concurrency =/= "" -> io:format("Concurrency: ~s~n", [Concurrency]); true -> ok end,
if ShouldExtend ->
open_extend_page(PublicKey);
true -> ok
end;
"expired" ->
io:format("\033[31mExpired\033[0m~n"),
io:format("Public Key: ~s~n", [PublicKey]),
io:format("Tier: ~s~n", [Tier]),
io:format("Expired: ~s~n", [ExpiresAt]),
io:format("\033[33mTo renew: Visit https://unsandbox.com/keys/extend\033[0m~n"),
if ShouldExtend ->
open_extend_page(PublicKey);
true -> ok
end;
"invalid" ->
io:format("\033[31mInvalid\033[0m~n"),
io:format("The API key is not valid.~n");
_ ->
io:format("~s~n", [Response])
end.
open_extend_page(PublicKey) ->
Url = "https://unsandbox.com/keys/extend?pk=" ++ PublicKey,
io:format("\033[33mOpening browser to extend key...\033[0m~n"),
case os:type() of
{unix, darwin} ->
os:cmd("open '" ++ Url ++ "'");
{unix, _} ->
os:cmd("xdg-open '" ++ Url ++ "' 2>/dev/null || sensible-browser '" ++ Url ++ "' 2>/dev/null &");
{win32, _} ->
os:cmd("start " ++ Url)
end.
%% Helpers
%% @doc Load credentials from a CSV file at the given path.
%% Skips blank lines and comment lines (#). Returns {ok, {PK, SK}} or error.
load_credentials_from_csv(CsvPath, AccountIndex) ->
case file:read_file(CsvPath) of
{ok, Bin} ->
Lines = string:split(binary_to_list(Bin), "\n", all),
ValidAccounts = lists:filtermap(fun(Line) ->
Trimmed = string:trim(Line),
case Trimmed of
"" -> false;
[$# | _] -> false;
_ ->
Parts = string:split(Trimmed, ",", all),
case Parts of
[PK, SK | _] ->
PKt = string:trim(PK),
SKt = string:trim(SK),
if
length(PKt) > 8 andalso length(SKt) > 8 ->
{true, {PKt, SKt}};
true -> false
end;
_ -> false
end
end
end, Lines),
if
AccountIndex < length(ValidAccounts) ->
{ok, lists:nth(AccountIndex + 1, ValidAccounts)};
true ->
error
end;
_ ->
error
end.
%% @doc Resolve credentials with correct priority:
%% 1. --account N process-dict override -> accounts.csv row N
%% 2. UNSANDBOX_PUBLIC_KEY / UNSANDBOX_SECRET_KEY env vars
%% 3. ~/.unsandbox/accounts.csv row 0 (or UNSANDBOX_ACCOUNT env)
%% 4. ./accounts.csv row 0
get_api_keys() ->
Home = case os:getenv("HOME") of false -> "."; H -> H end,
HomeCsv = filename:join([Home, ".unsandbox", "accounts.csv"]),
%% Priority 1: explicit --account N (stored in process dict by main/1)
case erlang:get(account_index) of
undefined ->
%% Priority 2: environment variables
PublicKey = os:getenv("UNSANDBOX_PUBLIC_KEY"),
SecretKey = os:getenv("UNSANDBOX_SECRET_KEY"),
ApiKey = os:getenv("UNSANDBOX_API_KEY"),
if
PublicKey =/= false andalso SecretKey =/= false ->
{PublicKey, SecretKey};
ApiKey =/= false ->
{ApiKey, false};
true ->
%% Priority 3: ~/.unsandbox/accounts.csv (or UNSANDBOX_ACCOUNT index)
DefaultIndex = case os:getenv("UNSANDBOX_ACCOUNT") of
false -> 0;
IdxStr -> try list_to_integer(string:trim(IdxStr)) catch _:_ -> 0 end
end,
case load_credentials_from_csv(HomeCsv, DefaultIndex) of
{ok, {PK, SK}} ->
{PK, SK};
error ->
%% Priority 4: ./accounts.csv
case load_credentials_from_csv("accounts.csv", DefaultIndex) of
{ok, {PK2, SK2}} ->
{PK2, SK2};
error ->
io:format("Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set (or UNSANDBOX_API_KEY for backwards compat)~n"),
halt(1)
end
end
end;
AccountIndex ->
case load_credentials_from_csv(HomeCsv, AccountIndex) of
{ok, {PK, SK}} ->
{PK, SK};
error ->
case load_credentials_from_csv("accounts.csv", AccountIndex) of
{ok, {PK2, SK2}} ->
{PK2, SK2};
error ->
io:format("Error: No credentials found for account index ~B in accounts.csv~n", [AccountIndex]),
halt(1)
end
end
end.
get_api_key() ->
{PublicKey, _} = get_api_keys(),
PublicKey.
hmac_sha256(Secret, Message) ->
string:lowercase(
lists:flatten([io_lib:format("~2.16.0b", [X]) || X <- binary_to_list(crypto:mac(hmac, sha256, Secret, Message))])
).
make_signature(SecretKey, Timestamp, Method, Path, Body) ->
Message = Timestamp ++ ":" ++ Method ++ ":" ++ Path ++ ":" ++ Body,
hmac_sha256(SecretKey, Message).
check_clock_drift_error(Response) ->
HasTimestamp = string:str(Response, "timestamp") > 0 orelse string:str(Response, "\"timestamp\"") > 0,
Has401 = string:str(Response, "401") > 0,
HasExpired = string:str(Response, "expired") > 0,
HasInvalid = string:str(Response, "invalid") > 0,
case HasTimestamp andalso (Has401 orelse HasExpired orelse HasInvalid) of
true ->
io:format(standard_error, "\033[31mError: Request timestamp expired (must be within 5 minutes of server time)\033[0m~n", []),
io:format(standard_error, "\033[33mYour computer's clock may have drifted.\033[0m~n", []),
io:format(standard_error, "Check your system time and sync with NTP if needed:~n", []),
io:format(standard_error, " Linux: sudo ntpdate -s time.nist.gov~n", []),
io:format(standard_error, " macOS: sudo sntp -sS time.apple.com~n", []),
io:format(standard_error, " Windows: w32tm /resync~n", []),
halt(1);
false ->
ok
end.
build_auth_headers(PublicKey, SecretKey, Method, Path, Body) ->
if
SecretKey =/= false ->
Timestamp = integer_to_list(erlang:system_time(second)),
Signature = make_signature(SecretKey, Timestamp, Method, Path, Body),
" -H 'Authorization: Bearer " ++ PublicKey ++ "'"
++ " -H 'X-Timestamp: " ++ Timestamp ++ "'"
++ " -H 'X-Signature: " ++ Signature ++ "'";
true ->
" -H 'Authorization: Bearer " ++ PublicKey ++ "'"
end.
ext_to_lang(".hs") -> {ok, "haskell"};
ext_to_lang(".ml") -> {ok, "ocaml"};
ext_to_lang(".clj") -> {ok, "clojure"};
ext_to_lang(".scm") -> {ok, "scheme"};
ext_to_lang(".lisp") -> {ok, "commonlisp"};
ext_to_lang(".erl") -> {ok, "erlang"};
ext_to_lang(".ex") -> {ok, "elixir"};
ext_to_lang(".exs") -> {ok, "elixir"};
ext_to_lang(".py") -> {ok, "python"};
ext_to_lang(".js") -> {ok, "javascript"};
ext_to_lang(".ts") -> {ok, "typescript"};
ext_to_lang(".rb") -> {ok, "ruby"};
ext_to_lang(".go") -> {ok, "go"};
ext_to_lang(".rs") -> {ok, "rust"};
ext_to_lang(".c") -> {ok, "c"};
ext_to_lang(".cpp") -> {ok, "cpp"};
ext_to_lang(".cc") -> {ok, "cpp"};
ext_to_lang(".java") -> {ok, "java"};
ext_to_lang(".kt") -> {ok, "kotlin"};
ext_to_lang(".cs") -> {ok, "csharp"};
ext_to_lang(".fs") -> {ok, "fsharp"};
ext_to_lang(".jl") -> {ok, "julia"};
ext_to_lang(".r") -> {ok, "r"};
ext_to_lang(".cr") -> {ok, "crystal"};
ext_to_lang(".d") -> {ok, "d"};
ext_to_lang(".nim") -> {ok, "nim"};
ext_to_lang(".zig") -> {ok, "zig"};
ext_to_lang(".v") -> {ok, "v"};
ext_to_lang(".dart") -> {ok, "dart"};
ext_to_lang(".sh") -> {ok, "bash"};
ext_to_lang(".pl") -> {ok, "perl"};
ext_to_lang(".lua") -> {ok, "lua"};
ext_to_lang(".php") -> {ok, "php"};
ext_to_lang(Ext) -> {error, Ext}.
escape_json(Str) ->
escape_json(Str, []).
escape_json([], Acc) ->
lists:reverse(Acc);
escape_json([$\\ | Rest], Acc) ->
escape_json(Rest, [$\\, $\\ | Acc]);
escape_json([$\" | Rest], Acc) ->
escape_json(Rest, [$\", $\\ | Acc]);
escape_json([$\n | Rest], Acc) ->
escape_json(Rest, [$n, $\\ | Acc]);
escape_json([$\r | Rest], Acc) ->
escape_json(Rest, [$r, $\\ | Acc]);
escape_json([$\t | Rest], Acc) ->
escape_json(Rest, [$t, $\\ | Acc]);
escape_json([C | Rest], Acc) ->
escape_json(Rest, [C | Acc]).
build_json(Language, Code) ->
"{\"language\":\"" ++ Language ++ "\",\"code\":\"" ++ escape_json(Code) ++ "\"}".
read_and_base64(Filepath) ->
case file:read_file(Filepath) of
{ok, Content} ->
base64:encode_to_string(Content);
{error, _} ->
""
end.
build_input_files_json([]) -> "";
build_input_files_json(Files) ->
FileJsons = lists:map(fun(F) ->
B64 = read_and_base64(F),
Basename = filename:basename(F),
"{\"filename\":\"" ++ escape_json(Basename) ++ "\",\"content\":\"" ++ B64 ++ "\"}"
end, Files),
",\"input_files\":[" ++ string:join(FileJsons, ",") ++ "]".
write_temp_file(Data) ->
TmpFile = "/tmp/un_erl_" ++ integer_to_list(rand:uniform(999999)) ++ ".json",
file:write_file(TmpFile, Data),
TmpFile.
curl_post(ApiKey, Endpoint, TmpFile) ->
{ok, Body} = file:read_file(TmpFile),
BodyStr = binary_to_list(Body),
{PublicKey, SecretKey} = get_api_keys(),
AuthHeaders = build_auth_headers(PublicKey, SecretKey, "POST", Endpoint, BodyStr),
Cmd = "curl -s -X POST https://api.unsandbox.com" ++ Endpoint ++
" -H 'Content-Type: application/json'" ++
AuthHeaders ++
" -d @" ++ TmpFile,
Result = os:cmd(Cmd),
check_clock_drift_error(Result),
Result.
curl_post_portal(ApiKey, Endpoint, Data) ->
TmpFile = write_temp_file(Data),
{PublicKey, SecretKey} = get_api_keys(),
AuthHeaders = build_auth_headers(PublicKey, SecretKey, "POST", Endpoint, Data),
Cmd = "curl -s -X POST https://unsandbox.com" ++ Endpoint ++
" -H 'Content-Type: application/json'" ++
AuthHeaders ++
" -d @" ++ TmpFile,
Result = os:cmd(Cmd),
file:delete(TmpFile),
check_clock_drift_error(Result),
Result.
curl_get(ApiKey, Endpoint) ->
{PublicKey, SecretKey} = get_api_keys(),
AuthHeaders = build_auth_headers(PublicKey, SecretKey, "GET", Endpoint, ""),
Cmd = "curl -s https://api.unsandbox.com" ++ Endpoint ++
AuthHeaders,
Result = os:cmd(Cmd),
check_clock_drift_error(Result),
Result.
curl_delete(ApiKey, Endpoint) ->
{PublicKey, SecretKey} = get_api_keys(),
AuthHeaders = build_auth_headers(PublicKey, SecretKey, "DELETE", Endpoint, ""),
Cmd = "curl -s -X DELETE https://api.unsandbox.com" ++ Endpoint ++
AuthHeaders,
Result = os:cmd(Cmd),
check_clock_drift_error(Result),
Result.
%% Handle 428 sudo OTP challenge - prompts user for OTP and retries the request
handle_sudo_challenge(Response, Method, Endpoint, Body) ->
ChallengeId = extract_json_field(Response, "challenge_id"),
io:format(standard_error, "\033[33mConfirmation required. Check your email for a one-time code.\033[0m~n", []),
io:format(standard_error, "Enter OTP: ", []),
case io:get_line("") of
eof ->
io:format(standard_error, "Error: Failed to read OTP~n", []),
{error, cancelled};
OtpRaw ->
Otp = string:trim(OtpRaw),
case Otp of
"" ->
io:format(standard_error, "Error: Operation cancelled~n", []),
{error, cancelled};
_ ->
%% Retry the request with sudo headers
{PublicKey, SecretKey} = get_api_keys(),
AuthHeaders = build_auth_headers(PublicKey, SecretKey, Method, Endpoint, Body),
SudoHeaders = " -H 'X-Sudo-OTP: " ++ Otp ++ "'",
ChallengeHeader = case ChallengeId of
"" -> "";
_ -> " -H 'X-Sudo-Challenge: " ++ ChallengeId ++ "'"
end,
Cmd = case Method of
"DELETE" ->
"curl -s -X DELETE https://api.unsandbox.com" ++ Endpoint ++
AuthHeaders ++ SudoHeaders ++ ChallengeHeader;
"POST" ->
TmpFile = write_temp_file(Body),
Result = "curl -s -X POST https://api.unsandbox.com" ++ Endpoint ++
" -H 'Content-Type: application/json'" ++
AuthHeaders ++ SudoHeaders ++ ChallengeHeader ++
" -d @" ++ TmpFile,
file:delete(TmpFile),
Result;
_ ->
"curl -s https://api.unsandbox.com" ++ Endpoint ++
AuthHeaders ++ SudoHeaders ++ ChallengeHeader
end,
RetryResult = os:cmd(Cmd),
case string:str(RetryResult, "\"error\"") of
0 -> {ok, RetryResult};
_ -> {error, RetryResult}
end
end
end.
%% Curl with 428 handling for destructive operations
curl_delete_with_sudo(ApiKey, Endpoint) ->
{PublicKey, SecretKey} = get_api_keys(),
AuthHeaders = build_auth_headers(PublicKey, SecretKey, "DELETE", Endpoint, ""),
Cmd = "curl -s -w '\\n%{http_code}' -X DELETE https://api.unsandbox.com" ++ Endpoint ++
AuthHeaders,
Result = os:cmd(Cmd),
%% Split response and status code
Lines = string:split(Result, "\n", all),
case lists:reverse(Lines) of
[StatusCodeStr | BodyLinesRev] ->
StatusCode = list_to_integer(string:trim(StatusCodeStr)),
Body = string:join(lists:reverse(BodyLinesRev), "\n"),
check_clock_drift_error(Body),
case StatusCode of
428 -> handle_sudo_challenge(Body, "DELETE", Endpoint, "");
_ -> {ok, Body, StatusCode}
end;
_ ->
{ok, Result, 200}
end.
curl_post_with_sudo(ApiKey, Endpoint, Json) ->
TmpFile = write_temp_file(Json),
{PublicKey, SecretKey} = get_api_keys(),
AuthHeaders = build_auth_headers(PublicKey, SecretKey, "POST", Endpoint, Json),
Cmd = "curl -s -w '\\n%{http_code}' -X POST https://api.unsandbox.com" ++ Endpoint ++
" -H 'Content-Type: application/json'" ++
AuthHeaders ++
" -d @" ++ TmpFile,
Result = os:cmd(Cmd),
file:delete(TmpFile),
%% Split response and status code
Lines = string:split(Result, "\n", all),
case lists:reverse(Lines) of
[StatusCodeStr | BodyLinesRev] ->
StatusCode = list_to_integer(string:trim(StatusCodeStr)),
Body = string:join(lists:reverse(BodyLinesRev), "\n"),
check_clock_drift_error(Body),
case StatusCode of
428 -> handle_sudo_challenge(Body, "POST", Endpoint, Json);
_ -> {ok, Body, StatusCode}
end;
_ ->
{ok, Result, 200}
end.
curl_patch(ApiKey, Endpoint, TmpFile) ->
{ok, Body} = file:read_file(TmpFile),
BodyStr = binary_to_list(Body),
{PublicKey, SecretKey} = get_api_keys(),
AuthHeaders = build_auth_headers(PublicKey, SecretKey, "PATCH", Endpoint, BodyStr),
Cmd = "curl -s -X PATCH https://api.unsandbox.com" ++ Endpoint ++
" -H 'Content-Type: application/json'" ++
AuthHeaders ++
" -d @" ++ TmpFile,
Result = os:cmd(Cmd),
check_clock_drift_error(Result),
Result.
curl_put_text(Endpoint, Content) ->
{PublicKey, SecretKey} = get_api_keys(),
TmpFile = write_temp_file(Content),
AuthHeaders = build_auth_headers(PublicKey, SecretKey, "PUT", Endpoint, Content),
Cmd = "curl -s -X PUT https://api.unsandbox.com" ++ Endpoint ++
" -H 'Content-Type: text/plain'" ++
AuthHeaders ++
" --data-binary @" ++ TmpFile,
Result = os:cmd(Cmd),
file:delete(TmpFile),
check_clock_drift_error(Result),
Result.
build_env_content(EnvVars, EnvFile) ->
% Build env content from list of env vars and env file
VarLines = EnvVars,
FileLines = case EnvFile of
undefined -> [];
"" -> [];
_ ->
case file:read_file(EnvFile) of
{ok, Bin} ->
Lines = string:split(binary_to_list(Bin), "\n", all),
[L || L <- Lines,
length(string:trim(L)) > 0,
not lists:prefix("#", string:trim(L))];
{error, _} -> []
end
end,
string:join(VarLines ++ FileLines, "\n").
service_env_status(ServiceId) ->
ApiKey = get_api_key(),
Endpoint = "/services/" ++ ServiceId ++ "/env",
Response = curl_get(ApiKey, Endpoint),
io:format("~s~n", [Response]).
service_env_set(ServiceId, Content) ->
Endpoint = "/services/" ++ ServiceId ++ "/env",
Response = curl_put_text(Endpoint, Content),
io:format("~s~n", [Response]).
service_env_export(ServiceId) ->
ApiKey = get_api_key(),
Endpoint = "/services/" ++ ServiceId ++ "/env/export",
TmpFile = write_temp_file("{}"),
Response = curl_post(ApiKey, Endpoint, TmpFile),
file:delete(TmpFile),
case extract_json_field(Response, "content") of
"" -> io:format("~s~n", [Response]);
ContentStr -> io:format("~s", [ContentStr])
end.
service_env_delete(ServiceId) ->
ApiKey = get_api_key(),
_ = curl_delete(ApiKey, "/services/" ++ ServiceId ++ "/env"),
io:format("\033[32mVault deleted: ~s\033[0m~n", [ServiceId]).
service_resize(ServiceId, VcpuStr) ->
ApiKey = get_api_key(),
Vcpu = list_to_integer(VcpuStr),
if
Vcpu < 1 orelse Vcpu > 8 ->
io:format(standard_error, "\033[31mError: --vcpu must be between 1 and 8\033[0m~n", []),
halt(1);
true -> ok
end,
Json = "{\"vcpu\":" ++ integer_to_list(Vcpu) ++ "}",
TmpFile = write_temp_file(Json),
_ = curl_patch(ApiKey, "/services/" ++ ServiceId, TmpFile),
file:delete(TmpFile),
Ram = Vcpu * 2,
io:format("\033[32mService resized to ~B vCPU, ~B GB RAM\033[0m~n", [Vcpu, Ram]).
service_set_unfreeze_on_demand(ServiceId, EnabledStr) ->
ApiKey = get_api_key(),
Enabled = case string:lowercase(EnabledStr) of
"true" -> "true";
"1" -> "true";
"yes" -> "true";
"on" -> "true";
_ -> "false"
end,
Json = "{\"unfreeze_on_demand\":" ++ Enabled ++ "}",
TmpFile = write_temp_file(Json),
_ = curl_patch(ApiKey, "/services/" ++ ServiceId, TmpFile),
file:delete(TmpFile),
io:format("\033[32mService unfreeze_on_demand set to ~s: ~s\033[0m~n", [Enabled, ServiceId]).
%% Argument parsing
parse_exec_args([], Opts) ->
{maps:get(file, Opts), Opts};
parse_exec_args([Arg | Rest], Opts) ->
case Arg of
"-" ++ _ -> parse_exec_args(Rest, Opts);
_ -> {Arg, Opts}
end.
get_shell_opt([], Default) -> Default;
get_shell_opt(["--shell", Shell | _], _) -> Shell;
get_shell_opt(["-s", Shell | _], _) -> Shell;
get_shell_opt([_ | Rest], Default) -> get_shell_opt(Rest, Default).
get_service_name([]) -> undefined;
get_service_name(["--name", Name | _]) -> Name;
get_service_name([_ | Rest]) -> get_service_name(Rest).
get_service_ports([]) -> undefined;
get_service_ports(["--ports", Ports | _]) -> Ports;
get_service_ports([_ | Rest]) -> get_service_ports(Rest).
get_service_bootstrap([]) -> undefined;
get_service_bootstrap(["--bootstrap", Bootstrap | _]) -> Bootstrap;
get_service_bootstrap([_ | Rest]) -> get_service_bootstrap(Rest).
get_service_bootstrap_file([]) -> undefined;
get_service_bootstrap_file(["--bootstrap-file", BootstrapFile | _]) -> BootstrapFile;
get_service_bootstrap_file([_ | Rest]) -> get_service_bootstrap_file(Rest).
get_service_type([]) -> undefined;
get_service_type(["--type", Type | _]) -> Type;
get_service_type([_ | Rest]) -> get_service_type(Rest).
get_input_files(Args) -> get_input_files(Args, []).
get_input_files([], Acc) -> lists:reverse(Acc);
get_input_files(["-f", File | Rest], Acc) -> get_input_files(Rest, [File | Acc]);
get_input_files([_ | Rest], Acc) -> get_input_files(Rest, Acc).
has_extend_flag([]) -> false;
has_extend_flag(["--extend" | _]) -> true;
has_extend_flag([_ | Rest]) -> has_extend_flag(Rest).
get_env_vars(Args) -> get_env_vars(Args, []).
get_env_vars([], Acc) -> lists:reverse(Acc);
get_env_vars(["-e", EnvVar | Rest], Acc) -> get_env_vars(Rest, [EnvVar | Acc]);
get_env_vars([_ | Rest], Acc) -> get_env_vars(Rest, Acc).
get_env_file([]) -> undefined;
get_env_file(["--env-file", EnvFile | _]) -> EnvFile;
get_env_file([_ | Rest]) -> get_env_file(Rest).
%% Simple JSON field extraction (works for simple string fields)
extract_json_field(Json, Field) ->
Pattern = "\"" ++ Field ++ "\":\"",
case string:str(Json, Pattern) of
0 -> "";
Pos ->
Start = Pos + length(Pattern),
Rest = lists:nthtail(Start - 1, Json),
extract_until_quote(Rest)
end.
extract_until_quote(Str) ->
extract_until_quote(Str, []).
extract_until_quote([], Acc) ->
lists:reverse(Acc);
extract_until_quote([$\" | _], Acc) ->
lists:reverse(Acc);
extract_until_quote([$\\, $\" | Rest], Acc) ->
extract_until_quote(Rest, [$\" | Acc]);
extract_until_quote([C | Rest], Acc) ->
extract_until_quote(Rest, [C | Acc]).
%% Extract JSON array of strings from a field like "languages":["python","javascript",...]
extract_json_array(Json, Field) ->
Pattern = "\"" ++ Field ++ "\":[",
case string:str(Json, Pattern) of
0 -> [];
Pos ->
Start = Pos + length(Pattern),
Rest = lists:nthtail(Start - 1, Json),
extract_array_elements(Rest, [])
end.
extract_array_elements([], Acc) ->
lists:reverse(Acc);
extract_array_elements([$] | _], Acc) ->
lists:reverse(Acc);
extract_array_elements([$\" | Rest], Acc) ->
{Elem, Remainder} = extract_until_quote_with_rest(Rest),
extract_array_elements(Remainder, [Elem | Acc]);
extract_array_elements([_ | Rest], Acc) ->
extract_array_elements(Rest, Acc).
extract_until_quote_with_rest(Str) ->
extract_until_quote_with_rest(Str, []).
extract_until_quote_with_rest([], Acc) ->
{lists:reverse(Acc), []};
extract_until_quote_with_rest([$\" | Rest], Acc) ->
{lists:reverse(Acc), Rest};
extract_until_quote_with_rest([$\\, $\" | Rest], Acc) ->
extract_until_quote_with_rest(Rest, [$\" | Acc]);
extract_until_quote_with_rest([C | Rest], Acc) ->
extract_until_quote_with_rest(Rest, [C | Acc]).
Esclarecimentos de documentação
Dependências
C Binary (un1) — requer libcurl e libwebsockets:
sudo apt install build-essential libcurl4-openssl-dev libwebsockets-dev
wget unsandbox.com/downloads/un.c && gcc -O2 -o un un.c -lcurl -lwebsockets
Implementações SDK — a maioria usa apenas stdlib (Ruby, JS, Go, etc). Alguns requerem dependências mínimas:
pip install requests # Python
Executar Código
Executar um script
./un hello.py
./un app.js
./un main.rs
Com variáveis de ambiente
./un -e DEBUG=1 -e NAME=World script.py
Com arquivos de entrada (teletransportar arquivos para sandbox)
./un -f data.csv -f config.json process.py
Obter binário compilado
./un -a -o ./bin main.c
Sessões interativas
Iniciar uma sessão de shell
# Default bash shell
./un session
# Choose your shell
./un session --shell zsh
./un session --shell fish
# Jump into a REPL
./un session --shell python3
./un session --shell node
./un session --shell julia
Sessão com acesso à rede
./un session -n semitrusted
Auditoria de sessão (gravação completa do terminal)
# Record everything (including vim, interactive programs)
./un session --audit -o ./logs
# Replay session later
zcat session.log*.gz | less -R
Coletar artefatos da sessão
# Files in /tmp/artifacts/ are collected on exit
./un session -a -o ./outputs
Persistência de sessão (tmux/screen)
# Default: session terminates on disconnect (clean exit)
./un session
# With tmux: session persists, can reconnect later
./un session --tmux
# Press Ctrl+b then d to detach
# With screen: alternative multiplexer
./un session --screen
# Press Ctrl+a then d to detach
Listar Trabalhos Ativos
./un session --list
# Output:
# Active sessions: 2
#
# SESSION ID CONTAINER SHELL TTL STATUS
# abc123... unsb-vm-12345 python3 45m30s active
# def456... unsb-vm-67890 bash 1h2m active
Reconectar à sessão existente
# Reconnect by container name (requires --tmux or --screen)
./un session --attach unsb-vm-12345
# Use exit to terminate session, or detach to keep it running
Encerrar uma sessão
./un session --kill unsb-vm-12345
Shells e REPLs disponíveis
Shells: bash, dash, sh, zsh, fish, ksh, tcsh, csh, elvish, xonsh, ash
REPLs: python3, bpython, ipython # Python
node # JavaScript
ruby, irb # Ruby
lua # Lua
php # PHP
perl # Perl
guile, scheme # Scheme
ghci # Haskell
erl, iex # Erlang/Elixir
sbcl, clisp # Common Lisp
r # R
julia # Julia
clojure # Clojure
Gerenciamento de Chave API
Verificar Status do Pagamento
# Check if your API key is valid
./un key
# Output:
# Valid: key expires in 30 days
Estender Chave Expirada
# Open the portal to extend an expired key
./un key --extend
# This opens the unsandbox.com portal where you can
# add more credits to extend your key's expiration
Autenticação
As credenciais são carregadas em ordem de prioridade (maior primeiro):
# 1. CLI flags (highest priority)
./un -p unsb-pk-xxxx -k unsb-sk-xxxxx script.py
# 2. Environment variables
export UNSANDBOX_PUBLIC_KEY=unsb-pk-xxxx-xxxx-xxxx-xxxx
export UNSANDBOX_SECRET_KEY=unsb-sk-xxxxx-xxxxx-xxxxx-xxxxx
./un script.py
# 3. Config file (lowest priority)
# ~/.unsandbox/accounts.csv format: public_key,secret_key
mkdir -p ~/.unsandbox
echo "unsb-pk-xxxx-xxxx-xxxx-xxxx,unsb-sk-xxxxx-xxxxx-xxxxx-xxxxx" > ~/.unsandbox/accounts.csv
./un script.py
As requisições são assinadas com HMAC-SHA256. O token bearer contém apenas a chave pública; a chave secreta calcula a assinatura (nunca é transmitida).
Escalonamento de Recursos
Definir Quantidade de vCPU
# Default: 1 vCPU, 2GB RAM
./un script.py
# Scale up: 4 vCPUs, 8GB RAM
./un -v 4 script.py
# Maximum: 8 vCPUs, 16GB RAM
./un --vcpu 8 heavy_compute.py
Reforço de Sessão Ao Vivo
# Boost a running session to 2 vCPU, 4GB RAM
./un session --boost sandbox-abc
# Boost to specific vCPU count (4 vCPU, 8GB RAM)
./un session --boost sandbox-abc --boost-vcpu 4
# Return to base resources (1 vCPU, 2GB RAM)
./un session --unboost sandbox-abc
Congelar/Descongelar Sessão
Congelar e Descongelar Sessões
# Freeze a session (stop billing, preserve state)
./un session --freeze sandbox-abc
# Unfreeze a frozen session
./un session --unfreeze sandbox-abc
# Note: Requires --tmux or --screen for persistence
Serviços Persistentes
Criar um Serviço
# Web server with ports
./un service --name web --ports 80,443 --bootstrap "python -m http.server 80"
# With custom domains
./un service --name blog --ports 8000 --domains blog.example.com
# Game server with SRV records
./un service --name mc --type minecraft --bootstrap ./setup.sh
# Deploy app tarball with bootstrap script
./un service --name app --ports 8000 -f app.tar.gz --bootstrap-file ./setup.sh
# setup.sh: cd /tmp && tar xzf app.tar.gz && ./app/start.sh
Gerenciar Serviços
# List all services
./un service --list
# Get service details
./un service --info abc123
# View bootstrap logs
./un service --logs abc123
./un service --tail abc123 # last 9000 lines
# Execute command in running service
./un service --execute abc123 'journalctl -u myapp -n 50'
# Dump bootstrap script (for migrations)
./un service --dump-bootstrap abc123
./un service --dump-bootstrap abc123 backup.sh
# Freeze/unfreeze service
./un service --freeze abc123
./un service --unfreeze abc123
# Service settings (auto-wake, freeze page display)
./un service --auto-unfreeze abc123 # enable auto-wake on HTTP
./un service --no-auto-unfreeze abc123 # disable auto-wake
./un service --show-freeze-page abc123 # show HTML payment page (default)
./un service --no-show-freeze-page abc123 # return JSON error instead
# Redeploy with new bootstrap
./un service --redeploy abc123 --bootstrap ./new-setup.sh
# Destroy service
./un service --destroy abc123
Snapshots
Listar Snapshots
./un snapshot --list
# Output:
# Snapshots: 3
#
# SNAPSHOT ID NAME SOURCE SIZE CREATED
# unsb-snapshot-a1b2-c3d4-e5f6-g7h8 before-upgrade session 512 MB 2h ago
# unsb-snapshot-i9j0-k1l2-m3n4-o5p6 stable-v1.0 service 1.2 GB 1d ago
Criar Snapshot da Sessão
# Snapshot with name
./un session --snapshot unsb-vm-12345 --name "before upgrade"
# Quick snapshot (auto-generated name)
./un session --snapshot unsb-vm-12345
Criar Snapshot do Serviço
# Standard snapshot (pauses container briefly)
./un service --snapshot unsb-service-abc123 --name "stable v1.0"
# Hot snapshot (no pause, may be inconsistent)
./un service --snapshot unsb-service-abc123 --hot
Restaurar a partir do Snapshot
# Restore session from snapshot
./un session --restore unsb-snapshot-a1b2-c3d4-e5f6-g7h8
# Restore service from snapshot
./un service --restore unsb-snapshot-i9j0-k1l2-m3n4-o5p6
Excluir Snapshot
./un snapshot --delete unsb-snapshot-a1b2-c3d4-e5f6-g7h8
Imagens
Imagens são imagens de container independentes e transferíveis que sobrevivem à exclusão do container. Diferente dos snapshots (que permanecem com seu container), imagens podem ser compartilhadas com outros usuários, transferidas entre chaves de API ou tornadas públicas no marketplace.
Listar Imagens
# List all images (owned + shared + public)
./un image --list
# List only your images
./un image --list owned
# List images shared with you
./un image --list shared
# List public marketplace images
./un image --list public
# Get image details
./un image --info unsb-image-xxxx-xxxx-xxxx-xxxx
Publicar Imagens
# Publish from a stopped or frozen service
./un image --publish-service unsb-service-abc123 \
--name "My App v1.0" --description "Production snapshot"
# Publish from a snapshot
./un image --publish-snapshot unsb-snapshot-xxxx-xxxx-xxxx-xxxx \
--name "Stable Release"
# Note: Cannot publish from running containers - stop or freeze first
Criar Serviços a partir de Imagens
# Spawn a new service from an image
./un image --spawn unsb-image-xxxx-xxxx-xxxx-xxxx \
--name new-service --ports 80,443
# Clone an image (creates a copy you own)
./un image --clone unsb-image-xxxx-xxxx-xxxx-xxxx
Proteção de Imagem
# Lock image to prevent accidental deletion
./un image --lock unsb-image-xxxx-xxxx-xxxx-xxxx
# Unlock image to allow deletion
./un image --unlock unsb-image-xxxx-xxxx-xxxx-xxxx
# Delete image (must be unlocked)
./un image --delete unsb-image-xxxx-xxxx-xxxx-xxxx
Visibilidade e Compartilhamento
# Set visibility level
./un image --visibility unsb-image-xxxx-xxxx-xxxx-xxxx private # owner only (default)
./un image --visibility unsb-image-xxxx-xxxx-xxxx-xxxx unlisted # can be shared
./un image --visibility unsb-image-xxxx-xxxx-xxxx-xxxx public # marketplace
# Share with specific user
./un image --grant unsb-image-xxxx-xxxx-xxxx-xxxx \
--key unsb-pk-friend-friend-friend-friend
# Revoke access
./un image --revoke unsb-image-xxxx-xxxx-xxxx-xxxx \
--key unsb-pk-friend-friend-friend-friend
# List who has access
./un image --trusted unsb-image-xxxx-xxxx-xxxx-xxxx
Transferir Propriedade
# Transfer image to another API key
./un image --transfer unsb-image-xxxx-xxxx-xxxx-xxxx \
--to unsb-pk-newowner-newowner-newowner-newowner
Referência de uso
Usage: ./un [options] <source_file>
./un session [options]
./un service [options]
./un snapshot [options]
./un image [options]
./un key
Commands:
(default) Execute source file in sandbox
session Open interactive shell/REPL session
service Manage persistent services
snapshot Manage container snapshots
image Manage container images (publish, share, transfer)
key Check API key validity and expiration
Options:
-e KEY=VALUE Set environment variable (can use multiple times)
-f FILE Add input file (can use multiple times)
-a Return and save artifacts from /tmp/artifacts/
-o DIR Output directory for artifacts (default: current dir)
-p KEY Public key (or set UNSANDBOX_PUBLIC_KEY env var)
-k KEY Secret key (or set UNSANDBOX_SECRET_KEY env var)
-n MODE Network mode: zerotrust (default) or semitrusted
-v N, --vcpu N vCPU count 1-8, each vCPU gets 2GB RAM (default: 1)
-y Skip confirmation for large uploads (>1GB)
-h Show this help
Authentication (priority order):
1. -p and -k flags (public and secret key)
2. UNSANDBOX_PUBLIC_KEY + UNSANDBOX_SECRET_KEY env vars
3. ~/.unsandbox/accounts.csv (format: public_key,secret_key per line)
Session options:
-s, --shell SHELL Shell/REPL to use (default: bash)
-l, --list List active sessions
--attach ID Reconnect to existing session (ID or container name)
--kill ID Terminate a session (ID or container name)
--freeze ID Freeze a session (requires --tmux/--screen)
--unfreeze ID Unfreeze a frozen session
--boost ID Boost session resources (2 vCPU, 4GB RAM)
--boost-vcpu N Specify vCPU count for boost (1-8)
--unboost ID Return to base resources
--audit Record full session for auditing
--tmux Enable session persistence with tmux (allows reconnect)
--screen Enable session persistence with screen (allows reconnect)
Service options:
--name NAME Service name (creates new service)
--ports PORTS Comma-separated ports (e.g., 80,443)
--domains DOMAINS Custom domains (e.g., example.com,www.example.com)
--type TYPE Service type: minecraft, mumble, teamspeak, source, tcp, udp
--bootstrap CMD Bootstrap command/file/URL to run on startup
-f FILE Upload file to /tmp/ (can use multiple times)
-l, --list List all services
--info ID Get service details
--tail ID Get last 9000 lines of bootstrap logs
--logs ID Get all bootstrap logs
--freeze ID Freeze a service
--unfreeze ID Unfreeze a service
--auto-unfreeze ID Enable auto-wake on HTTP request
--no-auto-unfreeze ID Disable auto-wake on HTTP request
--show-freeze-page ID Show HTML payment page when frozen (default)
--no-show-freeze-page ID Return JSON error when frozen
--destroy ID Destroy a service
--redeploy ID Re-run bootstrap script (requires --bootstrap)
--execute ID CMD Run a command in a running service
--dump-bootstrap ID [FILE] Dump bootstrap script (for migrations)
--snapshot ID Create snapshot of session or service
--snapshot-name User-friendly name for snapshot
--hot Create snapshot without pausing (may be inconsistent)
--restore ID Restore session/service from snapshot ID
Snapshot options:
-l, --list List all snapshots
--info ID Get snapshot details
--delete ID Delete a snapshot permanently
Image options:
-l, --list [owned|shared|public] List images (all, owned, shared, or public)
--info ID Get image details
--publish-service ID Publish image from stopped/frozen service
--publish-snapshot ID Publish image from snapshot
--name NAME Name for published image
--description DESC Description for published image
--delete ID Delete image (must be unlocked)
--clone ID Clone image (creates copy you own)
--spawn ID Create service from image (requires --name)
--lock ID Lock image to prevent deletion
--unlock ID Unlock image to allow deletion
--visibility ID LEVEL Set visibility (private|unlisted|public)
--grant ID --key KEY Grant access to another API key
--revoke ID --key KEY Revoke access from API key
--transfer ID --to KEY Transfer ownership to API key
--trusted ID List API keys with access
Key options:
(no options) Check API key validity
--extend Open portal to extend an expired key
Examples:
./un script.py # execute Python script
./un -e DEBUG=1 script.py # with environment variable
./un -f data.csv process.py # with input file
./un -a -o ./bin main.c # save compiled artifacts
./un -v 4 heavy.py # with 4 vCPUs, 8GB RAM
./un session # interactive bash session
./un session --tmux # bash with reconnect support
./un session --list # list active sessions
./un session --attach unsb-vm-12345 # reconnect to session
./un session --kill unsb-vm-12345 # terminate a session
./un session --freeze unsb-vm-12345 # freeze session
./un session --unfreeze unsb-vm-12345 # unfreeze session
./un session --boost unsb-vm-12345 # boost resources
./un session --unboost unsb-vm-12345 # return to base
./un session --shell python3 # Python REPL
./un session --shell node # Node.js REPL
./un session -n semitrusted # session with network access
./un session --audit -o ./logs # record session for auditing
./un service --name web --ports 80 # create web service
./un service --list # list all services
./un service --logs abc123 # view bootstrap logs
./un key # check API key
./un key --extend # extend expired key
./un snapshot --list # list all snapshots
./un session --snapshot unsb-vm-123 # snapshot a session
./un service --snapshot abc123 # snapshot a service
./un session --restore unsb-snapshot-xxxx # restore from snapshot
./un image --list # list all images
./un image --list owned # list your images
./un image --publish-service abc # publish image from service
./un image --spawn img123 --name x # create service from image
./un image --grant img --key pk # share image with user
CLI Inception
O UN CLI foi implementado em 42 linguagens de programação, demonstrando que a API do unsandbox pode ser acessada de praticamente qualquer ambiente.
Ver Todas as 42 Implementações →
Licença
DOMÍNIO PÚBLICO - SEM LICENÇA, SEM GARANTIA
Este é software gratuito de domínio público para o bem público de um permacomputador hospedado
em permacomputer.com - um computador sempre ativo pelo povo, para o povo. Um que é
durável, fácil de reparar e distribuído como água da torneira para inteligência de
aprendizado de máquina.
O permacomputador é infraestrutura de propriedade comunitária otimizada em torno de quatro valores:
VERDADE - Primeiros princípios, matemática & ciência, código aberto distribuído livremente
LIBERDADE - Parcerias voluntárias, liberdade da tirania e controle corporativo
HARMONIA - Desperdício mínimo, sistemas auto-renováveis com diversas conexões prósperas
AMOR - Seja você mesmo sem ferir os outros, cooperação através da lei natural
Este software contribui para essa visão ao permitir a execução de código em mais de 42
linguagens de programação através de uma interface unificada, acessível a todos. Código são
sementes que brotam em qualquer tecnologia abandonada.
Saiba mais: https://www.permacomputer.com
Qualquer pessoa é livre para copiar, modificar, publicar, usar, compilar, vender ou distribuir
este software, seja em forma de código-fonte ou como binário compilado, para qualquer propósito,
comercial ou não comercial, e por qualquer meio.
SEM GARANTIA. O SOFTWARE É FORNECIDO "COMO ESTÁ" SEM GARANTIA DE QUALQUER TIPO.
Dito isso, a camada de membrana digital do nosso permacomputador executa continuamente testes
unitários, de integração e funcionais em todo o seu próprio software - com nosso permacomputador
monitorando a si mesmo, reparando a si mesmo, com orientação humana mínima no ciclo.
Nossos agentes fazem o seu melhor.
Copyright 2025 TimeHexOn & foxhop & russell@unturf
https://www.timehexon.com
https://www.foxhop.net
https://www.unturf.com/software