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,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