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.
This commit is contained in:
russell@unturf.com 2026-03-23 15:14:36 -04:00
parent a839ffcd12
commit 13f15c8abc
5 changed files with 512 additions and 129 deletions

View file

@ -51,8 +51,10 @@ defmodule Un do
Credentials are loaded in priority order: Credentials are loaded in priority order:
1. Function arguments (public_key, secret_key) 1. Function arguments (public_key, secret_key)
2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) 2. --account N -> accounts.csv row N (bypasses env vars)
3. Config file (~/.unsandbox/accounts.csv) 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" @blue "\e[34m"
@ -1112,25 +1114,50 @@ defmodule Un do
# CLI Entry Point # CLI Entry Point
# ============================================================================ # ============================================================================
def main([]), do: print_usage() def main(raw_args) do
def main(["session" | rest]), do: session_command(rest) {account_index, args} = extract_account_arg(raw_args, nil, [])
def main(["service" | rest]), do: service_command(rest) if account_index != nil do
def main(["snapshot" | rest]), do: snapshot_command(rest) Process.put(:account_index, account_index)
def main(["image" | rest]), do: image_command(rest) end
def main(["key" | rest]), do: key_command(rest) dispatch(args)
def main(["languages" | rest]), do: languages_command(rest) end
def main(args), do: execute_command(args)
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 defp print_usage do
IO.puts("Usage: un.ex [options] <source_file>") IO.puts("Usage: un.ex [--account N] [options] <source_file>")
IO.puts(" un.ex session [options]") IO.puts(" un.ex [--account N] session [options]")
IO.puts(" un.ex service [options]") IO.puts(" un.ex [--account N] service [options]")
IO.puts(" un.ex service env <action> <service_id>") IO.puts(" un.ex [--account N] service env <action> <service_id>")
IO.puts(" un.ex snapshot [options]") IO.puts(" un.ex [--account N] snapshot [options]")
IO.puts(" un.ex image [options]") IO.puts(" un.ex [--account N] image [options]")
IO.puts(" un.ex key [--extend]") IO.puts(" un.ex [--account N] key [--extend]")
IO.puts(" un.ex languages [--json]") IO.puts(" un.ex languages [--json]")
IO.puts("") 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("Service options: --name, --ports, --bootstrap, -e KEY=VALUE, --env-file FILE")
IO.puts(" --set-unfreeze-on-demand ID true|false") IO.puts(" --set-unfreeze-on-demand ID true|false")
IO.puts("Service env commands: status, set, export, delete") IO.puts("Service env commands: status, set, export, delete")
@ -1913,11 +1940,46 @@ defmodule Un do
end end
# Helpers # 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 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") public_key = System.get_env("UNSANDBOX_PUBLIC_KEY")
secret_key = System.get_env("UNSANDBOX_SECRET_KEY") secret_key = System.get_env("UNSANDBOX_SECRET_KEY")
# Fall back to UNSANDBOX_API_KEY for backwards compatibility
api_key = System.get_env("UNSANDBOX_API_KEY") api_key = System.get_env("UNSANDBOX_API_KEY")
cond do cond do
@ -1926,10 +1988,39 @@ defmodule Un do
api_key -> api_key ->
{api_key, nil} {api_key, nil}
true -> 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)") IO.puts(:stderr, "Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set (or UNSANDBOX_API_KEY for backwards compat)")
System.halt(1) System.halt(1)
end end
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 defp get_api_key do
{public_key, _} = get_api_keys() {public_key, _} = get_api_keys()

View file

