diff --git a/clients/elixir/sync/src/un.ex b/clients/elixir/sync/src/un.ex index a3ef621..187faa5 100755 --- a/clients/elixir/sync/src/un.ex +++ b/clients/elixir/sync/src/un.ex @@ -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] ") - IO.puts(" un.ex session [options]") - IO.puts(" un.ex service [options]") - IO.puts(" un.ex service env ") - IO.puts(" un.ex snapshot [options]") - IO.puts(" un.ex image [options]") - IO.puts(" un.ex key [--extend]") + IO.puts("Usage: un.ex [--account N] [options] ") + IO.puts(" un.ex [--account N] session [options]") + IO.puts(" un.ex [--account N] service [options]") + IO.puts(" un.ex [--account N] service env ") + 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,21 +1940,85 @@ 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 - public_key = System.get_env("UNSANDBOX_PUBLIC_KEY") - secret_key = System.get_env("UNSANDBOX_SECRET_KEY") + home = System.get_env("HOME") || "." + home_csv = Path.join([home, ".unsandbox", "accounts.csv"]) - # Fall back to UNSANDBOX_API_KEY for backwards compatibility - api_key = System.get_env("UNSANDBOX_API_KEY") + # Priority 1: --account N (stored in process dict by main/1) + case Process.get(:account_index) do + nil -> + # Priority 2: environment variables + public_key = System.get_env("UNSANDBOX_PUBLIC_KEY") + secret_key = System.get_env("UNSANDBOX_SECRET_KEY") + api_key = System.get_env("UNSANDBOX_API_KEY") - cond do - public_key && secret_key -> - {public_key, secret_key} - api_key -> - {api_key, nil} - true -> - IO.puts(:stderr, "Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set (or UNSANDBOX_API_KEY for backwards compat)") - System.halt(1) + cond do + public_key && secret_key -> + {public_key, secret_key} + api_key -> + {api_key, nil} + true -> + # Priority 3: ~/.unsandbox/accounts.csv (or UNSANDBOX_ACCOUNT index) + default_index = + case System.get_env("UNSANDBOX_ACCOUNT") do + nil -> 0 + s -> case Integer.parse(s) do {n, ""} -> n; _ -> 0 end + end + case load_credentials_from_csv(home_csv, default_index) do + {:ok, {pk, sk}} -> {pk, sk} + :error -> + # Priority 4: ./accounts.csv + case load_credentials_from_csv("accounts.csv", default_index) do + {:ok, {pk, sk}} -> {pk, sk} + :error -> + IO.puts(:stderr, "Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set (or UNSANDBOX_API_KEY for backwards compat)") + System.halt(1) + end + end + end + + account_index -> + # Priority 1: --account N -> accounts.csv + case load_credentials_from_csv(home_csv, account_index) do + {:ok, {pk, sk}} -> {pk, sk} + :error -> + case load_credentials_from_csv("accounts.csv", account_index) do + {:ok, {pk, sk}} -> {pk, sk} + :error -> + IO.puts(:stderr, "Error: No credentials found for account index #{account_index} in accounts.csv") + System.halt(1) + end + end end end diff --git a/clients/erlang/sync/src/un.erl b/clients/erlang/sync/src/un.erl index 17ec8df..3c34b65 100755 --- a/clients/erlang/sync/src/un.erl +++ b/clients/erlang/sync/src/un.erl @@ -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] ~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] ~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 -get_api_keys() -> - 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 -> - io:format("Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set (or UNSANDBOX_API_KEY for backwards compat)~n"), - halt(1) +%% @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() -> diff --git a/clients/fsharp/sync/src/un.fs b/clients/fsharp/sync/src/un.fs index 58dcbdb..5752bd1 100644 --- a/clients/fsharp/sync/src/un.fs +++ b/clients/fsharp/sync/src/un.fs @@ -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 @@ -144,19 +145,70 @@ type Args = { mutable ImagePorts: string option } -let getApiKeys (argsKey: string option) = - let publicKey = Environment.GetEnvironmentVariable("UNSANDBOX_PUBLIC_KEY") - let secretKey = Environment.GetEnvironmentVariable("UNSANDBOX_SECRET_KEY") +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 - // Fall back to UNSANDBOX_API_KEY for backwards compatibility - if String.IsNullOrEmpty(publicKey) || String.IsNullOrEmpty(secretKey) then - 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 +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 - (legacyKey, null) - else - (publicKey, secretKey) + | None -> + let publicKey = Environment.GetEnvironmentVariable("UNSANDBOX_PUBLIC_KEY") + let secretKey = Environment.GetEnvironmentVariable("UNSANDBOX_SECRET_KEY") + + // 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 not (String.IsNullOrEmpty(legacyKey)) then + (legacyKey, null) + else + // 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() @@ -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" diff --git a/clients/haskell/sync/src/un.hs b/clients/haskell/sync/src/un.hs index d17d0b5..91d7a34 100644 --- a/clients/haskell/sync/src/un.hs +++ b/clients/haskell/sync/src/un.hs @@ -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,18 +1459,77 @@ 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 - publicKey <- lookupEnv "UNSANDBOX_PUBLIC_KEY" - secretKey <- lookupEnv "UNSANDBOX_SECRET_KEY" - apiKey <- lookupEnv "UNSANDBOX_API_KEY" - case (publicKey, secretKey, apiKey) of - (Just pk, Just sk, _) -> return (pk, Just sk) - (_, _, Just ak) -> return (ak, Nothing) - _ -> do - hPutStrLn stderr "Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set (or UNSANDBOX_API_KEY for backwards compat)" - exitFailure + 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" + case (publicKey, secretKey, apiKey) of + (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 getApiKey :: IO String getApiKey = do diff --git a/clients/ocaml/sync/src/un.ml b/clients/ocaml/sync/src/un.ml index 3baaf4a..b08d7d6 100755 --- a/clients/ocaml/sync/src/un.ml +++ b/clients/ocaml/sync/src/un.ml @@ -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 +(** 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 + 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.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 + +(** 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 accounts_path in - let lines = String.split_on_char '\n' content in - let valid_accounts = 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 - 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) + 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 *) - 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 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 -> - failwith "No credentials found. Set UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY, \ - or create ~/.unsandbox/accounts.csv, or pass credentials to function." + 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 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] \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 \n"; + Printf.printf "Usage: un.ml [--account N] [options] \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 \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";