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:
1. Function arguments (public_key, secret_key)
2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
3. Config file (~/.unsandbox/accounts.csv)
2. --account N -> accounts.csv row N (bypasses env vars)
3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
4. ~/.unsandbox/accounts.csv row 0 (or UNSANDBOX_ACCOUNT env)
5. ./accounts.csv row 0
"""
@blue "\e[34m"
@ -1112,25 +1114,50 @@ defmodule Un do
# CLI Entry Point
# ============================================================================
def main([]), do: print_usage()
def main(["session" | rest]), do: session_command(rest)
def main(["service" | rest]), do: service_command(rest)
def main(["snapshot" | rest]), do: snapshot_command(rest)
def main(["image" | rest]), do: image_command(rest)
def main(["key" | rest]), do: key_command(rest)
def main(["languages" | rest]), do: languages_command(rest)
def main(args), do: execute_command(args)
def main(raw_args) do
{account_index, args} = extract_account_arg(raw_args, nil, [])
if account_index != nil do
Process.put(:account_index, account_index)
end
dispatch(args)
end
defp dispatch([]), do: print_usage()
defp dispatch(["session" | rest]), do: session_command(rest)
defp dispatch(["service" | rest]), do: service_command(rest)
defp dispatch(["snapshot" | rest]), do: snapshot_command(rest)
defp dispatch(["image" | rest]), do: image_command(rest)
defp dispatch(["key" | rest]), do: key_command(rest)
defp dispatch(["languages" | rest]), do: languages_command(rest)
defp dispatch(args), do: execute_command(args)
defp extract_account_arg([], acc, rest_acc), do: {acc, Enum.reverse(rest_acc)}
defp extract_account_arg(["--account", n_str | rest], _acc, rest_acc) do
n = case Integer.parse(n_str) do
{n, ""} -> n
_ ->
IO.puts(:stderr, "Error: --account requires an integer argument")
System.halt(1)
end
extract_account_arg(rest, n, rest_acc)
end
defp extract_account_arg([arg | rest], acc, rest_acc) do
extract_account_arg(rest, acc, [arg | rest_acc])
end
defp print_usage do
IO.puts("Usage: un.ex [options] <source_file>")
IO.puts(" un.ex session [options]")
IO.puts(" un.ex service [options]")
IO.puts(" un.ex service env <action> <service_id>")
IO.puts(" un.ex snapshot [options]")
IO.puts(" un.ex image [options]")
IO.puts(" un.ex key [--extend]")
IO.puts("Usage: un.ex [--account N] [options] <source_file>")
IO.puts(" un.ex [--account N] session [options]")
IO.puts(" un.ex [--account N] service [options]")
IO.puts(" un.ex [--account N] service env <action> <service_id>")
IO.puts(" un.ex [--account N] snapshot [options]")
IO.puts(" un.ex [--account N] image [options]")
IO.puts(" un.ex [--account N] key [--extend]")
IO.puts(" un.ex languages [--json]")
IO.puts("")
IO.puts("Global options:")
IO.puts(" --account N Use accounts.csv row N (bypasses env vars)")
IO.puts("")
IO.puts("Service options: --name, --ports, --bootstrap, -e KEY=VALUE, --env-file FILE")
IO.puts(" --set-unfreeze-on-demand ID true|false")
IO.puts("Service env commands: status, set, export, delete")
@ -1913,11 +1940,46 @@ defmodule Un do
end
# Helpers
defp load_credentials_from_csv(csv_path, account_index) do
case File.read(csv_path) do
{:ok, content} ->
accounts =
content
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.filter(fn line -> line != "" and not String.starts_with?(line, "#") end)
|> Enum.flat_map(fn line ->
case String.split(line, ",") do
[pk, sk | _] ->
pk = String.trim(pk)
sk = String.trim(sk)
if String.length(pk) > 8 and String.length(sk) > 8 do
[{pk, sk}]
else
[]
end
_ -> []
end
end)
case Enum.at(accounts, account_index) do
nil -> :error
creds -> {:ok, creds}
end
_ -> :error
end
end
defp get_api_keys do
home = System.get_env("HOME") || "."
home_csv = Path.join([home, ".unsandbox", "accounts.csv"])
# Priority 1: --account N (stored in process dict by main/1)
case Process.get(:account_index) do
nil ->
# Priority 2: environment variables
public_key = System.get_env("UNSANDBOX_PUBLIC_KEY")
secret_key = System.get_env("UNSANDBOX_SECRET_KEY")
# Fall back to UNSANDBOX_API_KEY for backwards compatibility
api_key = System.get_env("UNSANDBOX_API_KEY")
cond do
@ -1926,10 +1988,39 @@ defmodule Un do
api_key ->
{api_key, nil}
true ->
# Priority 3: ~/.unsandbox/accounts.csv (or UNSANDBOX_ACCOUNT index)
default_index =
case System.get_env("UNSANDBOX_ACCOUNT") do
nil -> 0
s -> case Integer.parse(s) do {n, ""} -> n; _ -> 0 end
end
case load_credentials_from_csv(home_csv, default_index) do
{:ok, {pk, sk}} -> {pk, sk}
:error ->
# Priority 4: ./accounts.csv
case load_credentials_from_csv("accounts.csv", default_index) do
{:ok, {pk, sk}} -> {pk, sk}
:error ->
IO.puts(:stderr, "Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set (or UNSANDBOX_API_KEY for backwards compat)")
System.halt(1)
end
end
end
account_index ->
# Priority 1: --account N -> accounts.csv
case load_credentials_from_csv(home_csv, account_index) do
{:ok, {pk, sk}} -> {pk, sk}
:error ->
case load_credentials_from_csv("accounts.csv", account_index) do
{:ok, {pk, sk}} -> {pk, sk}
:error ->
IO.puts(:stderr, "Error: No credentials found for account index #{account_index} in accounts.csv")
System.halt(1)
end
end
end
end
defp get_api_key do
{public_key, _} = get_api_keys()

View file

@ -56,8 +56,10 @@
%%%
%%% Authentication Priority:
%%% 1. Function arguments (PublicKey, SecretKey)
%%% 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
%%% 3. Config file (~/.unsandbox/accounts.csv)
%%% 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").
@ -719,37 +721,60 @@ not_contains_error(Response) ->
%% CLI Entry Point
%% ============================================================================
main([]) ->
io:format("Usage: un.erl [options] <source_file>~n"),
io:format(" un.erl session [options]~n"),
io:format(" un.erl service [options]~n"),
io:format(" un.erl snapshot [options]~n"),
io:format(" un.erl image [options]~n"),
io:format(" un.erl key [options]~n"),
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);
main(["session" | Rest]) ->
dispatch(["session" | Rest]) ->
session_command(Rest);
main(["service" | Rest]) ->
dispatch(["service" | Rest]) ->
service_command(Rest);
main(["snapshot" | Rest]) ->
dispatch(["snapshot" | Rest]) ->
snapshot_command(Rest);
main(["image" | Rest]) ->
dispatch(["image" | Rest]) ->
image_command(Rest);
main(["key" | Rest]) ->
dispatch(["key" | Rest]) ->
key_command(Rest);
main(["languages" | Rest]) ->
dispatch(["languages" | Rest]) ->
languages_command(Rest);
main(Args) ->
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}),
@ -1452,19 +1477,96 @@ open_extend_page(PublicKey) ->
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() ->

View file

@ -77,6 +77,7 @@ type Args = {
mutable Command: string option
mutable SourceFile: string option
mutable ApiKey: string option
mutable AccountIndex: int option
mutable Network: string option
mutable Vcpu: int
Env: ResizeArray<string>
@ -144,19 +145,70 @@ type Args = {
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 secretKey = Environment.GetEnvironmentVariable("UNSANDBOX_SECRET_KEY")
// Fall back to UNSANDBOX_API_KEY for backwards compatibility
if String.IsNullOrEmpty(publicKey) || String.IsNullOrEmpty(secretKey) then
// Priority 2: environment variables
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")
if String.IsNullOrEmpty(legacyKey) then
eprintfn "%sError: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set%s" red reset
exit 1
if not (String.IsNullOrEmpty(legacyKey)) then
(legacyKey, null)
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 dotIndex = filename.LastIndexOf('.')
@ -631,7 +683,7 @@ let cmdServiceEnv (args: Args) (publicKey: string) (secretKey: string) =
exit 1
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 language = detectLanguage args.SourceFile.Value
@ -680,7 +732,7 @@ let cmdExecute (args: Args) =
exit exitCode
let cmdSession (args: Args) =
let (publicKey, secretKey) = getApiKeys args.ApiKey
let (publicKey, secretKey) = getApiKeys args.ApiKey args.AccountIndex
if args.SessionSnapshot.IsSome then
let mutable payload = []
@ -740,7 +792,7 @@ let openBrowser (url: string) =
eprintfn "%sError opening browser: %s%s" red ex.Message reset
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
@ -817,7 +869,7 @@ let cmdKey (args: Args) =
exit 1
let cmdLanguages (args: Args) =
let (publicKey, secretKey) = getApiKeys args.ApiKey
let (publicKey, secretKey) = getApiKeys args.ApiKey args.AccountIndex
// Try to load from cache first
let cachedResponse = loadLanguagesCache ()
@ -872,7 +924,7 @@ let cmdLanguages (args: Args) =
printfn "%s" lang
let cmdImage (args: Args) =
let (publicKey, secretKey) = getApiKeys args.ApiKey
let (publicKey, secretKey) = getApiKeys args.ApiKey args.AccountIndex
if args.ImageList then
let result = apiRequest "/images" "GET" None publicKey secretKey
@ -928,7 +980,7 @@ let cmdImage (args: Args) =
exit 1
let cmdSnapshot (args: Args) =
let (publicKey, secretKey) = getApiKeys args.ApiKey
let (publicKey, secretKey) = getApiKeys args.ApiKey args.AccountIndex
if args.SnapshotList then
let result = apiRequest "/snapshots" "GET" None publicKey secretKey
@ -959,7 +1011,7 @@ let cmdSnapshot (args: Args) =
exit 1
let cmdService (args: Args) =
let (publicKey, secretKey) = getApiKeys args.ApiKey
let (publicKey, secretKey) = getApiKeys args.ApiKey args.AccountIndex
// Handle env subcommand
if args.EnvAction.IsSome then
@ -1107,6 +1159,7 @@ let parseArgs (argv: string[]) =
Command = None
SourceFile = None
ApiKey = None
AccountIndex = None
Network = None
Vcpu = 0
Env = ResizeArray<string>()
@ -1192,6 +1245,13 @@ let parseArgs (argv: string[]) =
i <- i + 1
args.EnvTarget <- 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]
| "-v" | "--vcpu" -> i <- i + 1; args.Vcpu <- int 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 " -v N vCPU count (1-8)"
printfn " -k KEY API key"
printfn " --account N Use accounts.csv row N (bypasses env vars)"
printfn ""
printfn "Session options:"
printfn " --list List active sessions"

View file

@ -66,6 +66,8 @@ import Data.Char (isDigit, ord)
import Text.Printf (printf)
import Control.Monad (when, unless, forM_)
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.Char8 as BSC
import qualified Data.ByteString.Base64 as B64
@ -86,6 +88,11 @@ portalBase = "https://unsandbox.com"
languagesCacheTtl :: Int
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
blue, red, green, yellow, reset :: String
blue = "\x1b[34m"
@ -838,10 +845,26 @@ threadDelay us = do
_ <- readProcessWithExitCode "sleep" [show (fromIntegral ms / 1000.0 :: Double)] ""
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 :: IO ()
main = do
args <- getArgs
rawArgs <- getArgs
args <- stripAccountArg rawArgs
cmd <- parseArgs args
case cmd of
Execute opts -> executeCommand opts
@ -865,6 +888,9 @@ printHelp = do
putStrLn " un.hs languages [--json] List available languages"
putStrLn " un.hs key [options] Validate/extend API key"
putStrLn ""
putStrLn "Global options:"
putStrLn " --account N Use accounts.csv row N (bypasses env vars)"
putStrLn ""
putStrLn "Execute options:"
putStrLn " -e KEY=VALUE Environment variable"
putStrLn " -f FILE Input file"
@ -1433,9 +1459,56 @@ serviceEnvDelete serviceId = do
(exitCode, _, _) <- curlDelete apiKey (apiBase ++ "/services/" ++ serviceId ++ "/env")
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 = 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"
secretKey <- lookupEnv "UNSANDBOX_SECRET_KEY"
apiKey <- lookupEnv "UNSANDBOX_API_KEY"
@ -1443,6 +1516,18 @@ getApiKeys = do
(Just pk, Just sk, _) -> return (pk, Just sk)
(_, _, Just ak) -> return (ak, Nothing)
_ -> 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)"
exitFailure

View file

@ -281,39 +281,53 @@ let extract_json_int json_str key =
Credentials Management
============================================================================ *)
(** 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 accounts_path = Filename.concat home ".unsandbox/accounts.csv" in
if Sys.file_exists accounts_path then
try
let content = read_file accounts_path in
(** Global account index set by --account N CLI flag; -1 means not set *)
let cli_account_index = ref (-1)
(** Parse accounts from CSV content, return list of (pk, sk) pairs *)
let parse_accounts_csv content =
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
if String.length line = 0 || line.[0] = '#' then None
else
try
let comma_pos = String.index line ',' in
let pk = String.sub line 0 comma_pos in
let sk = 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-" &&
String.length sk > 8 && String.sub sk 0 8 = "unsb-sk-" then
let pk = String.trim (String.sub line 0 comma_pos) in
let sk = String.trim (String.sub line (comma_pos + 1) (String.length line - comma_pos - 1)) in
if String.length pk > 8 && String.length sk > 8 then
Some (pk, sk)
else None
with Not_found -> None
) lines in
if account_index < List.length valid_accounts then
Some (List.nth valid_accounts account_index)
) lines
(** 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
with _ -> 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:
1. Function arguments
2. Environment variables
3. ~/.unsandbox/accounts.csv
1. Function arguments (public_key, secret_key)
2. --account N (cli_account_index ref) -> accounts.csv row N
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 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
| (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_sk = try Some (Sys.getenv "UNSANDBOX_SECRET_KEY") with Not_found -> None in
match (env_pk, env_sk) with
| (Some pk, Some sk) -> (pk, sk)
| _ ->
(* Priority 3: Config file *)
match get_credentials_from_file ~account_index () with
(* Priority 4: ~/.unsandbox/accounts.csv (or UNSANDBOX_ACCOUNT index) *)
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)
| None ->
failwith "No credentials found. Set UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY, \
or create ~/.unsandbox/accounts.csv, or pass credentials to function."
end
(* Legacy function for backward compatibility *)
let get_api_keys () =
@ -2038,18 +2066,34 @@ let image_command args =
in
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 () =
Random.self_init ();
let args = Array.to_list Sys.argv in
match List.tl args with
let raw_args = Array.to_list Sys.argv in
let args = strip_account_arg (List.tl raw_args) in
match args with
| [] ->
Printf.printf "Usage: un.ml [options] <source_file>\n";
Printf.printf " un.ml session [options]\n";
Printf.printf " un.ml service [options]\n";
Printf.printf " un.ml image [options]\n";
Printf.printf " un.ml service env <action> <service_id>\n";
Printf.printf "Usage: un.ml [--account N] [options] <source_file>\n";
Printf.printf " un.ml [--account N] session [options]\n";
Printf.printf " un.ml [--account N] service [options]\n";
Printf.printf " un.ml [--account N] image [options]\n";
Printf.printf " un.ml [--account N] service env <action> <service_id>\n";
Printf.printf " un.ml languages [--json]\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 env commands: status, set, export, delete\n";
Printf.printf "Image options: --list, --info ID, --delete ID, --lock ID, --unlock ID,\n";