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:
parent
a839ffcd12
commit
13f15c8abc
5 changed files with 512 additions and 129 deletions
|
|
@ -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,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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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() ->
|
||||
|
|
|
|||
|
|
@ -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 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<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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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] <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";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue