From b1b2c14d865b359c8d63969e2f4fc1b426324d37 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Mon, 23 Mar 2026 16:02:50 -0400 Subject: [PATCH] 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). --- clients/csharp/sync/src/Un.cs | 89 ++++++++++++++++++++++++++------- clients/dotnet/sync/src/Un.cs | 83 +++++++++++++++++++++++------- clients/swift/sync/src/un.swift | 44 +++++++++++----- un.swift | 1 + 4 files changed, 170 insertions(+), 47 deletions(-) create mode 120000 un.swift diff --git a/clients/csharp/sync/src/Un.cs b/clients/csharp/sync/src/Un.cs index a0b5c27..327e4f6 100644 --- a/clients/csharp/sync/src/Un.cs +++ b/clients/csharp/sync/src/Un.cs @@ -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(); if (result.ContainsKey("languages") && result["languages"] is List langs) diff --git a/clients/dotnet/sync/src/Un.cs b/clients/dotnet/sync/src/Un.cs index 952c789..9b3faf9 100644 --- a/clients/dotnet/sync/src/Un.cs +++ b/clients/dotnet/sync/src/Un.cs @@ -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; diff --git a/clients/swift/sync/src/un.swift b/clients/swift/sync/src/un.swift index 918f23e..b1becc6 100644 --- a/clients/swift/sync/src/un.swift +++ b/clients/swift/sync/src/un.swift @@ -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) diff --git a/un.swift b/un.swift new file mode 120000 index 0000000..326497d --- /dev/null +++ b/un.swift @@ -0,0 +1 @@ +clients/swift/sync/src/un.swift \ No newline at end of file