@ -56,8 +56,10 @@
%%% %%%
%%% Authentication Priority: %%% Authentication Priority:
%%% 1. Function arguments (PublicKey, SecretKey) %%% 1. Function arguments (PublicKey, SecretKey)
%%% 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) %%% 2. --account N -> accounts.csv row N (bypasses env vars)
%%% 3. Config file (~/.unsandbox/accounts.csv) %%% 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(API_BASE, "https://api.unsandbox.com").
-define(PORTAL_BASE, "https://unsandbox.com"). -define(PORTAL_BASE, "https://unsandbox.com").
@ -719,37 +721,60 @@ not_contains_error(Response) ->
%% CLI Entry Point %% CLI Entry Point
%% ============================================================================ %% ============================================================================
main([]) -> main(RawArgs) ->
io:format("Usage: un.erl [options] <source_file>~n"), %% Strip --account N from args and store index in process dict before dispatch
io:format(" un.erl session [options]~n"), {AccountIndex, Args} = extract_account_arg(RawArgs, undefined, []),
io:format(" un.erl service [options]~n"), case AccountIndex of
io:format(" un.erl snapshot [options]~n"), undefined -> ok;
io:format(" un.erl image [options]~n"), N -> erlang:put(account_index, N)
io:format(" un.erl key [options]~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(" 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); halt(1);
main(["session" | Rest]) -> dispatch(["session" | Rest]) ->
session_command(Rest); session_command(Rest);
main(["service" | Rest]) -> dispatch(["service" | Rest]) ->
service_command(Rest); service_command(Rest);
main(["snapshot" | Rest]) -> dispatch(["snapshot" | Rest]) ->
snapshot_command(Rest); snapshot_command(Rest);
main(["image" | Rest]) -> dispatch(["image" | Rest]) ->
image_command(Rest); image_command(Rest);
main(["key" | Rest]) -> dispatch(["key" | Rest]) ->
key_command(Rest); key_command(Rest);
main(["languages" | Rest]) -> dispatch(["languages" | Rest]) ->
languages_command(Rest); languages_command(Rest);
main(Args) -> dispatch(Args) ->
execute_command(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
execute_command(Args) -> execute_command(Args) ->
{File, _Opts} = parse_exec_args(Args, #{file => undefined}), {File, _Opts} = parse_exec_args(Args, #{file => undefined}),
@ -1452,19 +1477,96 @@ open_extend_page(PublicKey) ->
end. end.
%% Helpers %% 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() -> 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"), PublicKey = os:getenv("UNSANDBOX_PUBLIC_KEY"),
SecretKey = os:getenv("UNSANDBOX_SECRET_KEY"), SecretKey = os:getenv("UNSANDBOX_SECRET_KEY"),
ApiKey = os:getenv("UNSANDBOX_API_KEY"), ApiKey = os:getenv("UNSANDBOX_API_KEY"),
if if
PublicKey =/= false andalso SecretKey =/= false -> PublicKey =/= false andalso SecretKey =/= false ->
{PublicKey, SecretKey}; {PublicKey, SecretKey};
ApiKey =/= false -> ApiKey =/= false ->
{ApiKey, false}; {ApiKey, false};
true -> 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"), io:format("Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set (or UNSANDBOX_API_KEY for backwards compat)~n"),
halt(1) 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. end.
get_api_key() -> get_api_key() ->

View file

@ -77,6 +77,7 @@ type Args = {
mutable Command: string option mutable Command: string option
mutable SourceFile: string option mutable SourceFile: string option
mutable ApiKey: string option mutable ApiKey: string option
mutable AccountIndex: int option
mutable Network: string option mutable Network: string option
mutable Vcpu: int mutable Vcpu: int
Env: ResizeArray<string> Env: ResizeArray<string>
@ -144,19 +145,70 @@ type Args = {
mutable ImagePorts: string option mutable ImagePorts: string option
} }
let getApiKeys (argsKey: string option) = let loadCredentialsFromCsv (csvPath: string) (accountIndex: int) =
if File.Exists(csvPath) then
try
let lines = File.ReadAllLines(csvPath)
let accounts =
lines
|> Array.map (fun l -> l.Trim())
|> Array.filter (fun l -> l.Length > 0 && not (l.StartsWith("#")))
|> Array.choose (fun line ->
let parts = line.Split(',')
if parts.Length >= 2 then
let pk = parts.[0].Trim()
let sk = parts.[1].Trim()
if pk.Length > 8 && sk.Length > 8 then Some (pk, sk)
else None
else None)
if accountIndex < accounts.Length then Some accounts.[accountIndex]
else None
with _ -> None
else None
let getApiKeys (argsKey: string option) (accountIndex: int option) =
let home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)
let homeCsv = Path.Combine(home, ".unsandbox", "accounts.csv")
// Priority 1: --account N -> accounts.csv row N (bypasses env vars)
match accountIndex with
| Some idx ->
let creds =
match loadCredentialsFromCsv homeCsv idx with
| Some c -> Some c
| None -> loadCredentialsFromCsv "accounts.csv" idx
match creds with
| Some (pk, sk) -> (pk, sk)
| None ->
eprintfn "%sError: No credentials found for account index %d in accounts.csv%s" red idx reset
exit 1
| None ->
let publicKey = Environment.GetEnvironmentVariable("UNSANDBOX_PUBLIC_KEY") let publicKey = Environment.GetEnvironmentVariable("UNSANDBOX_PUBLIC_KEY")
let secretKey = Environment.GetEnvironmentVariable("UNSANDBOX_SECRET_KEY") let secretKey = Environment.GetEnvironmentVariable("UNSANDBOX_SECRET_KEY")
// Fall back to UNSANDBOX_API_KEY for backwards compatibility // Priority 2: environment variables
if String.IsNullOrEmpty(publicKey) || String.IsNullOrEmpty(secretKey) then if not (String.IsNullOrEmpty(publicKey)) && not (String.IsNullOrEmpty(secretKey)) then
(publicKey, secretKey)
else
// Fall back to legacy UNSANDBOX_API_KEY
let legacyKey = match argsKey with | Some k -> k | None -> Environment.GetEnvironmentVariable("UNSANDBOX_API_KEY") let legacyKey = match argsKey with | Some k -> k | None -> Environment.GetEnvironmentVariable("UNSANDBOX_API_KEY")
if String.IsNullOrEmpty(legacyKey) then if not (String.IsNullOrEmpty(legacyKey)) then
eprintfn "%sError: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set%s" red reset
exit 1
(legacyKey, null) (legacyKey, null)
else else
(publicKey, secretKey) // Priority 3: ~/.unsandbox/accounts.csv (or UNSANDBOX_ACCOUNT index)
let defaultIndex =
let envIdx = Environment.GetEnvironmentVariable("UNSANDBOX_ACCOUNT")
if String.IsNullOrEmpty(envIdx) then 0
else match System.Int32.TryParse(envIdx) with | (true, n) -> n | _ -> 0
let creds =
match loadCredentialsFromCsv homeCsv defaultIndex with
| Some c -> Some c
| None -> loadCredentialsFromCsv "accounts.csv" defaultIndex
match creds with
| Some (pk, sk) -> (pk, sk)
| None ->
eprintfn "%sError: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set%s" red reset
exit 1
let detectLanguage (filename: string) = let detectLanguage (filename: string) =
let dotIndex = filename.LastIndexOf('.') let dotIndex = filename.LastIndexOf('.')
@ -631,7 +683,7 @@ let cmdServiceEnv (args: Args) (publicKey: string) (secretKey: string) =
exit 1 exit 1
let cmdExecute (args: Args) = let cmdExecute (args: Args) =
let (publicKey, secretKey) = getApiKeys args.ApiKey let (publicKey, secretKey) = getApiKeys args.ApiKey args.AccountIndex
let code = File.ReadAllText(args.SourceFile.Value) let code = File.ReadAllText(args.SourceFile.Value)
let language = detectLanguage args.SourceFile.Value let language = detectLanguage args.SourceFile.Value
@ -680,7 +732,7 @@ let cmdExecute (args: Args) =
exit exitCode exit exitCode
let cmdSession (args: Args) = let cmdSession (args: Args) =
let (publicKey, secretKey) = getApiKeys args.ApiKey let (publicKey, secretKey) = getApiKeys args.ApiKey args.AccountIndex
if args.SessionSnapshot.IsSome then if args.SessionSnapshot.IsSome then
let mutable payload = [] let mutable payload = []
@ -740,7 +792,7 @@ let openBrowser (url: string) =
eprintfn "%sError opening browser: %s%s" red ex.Message reset eprintfn "%sError opening browser: %s%s" red ex.Message reset
let cmdKey (args: Args) = let cmdKey (args: Args) =
let (publicKey, secretKey) = getApiKeys args.ApiKey let (publicKey, secretKey) = getApiKeys args.ApiKey args.AccountIndex
ServicePointManager.SecurityProtocol <- SecurityProtocolType.Tls12 ||| SecurityProtocolType.Tls11 ||| SecurityProtocolType.Tls ServicePointManager.SecurityProtocol <- SecurityProtocolType.Tls12 ||| SecurityProtocolType.Tls11 ||| SecurityProtocolType.Tls
@ -817,7 +869,7 @@ let cmdKey (args: Args) =
exit 1 exit 1
let cmdLanguages (args: Args) = let cmdLanguages (args: Args) =
let (publicKey, secretKey) = getApiKeys args.ApiKey let (publicKey, secretKey) = getApiKeys args.ApiKey args.AccountIndex
// Try to load from cache first // Try to load from cache first
let cachedResponse = loadLanguagesCache () let cachedResponse = loadLanguagesCache ()
@ -872,7 +924,7 @@ let cmdLanguages (args: Args) =
printfn "%s" lang printfn "%s" lang
let cmdImage (args: Args) = let cmdImage (args: Args) =
let (publicKey, secretKey) = getApiKeys args.ApiKey let (publicKey, secretKey) = getApiKeys args.ApiKey args.AccountIndex
if args.ImageList then if args.ImageList then
let result = apiRequest "/images" "GET" None publicKey secretKey let result = apiRequest "/images" "GET" None publicKey secretKey
@ -928,7 +980,7 @@ let cmdImage (args: Args) =
exit 1 exit 1
let cmdSnapshot (args: Args) = let cmdSnapshot (args: Args) =
let (publicKey, secretKey) = getApiKeys args.ApiKey let (publicKey, secretKey) = getApiKeys args.ApiKey args.AccountIndex
if args.SnapshotList then if args.SnapshotList then
let result = apiRequest "/snapshots" "GET" None publicKey secretKey let result = apiRequest "/snapshots" "GET" None publicKey secretKey
@ -959,7 +1011,7 @@ let cmdSnapshot (args: Args) =
exit 1 exit 1
let cmdService (args: Args) = let cmdService (args: Args) =
let (publicKey, secretKey) = getApiKeys args.ApiKey let (publicKey, secretKey) = getApiKeys args.ApiKey args.AccountIndex
// Handle env subcommand // Handle env subcommand
if args.EnvAction.IsSome then if args.EnvAction.IsSome then
@ -1107,6 +1159,7 @@ let parseArgs (argv: string[]) =
Command = None Command = None
SourceFile = None SourceFile = None
ApiKey = None ApiKey = None
AccountIndex = None
Network = None Network = None
Vcpu = 0 Vcpu = 0
Env = ResizeArray<string>() Env = ResizeArray<string>()
@ -1192,6 +1245,13 @@ let parseArgs (argv: string[]) =
i <- i + 1 i <- i + 1
args.EnvTarget <- Some argv.[i] args.EnvTarget <- Some argv.[i]
| "-k" | "--api-key" -> i <- i + 1; args.ApiKey <- Some argv.[i] | "-k" | "--api-key" -> i <- i + 1; args.ApiKey <- Some argv.[i]
| "--account" ->
i <- i + 1
match System.Int32.TryParse(argv.[i]) with
| (true, n) -> args.AccountIndex <- Some n
| _ ->
eprintfn "Error: --account requires an integer argument"
Environment.Exit(1)
| "-n" | "--network" -> i <- i + 1; args.Network <- Some argv.[i] | "-n" | "--network" -> i <- i + 1; args.Network <- Some argv.[i]
| "-v" | "--vcpu" -> i <- i + 1; args.Vcpu <- int argv.[i] | "-v" | "--vcpu" -> i <- i + 1; args.Vcpu <- int argv.[i]
| "-e" | "--env" -> i <- i + 1; args.Env.Add(argv.[i]) | "-e" | "--env" -> i <- i + 1; args.Env.Add(argv.[i])
@ -1358,6 +1418,7 @@ let printHelp () =
printfn " -n MODE Network mode (zerotrust/semitrusted)" printfn " -n MODE Network mode (zerotrust/semitrusted)"
printfn " -v N vCPU count (1-8)" printfn " -v N vCPU count (1-8)"
printfn " -k KEY API key" printfn " -k KEY API key"
printfn " --account N Use accounts.csv row N (bypasses env vars)"
printfn "" printfn ""
printfn "Session options:" printfn "Session options:"
printfn " --list List active sessions" printfn " --list List active sessions"

View file

@ -66,6 +66,8 @@ import Data.Char (isDigit, ord)
import Text.Printf (printf) import Text.Printf (printf)
import Control.Monad (when, unless, forM_) import Control.Monad (when, unless, forM_)
import Control.Exception (try, catch, IOError) import Control.Exception (try, catch, IOError)
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import System.IO.Unsafe (unsafePerformIO)
import qualified Data.ByteString as BS import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BSC import qualified Data.ByteString.Char8 as BSC
import qualified Data.ByteString.Base64 as B64 import qualified Data.ByteString.Base64 as B64
@ -86,6 +88,11 @@ portalBase = "https://unsandbox.com"
languagesCacheTtl :: Int languagesCacheTtl :: Int
languagesCacheTtl = 3600 -- 1 hour in seconds languagesCacheTtl = 3600 -- 1 hour in seconds
-- Global account index set by --account N flag (Nothing = not set)
{-# NOINLINE cliAccountIndex #-}
cliAccountIndex :: IORef (Maybe Int)
cliAccountIndex = unsafePerformIO (newIORef Nothing)
-- ANSI colors -- ANSI colors
blue, red, green, yellow, reset :: String blue, red, green, yellow, reset :: String
blue = "\x1b[34m" blue = "\x1b[34m"
@ -838,10 +845,26 @@ threadDelay us = do
_ <- readProcessWithExitCode "sleep" [show (fromIntegral ms / 1000.0 :: Double)] "" _ <- readProcessWithExitCode "sleep" [show (fromIntegral ms / 1000.0 :: Double)] ""
return () return ()
-- Strip --account N from argument list, set cliAccountIndex IORef
stripAccountArg :: [String] -> IO [String]
stripAccountArg [] = return []
stripAccountArg ("--account":n_str:rest) = do
case reads n_str of
[(n, "")] -> do
writeIORef cliAccountIndex (Just n)
stripAccountArg rest
_ -> do
hPutStrLn stderr "Error: --account requires an integer argument"
exitFailure
stripAccountArg (arg:rest) = do
rest' <- stripAccountArg rest
return (arg : rest')
-- Main -- Main
main :: IO () main :: IO ()
main = do main = do
args <- getArgs rawArgs <- getArgs
args <- stripAccountArg rawArgs
cmd <- parseArgs args cmd <- parseArgs args
case cmd of case cmd of
Execute opts -> executeCommand opts Execute opts -> executeCommand opts
@ -865,6 +888,9 @@ printHelp = do
putStrLn " un.hs languages [--json] List available languages" putStrLn " un.hs languages [--json] List available languages"
putStrLn " un.hs key [options] Validate/extend API key" putStrLn " un.hs key [options] Validate/extend API key"
putStrLn "" putStrLn ""
putStrLn "Global options:"
putStrLn " --account N Use accounts.csv row N (bypasses env vars)"
putStrLn ""
putStrLn "Execute options:" putStrLn "Execute options:"
putStrLn " -e KEY=VALUE Environment variable" putStrLn " -e KEY=VALUE Environment variable"
putStrLn " -f FILE Input file" putStrLn " -f FILE Input file"
@ -1433,9 +1459,56 @@ serviceEnvDelete serviceId = do
(exitCode, _, _) <- curlDelete apiKey (apiBase ++ "/services/" ++ serviceId ++ "/env") (exitCode, _, _) <- curlDelete apiKey (apiBase ++ "/services/" ++ serviceId ++ "/env")
return (exitCode == ExitSuccess) return (exitCode == ExitSuccess)
-- Get API keys from environment -- Load credentials from a CSV file at a given account index
loadCredentialsFromCsv :: FilePath -> Int -> IO (Maybe (String, String))
loadCredentialsFromCsv csvPath accountIndex = do
result <- (try (readFile csvPath) :: IO (Either IOError String))
case result of
Left _ -> return Nothing
Right content -> do
let ls = filter (\l -> not (null l) && head l /= '#') $
map trim $
lines content
accounts = [ (pk', sk')
| l <- ls
, let (pk, rest) = break (== ',') l
, not (null rest)
, let pk' = trim pk
sk' = trim (drop 1 rest)
, length pk' > 8 && length sk' > 8
]
if accountIndex < length accounts then
return $ Just (accounts !! accountIndex)
else
return Nothing
where
trim = reverse . dropWhile (== ' ') . reverse . dropWhile (== ' ')
-- Get API keys with correct priority:
-- 1. --account N (cliAccountIndex IORef) -> 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
getApiKeys :: IO (String, Maybe String) getApiKeys :: IO (String, Maybe String)
getApiKeys = do getApiKeys = do
home <- maybe "." id <$> lookupEnv "HOME"
let homeCsv = home ++ "/.unsandbox/accounts.csv"
-- Priority 1: --account N
mIdx <- readIORef cliAccountIndex
case mIdx of
Just idx -> do
creds <- loadCredentialsFromCsv homeCsv idx
case creds of
Just (pk, sk) -> return (pk, Just sk)
Nothing -> do
creds2 <- loadCredentialsFromCsv "accounts.csv" idx
case creds2 of
Just (pk, sk) -> return (pk, Just sk)
Nothing -> do
hPutStrLn stderr $ "Error: No credentials found for account index " ++ show idx ++ " in accounts.csv"
exitFailure
Nothing -> do
-- Priority 2: environment variables
publicKey <- lookupEnv "UNSANDBOX_PUBLIC_KEY" publicKey <- lookupEnv "UNSANDBOX_PUBLIC_KEY"
secretKey <- lookupEnv "UNSANDBOX_SECRET_KEY" secretKey <- lookupEnv "UNSANDBOX_SECRET_KEY"
apiKey <- lookupEnv "UNSANDBOX_API_KEY" apiKey <- lookupEnv "UNSANDBOX_API_KEY"
@ -1443,6 +1516,18 @@ getApiKeys = do
(Just pk, Just sk, _) -> return (pk, Just sk) (Just pk, Just sk, _) -> return (pk, Just sk)
(_, _, Just ak) -> return (ak, Nothing) (_, _, Just ak) -> return (ak, Nothing)
_ -> do _ -> do
-- Priority 3: ~/.unsandbox/accounts.csv (or UNSANDBOX_ACCOUNT index)
defaultIndexStr <- lookupEnv "UNSANDBOX_ACCOUNT"
let defaultIndex = maybe 0 (\s -> case reads s of [(n,"")] -> n; _ -> 0) defaultIndexStr
creds <- loadCredentialsFromCsv homeCsv defaultIndex
case creds of
Just (pk, sk) -> return (pk, Just sk)
Nothing -> do
-- Priority 4: ./accounts.csv
creds2 <- loadCredentialsFromCsv "accounts.csv" defaultIndex
case creds2 of
Just (pk, sk) -> return (pk, Just sk)
Nothing -> do
hPutStrLn stderr "Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set (or UNSANDBOX_API_KEY for backwards compat)" hPutStrLn stderr "Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set (or UNSANDBOX_API_KEY for backwards compat)"
exitFailure exitFailure

View file

@ -281,39 +281,53 @@ let extract_json_int json_str key =
Credentials Management Credentials Management
============================================================================ *) ============================================================================ *)
(** Get credentials from config file ~/.unsandbox/accounts.csv *) (** Global account index set by --account N CLI flag; -1 means not set *)
let get_credentials_from_file ?(account_index=0) () = let cli_account_index = ref (-1)
let home = try Sys.getenv "HOME" with Not_found -> "." in
let accounts_path = Filename.concat home ".unsandbox/accounts.csv" in (** Parse accounts from CSV content, return list of (pk, sk) pairs *)
if Sys.file_exists accounts_path then let parse_accounts_csv content =
try
let content = read_file accounts_path in
let lines = String.split_on_char '\n' content in let lines = String.split_on_char '\n' content in
let valid_accounts = List.filter_map (fun line -> List.filter_map (fun line ->
let line = String.trim line in let line = String.trim line in
if String.length line = 0 || line.[0] = '#' then None if String.length line = 0 || line.[0] = '#' then None
else else
try try
let comma_pos = String.index line ',' in let comma_pos = String.index line ',' in
let pk = String.sub line 0 comma_pos in let pk = String.trim (String.sub line 0 comma_pos) in
let sk = String.sub line (comma_pos + 1) (String.length line - comma_pos - 1) in let sk = String.trim (String.sub line (comma_pos + 1) (String.length line - comma_pos - 1)) in
if String.length pk > 8 && String.sub pk 0 8 = "unsb-pk-" && if String.length pk > 8 && String.length sk > 8 then
String.length sk > 8 && String.sub sk 0 8 = "unsb-sk-" then
Some (pk, sk) Some (pk, sk)
else None else None
with Not_found -> None with Not_found -> None
) lines in ) lines
if account_index < List.length valid_accounts then
Some (List.nth valid_accounts account_index) (** Load credentials from a specific CSV path at the given account index *)
let load_csv_at path account_index =
if Sys.file_exists path then
try
let content = read_file path in
let accounts = parse_accounts_csv content in
if account_index < List.length accounts then
Some (List.nth accounts account_index)
else None else None
with _ -> None with _ -> None
else None else None
(** Get credentials from config file ~/.unsandbox/accounts.csv *)
let get_credentials_from_file ?(account_index=0) () =
let home = try Sys.getenv "HOME" with Not_found -> "." in
let home_csv = Filename.concat home ".unsandbox/accounts.csv" in
match load_csv_at home_csv account_index with
| Some _ as r -> r
| None -> load_csv_at "accounts.csv" account_index
(** (**
Get API credentials in priority order: Get API credentials in priority order:
1. Function arguments 1. Function arguments (public_key, secret_key)
2. Environment variables 2. --account N (cli_account_index ref) -> accounts.csv row N
3. ~/.unsandbox/accounts.csv 3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
4. ~/.unsandbox/accounts.csv row 0 (or UNSANDBOX_ACCOUNT env)
5. ./accounts.csv row 0
@param public_key Optional public key override @param public_key Optional public key override
@param secret_key Optional secret key override @param secret_key Optional secret key override
@ -326,18 +340,32 @@ let get_credentials ?public_key ?secret_key ?(account_index=0) () =
match (public_key, secret_key) with match (public_key, secret_key) with
| (Some pk, Some sk) -> (pk, sk) | (Some pk, Some sk) -> (pk, sk)
| _ -> | _ ->
(* Priority 2: Environment variables *) (* Priority 2: --account N CLI flag overrides env vars *)
let effective_index = if !cli_account_index >= 0 then !cli_account_index else account_index in
if !cli_account_index >= 0 then begin
match get_credentials_from_file ~account_index:effective_index () with
| Some (pk, sk) -> (pk, sk)
| None ->
Printf.fprintf stderr "Error: No credentials found for account index %d in accounts.csv\n" !cli_account_index;
exit 1
end else begin
(* Priority 3: Environment variables *)
let env_pk = try Some (Sys.getenv "UNSANDBOX_PUBLIC_KEY") with Not_found -> None in let env_pk = try Some (Sys.getenv "UNSANDBOX_PUBLIC_KEY") with Not_found -> None in
let env_sk = try Some (Sys.getenv "UNSANDBOX_SECRET_KEY") with Not_found -> None in let env_sk = try Some (Sys.getenv "UNSANDBOX_SECRET_KEY") with Not_found -> None in
match (env_pk, env_sk) with match (env_pk, env_sk) with
| (Some pk, Some sk) -> (pk, sk) | (Some pk, Some sk) -> (pk, sk)
| _ -> | _ ->
(* Priority 3: Config file *) (* Priority 4: ~/.unsandbox/accounts.csv (or UNSANDBOX_ACCOUNT index) *)
match get_credentials_from_file ~account_index () with let default_index =
try int_of_string (String.trim (Sys.getenv "UNSANDBOX_ACCOUNT"))
with Not_found | Failure _ -> 0
in
match get_credentials_from_file ~account_index:default_index () with
| Some (pk, sk) -> (pk, sk) | Some (pk, sk) -> (pk, sk)
| None -> | None ->
failwith "No credentials found. Set UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY, \ failwith "No credentials found. Set UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY, \
or create ~/.unsandbox/accounts.csv, or pass credentials to function." or create ~/.unsandbox/accounts.csv, or pass credentials to function."
end
(* Legacy function for backward compatibility *) (* Legacy function for backward compatibility *)
let get_api_keys () = let get_api_keys () =
@ -2038,18 +2066,34 @@ let image_command args =
in in
parse_args false "" "" "" "" "" "" "" "" "" "" "" "" args parse_args false "" "" "" "" "" "" "" "" "" "" "" "" args
let strip_account_arg args =
let rec aux = function
| [] -> []
| "--account" :: n_str :: rest ->
(try cli_account_index := int_of_string (String.trim n_str)
with Failure _ ->
Printf.fprintf stderr "Error: --account requires an integer argument\n";
exit 1);
aux rest
| arg :: rest -> arg :: aux rest
in
aux args
let () = let () =
Random.self_init (); Random.self_init ();
let args = Array.to_list Sys.argv in let raw_args = Array.to_list Sys.argv in
match List.tl args with let args = strip_account_arg (List.tl raw_args) in
match args with
| [] -> | [] ->
Printf.printf "Usage: un.ml [options] <source_file>\n"; Printf.printf "Usage: un.ml [--account N] [options] <source_file>\n";
Printf.printf " un.ml session [options]\n"; Printf.printf " un.ml [--account N] session [options]\n";
Printf.printf " un.ml service [options]\n"; Printf.printf " un.ml [--account N] service [options]\n";
Printf.printf " un.ml image [options]\n"; Printf.printf " un.ml [--account N] image [options]\n";
Printf.printf " un.ml service env <action> <service_id>\n"; Printf.printf " un.ml [--account N] service env <action> <service_id>\n";
Printf.printf " un.ml languages [--json]\n"; Printf.printf " un.ml languages [--json]\n";
Printf.printf " un.ml key [--extend]\n\n"; Printf.printf " un.ml key [--extend]\n\n";
Printf.printf "Global options:\n";
Printf.printf " --account N Use accounts.csv row N (bypasses env vars)\n\n";
Printf.printf "Service options: --name, --ports, --bootstrap, --bootstrap-file, -e KEY=VALUE, --env-file FILE\n"; Printf.printf "Service options: --name, --ports, --bootstrap, --bootstrap-file, -e KEY=VALUE, --env-file FILE\n";
Printf.printf "Service env commands: status, set, export, delete\n"; Printf.printf "Service env commands: status, set, export, delete\n";
Printf.printf "Image options: --list, --info ID, --delete ID, --lock ID, --unlock ID,\n"; Printf.printf "Image options: --list, --info ID, --delete ID, --lock ID, --unlock ID,\n";