Add --account N flag and fix credential priority in C#, dotnet, Swift

All three implementations had a defect where UNSANDBOX_PUBLIC_KEY/
UNSANDBOX_SECRET_KEY env vars were checked before --account N, making
it impossible to select a specific accounts.csv row when env vars exist.

Correct priority order now enforced:
  1. Explicit -p/-k flags
  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 var)
  5. ./accounts.csv row 0

Changes per file:
- clients/csharp/sync/src/Un.cs: add LoadAccountsCSV(), rewrite
  GetApiKeys() with correct tier ordering, add Account=-1 to Args,
  parse --account N, update all GetApiKeys call sites
- clients/dotnet/sync/src/Un.cs: same as above (top-level stmt style)
- clients/swift/sync/src/un.swift: reorder resolveCredentials() tiers,
  add accountIndex to CLIArgs, parse --account N, pass to entry point

Also create un.swift symlink at repo root (parallel to Un.cs symlink).
This commit is contained in:
russell@unturf.com 2026-03-23 16:02:50 -04:00
parent 7dd16bf796
commit b1b2c14d86
4 changed files with 170 additions and 47 deletions

View file

@ -115,7 +115,7 @@ class Un
static void CmdExecute(Args args)
{
var (publicKey, secretKey) = GetApiKeys(args.ApiKey);
var (publicKey, secretKey) = GetApiKeys(args.ApiKey, args.Account);
string code = File.ReadAllText(args.SourceFile);
string language = DetectLanguage(args.SourceFile);
@ -202,7 +202,7 @@ class Un
static void CmdSession(Args args)
{
var (publicKey, secretKey) = GetApiKeys(args.ApiKey);
var (publicKey, secretKey) = GetApiKeys(args.ApiKey, args.Account);
if (args.SessionList)
{
@ -255,7 +255,7 @@ class Un
static void CmdKey(Args args)
{
var (publicKey, secretKey) = GetApiKeys(args.ApiKey);
var (publicKey, secretKey) = GetApiKeys(args.ApiKey, args.Account);
var result = ApiRequest("/keys/validate", "POST", null, publicKey, secretKey);
@ -340,7 +340,7 @@ class Un
static void CmdService(Args args)
{
var (publicKey, secretKey) = GetApiKeys(args.ApiKey);
var (publicKey, secretKey) = GetApiKeys(args.ApiKey, args.Account);
// Handle env subcommand
if (!string.IsNullOrEmpty(args.EnvAction))
@ -606,24 +606,77 @@ class Un
Environment.Exit(1);
}
static (string, string) GetApiKeys(string argsKey)
static (string, string) LoadAccountsCSV(string path, int index)
{
string publicKey = Environment.GetEnvironmentVariable("UNSANDBOX_PUBLIC_KEY");
string secretKey = Environment.GetEnvironmentVariable("UNSANDBOX_SECRET_KEY");
// Fall back to UNSANDBOX_API_KEY for backwards compatibility
if (string.IsNullOrEmpty(publicKey) || string.IsNullOrEmpty(secretKey))
if (!File.Exists(path)) return (null, null);
int row = 0;
foreach (string rawLine in File.ReadAllLines(path))
{
string legacyKey = argsKey ?? Environment.GetEnvironmentVariable("UNSANDBOX_API_KEY");
if (string.IsNullOrEmpty(legacyKey))
string line = rawLine.Trim();
if (line.Length == 0 || line.StartsWith("#")) continue;
if (row == index)
{
Console.Error.WriteLine($"{RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set{RESET}");
Environment.Exit(1);
string[] parts = line.Split(',');
if (parts.Length >= 2)
return (parts[0].Trim(), parts[1].Trim());
}
return (legacyKey, null);
row++;
}
return (null, null);
}
static (string, string) GetApiKeys(string argsKey, int accountIndex = -1)
{
// Tier 1: explicit -p/-k flags (argsKey covers legacy -k/--api-key)
// (handled by callers that pass explicit keys directly to ApiRequest)
// Tier 2: --account N → accounts.csv row N (bypasses env vars)
if (accountIndex >= 0)
{
string home = Environment.GetEnvironmentVariable("HOME")
?? Environment.GetEnvironmentVariable("USERPROFILE") ?? ".";
string homeCsv = Path.Combine(home, ".unsandbox", "accounts.csv");
var (pk1, sk1) = LoadAccountsCSV(homeCsv, accountIndex);
if (!string.IsNullOrEmpty(pk1) && !string.IsNullOrEmpty(sk1))
return (pk1, sk1);
var (pk2, sk2) = LoadAccountsCSV("accounts.csv", accountIndex);
if (!string.IsNullOrEmpty(pk2) && !string.IsNullOrEmpty(sk2))
return (pk2, sk2);
Console.Error.WriteLine($"{RED}Error: No account at index {accountIndex} in accounts.csv{RESET}");
Environment.Exit(1);
}
return (publicKey, secretKey);
// Tier 3: environment variables
string publicKey = Environment.GetEnvironmentVariable("UNSANDBOX_PUBLIC_KEY");
string secretKey = Environment.GetEnvironmentVariable("UNSANDBOX_SECRET_KEY");
if (!string.IsNullOrEmpty(publicKey) && !string.IsNullOrEmpty(secretKey))
return (publicKey, secretKey);
// Tier 4: ~/.unsandbox/accounts.csv row 0 (or UNSANDBOX_ACCOUNT env var)
int defaultIdx = 0;
string acctEnv = Environment.GetEnvironmentVariable("UNSANDBOX_ACCOUNT");
if (!string.IsNullOrEmpty(acctEnv) && int.TryParse(acctEnv, out int parsedIdx))
defaultIdx = parsedIdx;
string home2 = Environment.GetEnvironmentVariable("HOME")
?? Environment.GetEnvironmentVariable("USERPROFILE") ?? ".";
string homeCsv2 = Path.Combine(home2, ".unsandbox", "accounts.csv");
var (pk3, sk3) = LoadAccountsCSV(homeCsv2, defaultIdx);
if (!string.IsNullOrEmpty(pk3) && !string.IsNullOrEmpty(sk3))
return (pk3, sk3);
// Tier 5: ./accounts.csv row 0
var (pk4, sk4) = LoadAccountsCSV("accounts.csv", defaultIdx);
if (!string.IsNullOrEmpty(pk4) && !string.IsNullOrEmpty(sk4))
return (pk4, sk4);
// Fall back to UNSANDBOX_API_KEY for backwards compatibility
string legacyKey = argsKey ?? Environment.GetEnvironmentVariable("UNSANDBOX_API_KEY");
if (string.IsNullOrEmpty(legacyKey))
{
Console.Error.WriteLine($"{RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set{RESET}");
Environment.Exit(1);
}
return (legacyKey, null);
}
static string DetectLanguage(string filename)
@ -1284,6 +1337,7 @@ class Un
public string EnvTarget = null;
public bool KeyExtend = false;
public bool LanguagesJson = false;
public int Account = -1;
}
static Args ParseArgs(string[] args)
@ -1345,6 +1399,7 @@ class Un
else if (arg == "--redeploy") result.ServiceRedeploy = args[++i];
else if (arg == "--extend") result.KeyExtend = true;
else if (arg == "--json") result.LanguagesJson = true;
else if (arg == "--account") result.Account = int.Parse(args[++i]);
else if (!arg.StartsWith("-")) result.SourceFile = arg;
}
return result;
@ -1408,7 +1463,7 @@ class Un
if (languages == null)
{
var (publicKey, secretKey) = GetApiKeys(args.ApiKey);
var (publicKey, secretKey) = GetApiKeys(args.ApiKey, args.Account);
var result = ApiRequest("/languages", "GET", null, publicKey, secretKey);
languages = new List<string>();
if (result.ContainsKey("languages") && result["languages"] is List<object> langs)

View file

@ -87,7 +87,7 @@ catch (Exception ex)
void CmdExecute(Args args)
{
var (publicKey, secretKey) = GetApiKeys(args.ApiKey);
var (publicKey, secretKey) = GetApiKeys(args.ApiKey, args.Account);
var code = File.ReadAllText(args.SourceFile!);
var language = DetectLanguage(args.SourceFile!);
@ -148,7 +148,7 @@ void CmdExecute(Args args)
void CmdSession(Args args)
{
var (publicKey, secretKey) = GetApiKeys(args.ApiKey);
var (publicKey, secretKey) = GetApiKeys(args.ApiKey, args.Account);
if (args.SessionList)
{
@ -224,7 +224,7 @@ void CmdSession(Args args)
void CmdKey(Args args)
{
var (publicKey, secretKey) = GetApiKeys(args.ApiKey);
var (publicKey, secretKey) = GetApiKeys(args.ApiKey, args.Account);
var result = ApiRequest("/keys/validate", HttpMethod.Post, null, publicKey, secretKey);
if (!result.TryGetValue("valid", out var validObj) || validObj is not JsonElement validEl)
@ -281,7 +281,7 @@ void OpenBrowser(string url)
void CmdService(Args args)
{
var (publicKey, secretKey) = GetApiKeys(args.ApiKey);
var (publicKey, secretKey) = GetApiKeys(args.ApiKey, args.Account);
if (!string.IsNullOrEmpty(args.EnvAction))
{
@ -548,7 +548,7 @@ void CmdServiceEnv(Args args, string publicKey, string secretKey)
void CmdSnapshot(Args args)
{
var (publicKey, secretKey) = GetApiKeys(args.ApiKey);
var (publicKey, secretKey) = GetApiKeys(args.ApiKey, args.Account);
if (args.SnapshotList)
{
@ -620,7 +620,7 @@ void CmdSnapshot(Args args)
void CmdImage(Args args)
{
var (publicKey, secretKey) = GetApiKeys(args.ApiKey);
var (publicKey, secretKey) = GetApiKeys(args.ApiKey, args.Account);
if (args.ImageList)
{
@ -715,7 +715,7 @@ void CmdImage(Args args)
void CmdLanguages(Args args)
{
var (publicKey, secretKey) = GetApiKeys(args.ApiKey);
var (publicKey, secretKey) = GetApiKeys(args.ApiKey, args.Account);
// Check cache first
var cacheDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unsandbox");
@ -896,22 +896,69 @@ bool ServiceEnvSet(string serviceId, string envContent, string publicKey, string
catch { return false; }
}
(string, string) GetApiKeys(string? argsKey)
(string, string) LoadAccountsCSV(string path, int index)
{
if (!File.Exists(path)) return (null!, null!);
var row = 0;
foreach (var rawLine in File.ReadAllLines(path))
{
var line = rawLine.Trim();
if (line.Length == 0 || line.StartsWith("#")) continue;
if (row == index)
{
var parts = line.Split(',');
if (parts.Length >= 2)
return (parts[0].Trim(), parts[1].Trim());
}
row++;
}
return (null!, null!);
}
(string, string) GetApiKeys(string? argsKey, int accountIndex = -1)
{
// Tier 2: --account N → accounts.csv row N (bypasses env vars)
if (accountIndex >= 0)
{
var home = Environment.GetEnvironmentVariable("HOME")
?? Environment.GetEnvironmentVariable("USERPROFILE") ?? ".";
var homeCsv = Path.Combine(home, ".unsandbox", "accounts.csv");
var (pk1, sk1) = LoadAccountsCSV(homeCsv, accountIndex);
if (!string.IsNullOrEmpty(pk1) && !string.IsNullOrEmpty(sk1)) return (pk1, sk1);
var (pk2, sk2) = LoadAccountsCSV("accounts.csv", accountIndex);
if (!string.IsNullOrEmpty(pk2) && !string.IsNullOrEmpty(sk2)) return (pk2, sk2);
Console.Error.WriteLine($"{RED}Error: No account at index {accountIndex} in accounts.csv{RESET}");
Environment.Exit(1);
}
// Tier 3: environment variables
var publicKey = Environment.GetEnvironmentVariable("UNSANDBOX_PUBLIC_KEY");
var secretKey = Environment.GetEnvironmentVariable("UNSANDBOX_SECRET_KEY");
if (!string.IsNullOrEmpty(publicKey) && !string.IsNullOrEmpty(secretKey))
return (publicKey, secretKey);
if (string.IsNullOrEmpty(publicKey) || string.IsNullOrEmpty(secretKey))
// Tier 4: ~/.unsandbox/accounts.csv row 0 (or UNSANDBOX_ACCOUNT env var)
var defaultIdx = 0;
var acctEnv = Environment.GetEnvironmentVariable("UNSANDBOX_ACCOUNT");
if (!string.IsNullOrEmpty(acctEnv) && int.TryParse(acctEnv, out var parsedIdx))
defaultIdx = parsedIdx;
var home2 = Environment.GetEnvironmentVariable("HOME")
?? Environment.GetEnvironmentVariable("USERPROFILE") ?? ".";
var (pk3, sk3) = LoadAccountsCSV(Path.Combine(home2, ".unsandbox", "accounts.csv"), defaultIdx);
if (!string.IsNullOrEmpty(pk3) && !string.IsNullOrEmpty(sk3)) return (pk3, sk3);
// Tier 5: ./accounts.csv row 0
var (pk4, sk4) = LoadAccountsCSV("accounts.csv", defaultIdx);
if (!string.IsNullOrEmpty(pk4) && !string.IsNullOrEmpty(sk4)) return (pk4, sk4);
// Fall back to UNSANDBOX_API_KEY for backwards compatibility
var legacyKey = argsKey ?? Environment.GetEnvironmentVariable("UNSANDBOX_API_KEY");
if (string.IsNullOrEmpty(legacyKey))
{
var legacyKey = argsKey ?? Environment.GetEnvironmentVariable("UNSANDBOX_API_KEY");
if (string.IsNullOrEmpty(legacyKey))
{
Console.Error.WriteLine($"{RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set{RESET}");
Environment.Exit(1);
}
return (legacyKey, "");
Console.Error.WriteLine($"{RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set{RESET}");
Environment.Exit(1);
}
return (publicKey, secretKey);
return (legacyKey!, "");
}
string DetectLanguage(string filename)
@ -1037,6 +1084,7 @@ Args ParseArgs(string[] args)
else if (arg == "--show-freeze-page-enabled") result.ServiceShowFreezePageEnabled = args[++i].ToLower() == "true";
else if (arg == "--with-unfreeze-on-demand") result.ServiceCreateUnfreezeOnDemand = true;
else if (arg == "--extend") result.KeyExtend = true;
else if (arg == "--account") result.Account = int.Parse(args[++i]);
else if (arg == "--delete")
{
var val = args[++i];
@ -2073,6 +2121,7 @@ class Args
public bool ServiceCreateUnfreezeOnDemand;
public string? EnvFile, EnvAction, EnvTarget;
public bool KeyExtend;
public int Account = -1;
public bool SnapshotList;
public string? SnapshotInfo, SnapshotDelete, SnapshotLock, SnapshotUnlock, SnapshotClone;
public string? SnapshotCloneType, SnapshotName;

View file

@ -153,30 +153,41 @@ func loadCredentialsFromCSV(_ path: URL, accountIndex: Int = 0) -> (String, Stri
/// Resolve credentials from 4-tier priority system
func resolveCredentials(publicKey: String? = nil, secretKey: String? = nil, accountIndex: Int? = nil) throws -> (String, String) {
// Tier 1: Function arguments
// Tier 1: Function arguments (-p/-k flags)
if let pk = publicKey, let sk = secretKey, !pk.isEmpty, !sk.isEmpty {
return (pk, sk)
}
// Tier 2: Environment variables
// Tier 2: --account N accounts.csv row N (bypasses env vars)
if let idx = accountIndex {
let unsandboxDir = getUnsandboxDir()
if let creds = loadCredentialsFromCSV(unsandboxDir.appendingPathComponent("accounts.csv"), accountIndex: idx) {
return creds
}
let localPath = URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent("accounts.csv")
if let creds = loadCredentialsFromCSV(localPath, accountIndex: idx) {
return creds
}
throw UnsandboxError.credentialsNotFound("No account at index \(idx) in accounts.csv")
}
// Tier 3: Environment variables
if let envPk = ProcessInfo.processInfo.environment["UNSANDBOX_PUBLIC_KEY"],
let envSk = ProcessInfo.processInfo.environment["UNSANDBOX_SECRET_KEY"],
!envPk.isEmpty, !envSk.isEmpty {
return (envPk, envSk)
}
// Determine account index
let idx = accountIndex ?? Int(ProcessInfo.processInfo.environment["UNSANDBOX_ACCOUNT"] ?? "0") ?? 0
// Tier 3: ~/.unsandbox/accounts.csv
// Tier 4: ~/.unsandbox/accounts.csv row 0 (or UNSANDBOX_ACCOUNT env var)
let defaultIdx = Int(ProcessInfo.processInfo.environment["UNSANDBOX_ACCOUNT"] ?? "0") ?? 0
let unsandboxDir = getUnsandboxDir()
if let creds = loadCredentialsFromCSV(unsandboxDir.appendingPathComponent("accounts.csv"), accountIndex: idx) {
if let creds = loadCredentialsFromCSV(unsandboxDir.appendingPathComponent("accounts.csv"), accountIndex: defaultIdx) {
return creds
}
// Tier 4: ./accounts.csv
// Tier 5: ./accounts.csv row 0
let localPath = URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent("accounts.csv")
if let creds = loadCredentialsFromCSV(localPath, accountIndex: idx) {
if let creds = loadCredentialsFromCSV(localPath, accountIndex: defaultIdx) {
return creds
}
@ -184,9 +195,10 @@ func resolveCredentials(publicKey: String? = nil, secretKey: String? = nil, acco
"""
No credentials found. Please provide via:
1. Function arguments (publicKey, secretKey)
2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
3. ~/.unsandbox/accounts.csv
4. ./accounts.csv
2. --account N (accounts.csv row N)
3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
4. ~/.unsandbox/accounts.csv
5. ./accounts.csv
"""
)
}
@ -1719,6 +1731,9 @@ class CLIArgs {
// Languages options
var jsonOutput: Bool = false
// Credential selection
var accountIndex: Int? = nil
func parse(_ args: [String]) {
var i = 0
let args = Array(args.dropFirst()) // Skip program name
@ -1753,6 +1768,9 @@ class CLIArgs {
case "-k", "--secret-key":
i += 1
if i < args.count { secretKey = args[i] }
case "--account":
i += 1
if i < args.count { accountIndex = Int(args[i]) }
case "-n", "--network":
i += 1
if i < args.count { networkMode = args[i] }
@ -2477,7 +2495,7 @@ struct UnCLI {
let pk: String
let sk: String
do {
(pk, sk) = try resolveCredentials(publicKey: args.publicKey, secretKey: args.secretKey)
(pk, sk) = try resolveCredentials(publicKey: args.publicKey, secretKey: args.secretKey, accountIndex: args.accountIndex)
} catch {
fputs("Error: \(error)\n", stderr)
exit(3)

1
un.swift Symbolic link
View file

@ -0,0 +1 @@
clients/swift/sync/src/un.swift