diff --git a/clients/crystal/sync/src/un.cr b/clients/crystal/sync/src/un.cr index 40b8ed5..f27a4c5 100644 --- a/clients/crystal/sync/src/un.cr +++ b/clients/crystal/sync/src/un.cr @@ -132,21 +132,72 @@ def save_languages_cache(response : JSON::Any) end end -def get_api_keys(args_key : String?) : {String, String?} - public_key = ENV["UNSANDBOX_PUBLIC_KEY"]? - secret_key = ENV["UNSANDBOX_SECRET_KEY"]? - - # Fall back to UNSANDBOX_API_KEY for backwards compatibility - if public_key.nil? || public_key.empty? || secret_key.nil? || secret_key.empty? - legacy_key = args_key || ENV["UNSANDBOX_API_KEY"]? - if legacy_key.nil? || legacy_key.empty? - STDERR.puts "#{RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set#{RESET}" - exit 1 +def load_accounts_csv(path : String, index : Int32) : {String, String}? + return nil unless File.exists?(path) + begin + lines = File.read(path).split('\n').select do |l| + t = l.strip + !t.empty? && !t.starts_with?('#') end + return nil if index < 0 || index >= lines.size + parts = lines[index].split(',') + return nil if parts.size < 2 + pk = parts[0].strip + sk = parts[1].strip + return nil if pk.empty? || sk.empty? + {pk, sk} + rescue + nil + end +end + +def get_api_keys(args_key : String?, args_public_key : String? = nil, account : Int32? = nil) : {String, String?} + # Tier 1: explicit -p/-k flags + if args_public_key && !args_public_key.empty? && args_key && !args_key.empty? + return {args_public_key, args_key} + end + + # Tier 2: --account N → accounts.csv row N (bypasses env vars) + if !account.nil? + idx = account.not_nil! + home = ENV["HOME"]? + if home && !home.empty? + result = load_accounts_csv(File.join(home, ".unsandbox", "accounts.csv"), idx) + return {result[0], result[1]} if result + end + result = load_accounts_csv("accounts.csv", idx) + return {result[0], result[1]} if result + STDERR.puts "#{RED}Error: --account #{idx} not found in accounts.csv#{RESET}" + exit 1 + end + + # Tier 3: env vars + env_pk = ENV["UNSANDBOX_PUBLIC_KEY"]? + env_sk = ENV["UNSANDBOX_SECRET_KEY"]? + if env_pk && !env_pk.empty? && env_sk && !env_sk.empty? + return {env_pk, env_sk} + end + + # Tier 4: ~/.unsandbox/accounts.csv row 0 (or UNSANDBOX_ACCOUNT env var) + home = ENV["HOME"]? + def_index = (ENV["UNSANDBOX_ACCOUNT"]?.try(&.to_i?) || 0).to_i32 + if home && !home.empty? + result = load_accounts_csv(File.join(home, ".unsandbox", "accounts.csv"), def_index) + return {result[0], result[1]} if result + end + + # Tier 5: ./accounts.csv row 0 + result = load_accounts_csv("accounts.csv", def_index) + return {result[0], result[1]} if result + + # Legacy UNSANDBOX_API_KEY fallback + legacy_key = args_key || ENV["UNSANDBOX_API_KEY"]? + if legacy_key && !legacy_key.empty? return {legacy_key, nil} end - {public_key, secret_key} + STDERR.puts "#{RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set#{RESET}" + exit 1 end def extract_challenge_id(response_body : String) : String? @@ -394,7 +445,7 @@ def build_env_content(envs : Array(String), env_file : String?) : String end def cmd_service_env(args) - public_key, secret_key = get_api_keys(args[:api_key]?.as?(String)) + public_key, secret_key = get_api_keys(args[:api_key]?.as?(String), args_public_key: args[:public_key]?.as?(String), account: args[:account]?.as?(Int32)) action = args[:env_action]?.as?(String) || "" target = args[:env_target]?.as?(String) || "" @@ -463,7 +514,7 @@ def cmd_service_env(args) end def cmd_execute(args) - public_key, secret_key = get_api_keys(args[:api_key]?) + public_key, secret_key = get_api_keys(args[:api_key]?.as?(String), args_public_key: args[:public_key]?.as?(String), account: args[:account]?.as?(Int32)) filename = args[:source_file].as(String) unless File.exists?(filename) @@ -553,7 +604,7 @@ def cmd_execute(args) end def cmd_session(args) - public_key, secret_key = get_api_keys(args[:api_key]?) + public_key, secret_key = get_api_keys(args[:api_key]?.as?(String), args_public_key: args[:public_key]?.as?(String), account: args[:account]?.as?(Int32)) if args[:list]?.as?(Bool) result = api_request("/sessions", public_key, secret_key) @@ -661,7 +712,7 @@ def cmd_session(args) end def cmd_languages(args) - public_key, secret_key = get_api_keys(args[:api_key]?) + public_key, secret_key = get_api_keys(args[:api_key]?.as?(String), args_public_key: args[:public_key]?.as?(String), account: args[:account]?.as?(Int32)) # Try to load from cache first cached_response = load_languages_cache @@ -694,7 +745,7 @@ def cmd_languages(args) end def cmd_key(args) - public_key, secret_key = get_api_keys(args[:api_key]?) + public_key, secret_key = get_api_keys(args[:api_key]?.as?(String), args_public_key: args[:public_key]?.as?(String), account: args[:account]?.as?(Int32)) # Validate key url = URI.parse(PORTAL_BASE + "/keys/validate") @@ -788,7 +839,7 @@ def cmd_key(args) end def cmd_image(args) - public_key, secret_key = get_api_keys(args[:api_key]?) + public_key, secret_key = get_api_keys(args[:api_key]?.as?(String), args_public_key: args[:public_key]?.as?(String), account: args[:account]?.as?(Int32)) if args[:list]?.as?(Bool) result = api_request("/images", public_key, secret_key) @@ -949,7 +1000,7 @@ def cmd_image(args) end def cmd_snapshot(args) - public_key, secret_key = get_api_keys(args[:api_key]?) + public_key, secret_key = get_api_keys(args[:api_key]?.as?(String), args_public_key: args[:public_key]?.as?(String), account: args[:account]?.as?(Int32)) if args[:list]?.as?(Bool) result = api_request("/snapshots", public_key, secret_key) @@ -1072,7 +1123,7 @@ def cmd_snapshot(args) end def cmd_logs(args) - public_key, secret_key = get_api_keys(args[:api_key]?) + public_key, secret_key = get_api_keys(args[:api_key]?.as?(String), args_public_key: args[:public_key]?.as?(String), account: args[:account]?.as?(Int32)) source = args[:logs_source]?.as?(String) || "all" lines = args[:logs_lines]?.as?(Int32) || 100 @@ -1187,7 +1238,7 @@ def cmd_service(args) end end - public_key, secret_key = get_api_keys(args[:api_key]?) + public_key, secret_key = get_api_keys(args[:api_key]?.as?(String), args_public_key: args[:public_key]?.as?(String), account: args[:account]?.as?(Int32)) if args[:list]?.as?(Bool) result = api_request("/services", public_key, secret_key) @@ -1441,6 +1492,8 @@ def main args = { source_file: nil, api_key: nil, + public_key: nil, + account: nil, network: nil, env: [] of String, files: [] of String, @@ -1529,7 +1582,9 @@ def main parser = OptionParser.new do |opts| opts.banner = "Usage: un.cr [options] \n un.cr languages [--json]\n un.cr session [options]\n un.cr service [options]\n un.cr service env [options]\n un.cr snapshot [options]\n un.cr image [options]\n un.cr logs [options]\n un.cr key [options]\n un.cr health\n un.cr version\n\nService env commands:\n env status Show vault status\n env set Set vault (-e KEY=VALUE or --env-file FILE)\n env export Export vault contents\n env delete Delete vault" - opts.on("-k API_KEY", "--api-key=API_KEY", "API key") { |k| args[:api_key] = k } + opts.on("-k API_KEY", "--api-key=API_KEY", "Secret/API key") { |k| args[:api_key] = k } + opts.on("-p PUBLIC_KEY", "--public-key=PUBLIC_KEY", "Public key (use with -k for secret key)") { |k| args[:public_key] = k } + opts.on("--account=N", "Use row N from accounts.csv (0-based)") { |n| args[:account] = n.to_i32 } opts.on("-n NETWORK", "--network=NETWORK", "Network mode") { |n| args[:network] = n } opts.on("-e ENV", "--env=ENV", "Environment variable (KEY=VALUE)") { |e| args[:env].as(Array(String)) << e diff --git a/clients/dart/sync/src/un.dart b/clients/dart/sync/src/un.dart index deba76c..2c37ba1 100644 --- a/clients/dart/sync/src/un.dart +++ b/clients/dart/sync/src/un.dart @@ -73,6 +73,8 @@ class Args { String? command; String? sourceFile; String? apiKey; + String? publicKey; + int? account; String? network; int vcpu = 0; List env = []; @@ -141,21 +143,73 @@ class Args { bool snapshotHot = false; } -List getApiKeys(String? argsKey) { - final publicKey = Platform.environment['UNSANDBOX_PUBLIC_KEY']; - final secretKey = Platform.environment['UNSANDBOX_SECRET_KEY']; +Map? loadAccountsCSV(String path, int index) { + try { + final file = File(path); + if (!file.existsSync()) return null; + final lines = file.readAsLinesSync().where((l) { + final t = l.trim(); + return t.isNotEmpty && !t.startsWith('#'); + }).toList(); + if (index < 0 || index >= lines.length) return null; + final parts = lines[index].split(','); + if (parts.length < 2) return null; + final pk = parts[0].trim(); + final sk = parts[1].trim(); + if (pk.isEmpty || sk.isEmpty) return null; + return {'pk': pk, 'sk': sk}; + } catch (e) { + return null; + } +} - // Fall back to UNSANDBOX_API_KEY for backwards compatibility - if (publicKey == null || publicKey.isEmpty || secretKey == null || secretKey.isEmpty) { - final legacyKey = argsKey ?? Platform.environment['UNSANDBOX_API_KEY']; - if (legacyKey == null || legacyKey.isEmpty) { - stderr.writeln('${red}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set$reset'); - exit(1); +List getApiKeys(String? argsKey, {String? argsPublicKey, int? account}) { + // Tier 1: explicit -p/-k flags + if (argsPublicKey != null && argsPublicKey.isNotEmpty && argsKey != null && argsKey.isNotEmpty) { + return [argsPublicKey, argsKey]; + } + + // Tier 2: --account N → accounts.csv row N (bypasses env vars) + if (account != null) { + final home = Platform.environment['HOME'] ?? ''; + if (home.isNotEmpty) { + final fromHome = loadAccountsCSV('$home/.unsandbox/accounts.csv', account); + if (fromHome != null) return [fromHome['pk'], fromHome['sk']]; } + final fromLocal = loadAccountsCSV('./accounts.csv', account); + if (fromLocal != null) return [fromLocal['pk'], fromLocal['sk']]; + stderr.writeln('${red}Error: --account $account not found in accounts.csv$reset'); + exit(1); + } + + // Tier 3: env vars + final envPk = Platform.environment['UNSANDBOX_PUBLIC_KEY']; + final envSk = Platform.environment['UNSANDBOX_SECRET_KEY']; + if (envPk != null && envPk.isNotEmpty && envSk != null && envSk.isNotEmpty) { + return [envPk, envSk]; + } + + // Tier 4: ~/.unsandbox/accounts.csv row 0 (or UNSANDBOX_ACCOUNT env var) + final home = Platform.environment['HOME'] ?? ''; + final defIndexStr = Platform.environment['UNSANDBOX_ACCOUNT'] ?? '0'; + final defIndex = int.tryParse(defIndexStr) ?? 0; + if (home.isNotEmpty) { + final fromHome = loadAccountsCSV('$home/.unsandbox/accounts.csv', defIndex); + if (fromHome != null) return [fromHome['pk'], fromHome['sk']]; + } + + // Tier 5: ./accounts.csv row 0 + final fromLocal = loadAccountsCSV('./accounts.csv', defIndex); + if (fromLocal != null) return [fromLocal['pk'], fromLocal['sk']]; + + // Legacy UNSANDBOX_API_KEY fallback + final legacyKey = argsKey ?? Platform.environment['UNSANDBOX_API_KEY']; + if (legacyKey != null && legacyKey.isNotEmpty) { return [legacyKey, null]; } - return [publicKey, secretKey]; + stderr.writeln('${red}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set$reset'); + exit(1); } String detectLanguage(String filename) { @@ -509,7 +563,7 @@ Future serviceEnvDelete(String serviceId, String publicKey, String? secret } Future cmdServiceEnv(Args args) async { - final keys = getApiKeys(args.apiKey); + final keys = getApiKeys(args.apiKey, argsPublicKey: args.publicKey, account: args.account); final publicKey = keys[0]!; final secretKey = keys[1]; final action = args.envAction; @@ -579,7 +633,7 @@ Future cmdServiceEnv(Args args) async { } Future cmdExecute(Args args) async { - final keys = getApiKeys(args.apiKey); + final keys = getApiKeys(args.apiKey, argsPublicKey: args.publicKey, account: args.account); final publicKey = keys[0]!; final secretKey = keys[1]; final code = await File(args.sourceFile!).readAsString(); @@ -657,7 +711,7 @@ Future cmdExecute(Args args) async { } Future cmdSession(Args args) async { - final keys = getApiKeys(args.apiKey); + final keys = getApiKeys(args.apiKey, argsPublicKey: args.publicKey, account: args.account); final publicKey = keys[0]!; final secretKey = keys[1]; @@ -717,7 +771,7 @@ Future cmdSession(Args args) async { } Future cmdService(Args args) async { - final keys = getApiKeys(args.apiKey); + final keys = getApiKeys(args.apiKey, argsPublicKey: args.publicKey, account: args.account); final publicKey = keys[0]!; final secretKey = keys[1]; @@ -941,7 +995,7 @@ Future cmdService(Args args) async { } Future cmdLanguages(Args args) async { - final keys = getApiKeys(args.apiKey); + final keys = getApiKeys(args.apiKey, argsPublicKey: args.publicKey, account: args.account); final publicKey = keys[0]!; final secretKey = keys[1]; @@ -973,7 +1027,7 @@ Future cmdLanguages(Args args) async { } Future cmdImage(Args args) async { - final keys = getApiKeys(args.apiKey); + final keys = getApiKeys(args.apiKey, argsPublicKey: args.publicKey, account: args.account); final publicKey = keys[0]!; final secretKey = keys[1]; @@ -1115,7 +1169,7 @@ Future imageTransfer(String id, String toKey, String publicKey, String? se // Snapshot functions Future cmdSnapshot(Args args) async { - final keys = getApiKeys(args.apiKey); + final keys = getApiKeys(args.apiKey, argsPublicKey: args.publicKey, account: args.account); final publicKey = keys[0]!; final secretKey = keys[1]; @@ -1319,7 +1373,7 @@ String sdkVersion() { } Future cmdKey(Args args) async { - final keys = getApiKeys(args.apiKey); + final keys = getApiKeys(args.apiKey, argsPublicKey: args.publicKey, account: args.account); final publicKey = keys[0]!; final secretKey = keys[1]; @@ -1408,6 +1462,13 @@ Args parseArgs(List argv) { case '--api-key': args.apiKey = argv[++i]; break; + case '-p': + case '--public-key': + args.publicKey = argv[++i]; + break; + case '--account': + args.account = int.parse(argv[++i]); + break; case '-n': case '--network': args.network = argv[++i]; @@ -1658,7 +1719,9 @@ Execute options: -o DIR Output directory for artifacts -n MODE Network mode (zerotrust/semitrusted) -v N vCPU count (1-8) - -k KEY API key + -p KEY Public key (use with -k for secret key) + -k KEY Secret/API key + --account N Use row N from accounts.csv (0-based) Session options: --list List active sessions diff --git a/clients/nim/sync/src/un.nim b/clients/nim/sync/src/un.nim index 80da4a1..b0aac48 100644 --- a/clients/nim/sync/src/un.nim +++ b/clients/nim/sync/src/un.nim @@ -43,7 +43,7 @@ # un.nim session --list # un.nim service --name web --ports 8080 -import os, strutils, osproc, strformat, times +import os, strutils, osproc, strformat, times, sequtils const API_BASE = "https://api.unsandbox.com" @@ -1220,13 +1220,95 @@ proc cmdImage(list: bool, infoId, deleteId, lockId, unlockId, publishId, sourceT stderr.writeLine(RED & "Error: Use --list, --info, --delete, --lock, --unlock, --publish, --visibility, --spawn, --clone, --grant, --revoke, --trusted, or --transfer" & RESET) quit(1) -proc main() = - var publicKey = getEnv("UNSANDBOX_PUBLIC_KEY", "") - var secretKey = getEnv("UNSANDBOX_SECRET_KEY", "") +proc loadCredentialsFromCsv(csvPath: string, accountIndex: int): tuple[pk: string, sk: string] = + ## Load public_key,secret_key from CSV at given row index (0-based, skipping comments/blanks). + result = ("", "") + if not fileExists(csvPath): + return + try: + let content = readFile(csvPath) + var dataIndex = 0 + for line in content.splitLines(): + let trimmed = line.strip() + if trimmed.len == 0 or trimmed.startsWith("#"): + continue + if dataIndex == accountIndex: + let parts = trimmed.split(',') + if parts.len >= 2: + result = (parts[0].strip(), parts[1].strip()) + return + dataIndex.inc + except: + discard - # Fall back to UNSANDBOX_API_KEY for backwards compatibility - if publicKey == "": - publicKey = getEnv("UNSANDBOX_API_KEY", "") +proc resolveCredentials(argPk: string, argSk: string, accountIndex: int): tuple[pk: string, sk: string] = + ## Resolve credentials using 5-tier priority: + ## 1. Explicit -p/-k flags (argPk/argSk) + ## 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 + # Tier 1: explicit key flags + if argPk != "" and argSk != "": + return (argPk, argSk) + # Tier 2: --account N bypasses env vars + if accountIndex >= 0: + let homeDir = getHomeDir() + let homeCsv = homeDir / ".unsandbox" / "accounts.csv" + var creds = loadCredentialsFromCsv(homeCsv, accountIndex) + if creds.pk != "": + return creds + creds = loadCredentialsFromCsv("accounts.csv", accountIndex) + if creds.pk != "": + return creds + stderr.writeLine(RED & fmt"Error: No credentials found for account index {accountIndex} in accounts.csv" & RESET) + quit(1) + # Tier 3: env vars + let envPk = getEnv("UNSANDBOX_PUBLIC_KEY", "") + let envSk = getEnv("UNSANDBOX_SECRET_KEY", "") + if envPk != "" and envSk != "": + return (envPk, envSk) + # Tier 4: ~/.unsandbox/accounts.csv (default row or UNSANDBOX_ACCOUNT) + let defaultIndex = parseInt(getEnv("UNSANDBOX_ACCOUNT", "0")) + let homeDir = getHomeDir() + let homeCsv = homeDir / ".unsandbox" / "accounts.csv" + var creds = loadCredentialsFromCsv(homeCsv, defaultIndex) + if creds.pk != "": + return creds + # Tier 5: ./accounts.csv + creds = loadCredentialsFromCsv("accounts.csv", defaultIndex) + if creds.pk != "": + return creds + # Legacy UNSANDBOX_API_KEY + let legacyKey = getEnv("UNSANDBOX_API_KEY", "") + if legacyKey != "": + return (legacyKey, legacyKey) + stderr.writeLine(RED & "Error: No credentials found. Set UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY" & RESET) + quit(1) + +proc main() = + var argPublicKey = "" + var argSecretKey = "" + var accountIndex = -1 # -1 means not set + + # Pre-scan for --account, -p, and -k before subcommand dispatch + let rawArgs = commandLineParams() + var i = 0 + while i < rawArgs.len: + if rawArgs[i] == "--account" and i + 1 < rawArgs.len: + accountIndex = parseInt(rawArgs[i + 1]) + i.inc + elif rawArgs[i] == "-p" and i + 1 < rawArgs.len: + argPublicKey = rawArgs[i + 1] + i.inc + elif rawArgs[i] == "-k" and i + 1 < rawArgs.len: + argSecretKey = rawArgs[i + 1] + i.inc + i.inc + + let resolved = resolveCredentials(argPublicKey, argSecretKey, accountIndex) + var publicKey = resolved.pk + var secretKey = resolved.sk let args = commandLineParams() @@ -1265,6 +1347,11 @@ proc main() = stderr.writeLine("Service options:") stderr.writeLine(" -e KEY=VALUE Set environment variable (for vault)") stderr.writeLine(" --env-file FILE Load env vars from file (for vault)") + stderr.writeLine("") + stderr.writeLine("Credential options (global):") + stderr.writeLine(" -p PUBLIC_KEY Explicit public key") + stderr.writeLine(" -k SECRET_KEY Explicit secret key") + stderr.writeLine(" --account N Use row N from accounts.csv (bypasses env vars)") quit(1) if args[0] == "languages": @@ -1506,6 +1593,8 @@ proc main() = of "-n": network = args[i+1]; inc i of "-v": vcpu = parseInt(args[i+1]); inc i of "-k": publicKey = args[i+1]; inc i + of "-p": publicKey = args[i+1]; inc i + of "--account": inc i # already handled in pre-scan else: if args[i].startsWith("-"): stderr.writeLine(RED & "Unknown option: " & args[i] & RESET) diff --git a/clients/typescript/sync/src/un.ts b/clients/typescript/sync/src/un.ts index c1f668e..34056dd 100644 --- a/clients/typescript/sync/src/un.ts +++ b/clients/typescript/sync/src/un.ts @@ -141,6 +141,8 @@ interface Args { network: string | null; vcpu: number | null; apiKey: string | null; + publicKey: string | null; + account: number | null; shell: string | null; list: boolean; attach: string | null; @@ -203,23 +205,71 @@ interface ApiKeys { secretKey: string; } -function getApiKeys(argsKey: string | null): ApiKeys { - let publicKey = process.env.UNSANDBOX_PUBLIC_KEY; - let secretKey = process.env.UNSANDBOX_SECRET_KEY; +function loadAccountsCSV(filePath: string, index: number): { pk: string; sk: string } | null { + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const lines = content.split('\n').filter(l => { + const t = l.trim(); + return t.length > 0 && !t.startsWith('#'); + }); + if (index < 0 || index >= lines.length) return null; + const parts = lines[index].split(','); + if (parts.length < 2) return null; + const pk = parts[0].trim(); + const sk = parts[1].trim(); + if (!pk || !sk) return null; + return { pk, sk }; + } catch (e) { + return null; + } +} - if (!publicKey || !secretKey) { - const oldKey = argsKey || process.env.UNSANDBOX_API_KEY; - if (oldKey) { - publicKey = oldKey; - secretKey = oldKey; - } else { - console.error(`${RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set${RESET}`); - console.error(`${RED} (or legacy UNSANDBOX_API_KEY for backwards compatibility)${RESET}`); - process.exit(1); - } +function getApiKeys(argsPublicKey: string | null, argsSecretKey: string | null, account: number | null): ApiKeys { + // Tier 1: explicit -p/-k flags + if (argsPublicKey && argsSecretKey) { + return { publicKey: argsPublicKey, secretKey: argsSecretKey }; } - return { publicKey, secretKey }; + // Tier 2: --account N → accounts.csv row N (bypasses env vars) + if (account !== null) { + const homeDir = process.env.HOME || process.env.USERPROFILE || '.'; + const homeCsv = path.join(homeDir, '.unsandbox', 'accounts.csv'); + const localCsv = './accounts.csv'; + const fromHome = loadAccountsCSV(homeCsv, account); + if (fromHome) return { publicKey: fromHome.pk, secretKey: fromHome.sk }; + const fromLocal = loadAccountsCSV(localCsv, account); + if (fromLocal) return { publicKey: fromLocal.pk, secretKey: fromLocal.sk }; + console.error(`${RED}Error: --account ${account} not found in accounts.csv${RESET}`); + process.exit(1); + } + + // Tier 3: env vars + let publicKey = process.env.UNSANDBOX_PUBLIC_KEY; + let secretKey = process.env.UNSANDBOX_SECRET_KEY; + if (publicKey && secretKey) { + return { publicKey, secretKey }; + } + + // Tier 4: ~/.unsandbox/accounts.csv row 0 (or UNSANDBOX_ACCOUNT env var) + const homeDir = process.env.HOME || process.env.USERPROFILE || '.'; + const accountIndex = process.env.UNSANDBOX_ACCOUNT ? parseInt(process.env.UNSANDBOX_ACCOUNT) : 0; + const homeCsv = path.join(homeDir, '.unsandbox', 'accounts.csv'); + const fromHome = loadAccountsCSV(homeCsv, accountIndex); + if (fromHome) return { publicKey: fromHome.pk, secretKey: fromHome.sk }; + + // Tier 5: ./accounts.csv row 0 + const fromLocal = loadAccountsCSV('./accounts.csv', accountIndex); + if (fromLocal) return { publicKey: fromLocal.pk, secretKey: fromLocal.sk }; + + // Legacy UNSANDBOX_API_KEY fallback + const oldKey = process.env.UNSANDBOX_API_KEY; + if (oldKey) { + return { publicKey: oldKey, secretKey: oldKey }; + } + + console.error(`${RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set${RESET}`); + console.error(`${RED} (or legacy UNSANDBOX_API_KEY for backwards compatibility)${RESET}`); + process.exit(1); } function detectLanguage(filename: string): string { @@ -695,7 +745,7 @@ async function cmdServiceEnv(action: string, target: string, envs: string[], env } async function cmdExecute(args: Args): Promise { - const keys = getApiKeys(args.apiKey); + const keys = getApiKeys(args.publicKey, args.apiKey, args.account); let code: string; try { @@ -759,7 +809,7 @@ async function cmdExecute(args: Args): Promise { } async function cmdSession(args: Args): Promise { - const keys = getApiKeys(args.apiKey); + const keys = getApiKeys(args.publicKey, args.apiKey, args.account); if (args.list) { const result = await apiRequest("/sessions", "GET", null, keys); @@ -817,7 +867,7 @@ async function cmdSession(args: Args): Promise { } async function cmdService(args: Args): Promise { - const keys = getApiKeys(args.apiKey); + const keys = getApiKeys(args.publicKey, args.apiKey, args.account); if (args.list) { const result = await apiRequest("/services", "GET", null, keys); @@ -1083,7 +1133,7 @@ async function validateKey(keys: ApiKeys, shouldExtend: boolean): Promise } async function cmdLanguages(args: Args): Promise { - const keys = getApiKeys(args.apiKey); + const keys = getApiKeys(args.publicKey, args.apiKey, args.account); // Try cache first let langs = loadLanguagesCache(); @@ -1106,7 +1156,7 @@ async function cmdLanguages(args: Args): Promise { } async function cmdImage(args: Args): Promise { - const keys = getApiKeys(args.apiKey); + const keys = getApiKeys(args.publicKey, args.apiKey, args.account); if (args.list) { const result = await apiRequest("/images", "GET", null, keys); @@ -1199,7 +1249,7 @@ async function cmdImage(args: Args): Promise { } async function cmdKey(args: Args): Promise { - const keys = getApiKeys(args.apiKey); + const keys = getApiKeys(args.publicKey, args.apiKey, args.account); await validateKey(keys, args.extend); } @@ -1214,6 +1264,8 @@ function parseArgs(argv: string[]): Args { network: null, vcpu: null, apiKey: null, + publicKey: null, + account: null, shell: null, list: false, attach: null, @@ -1291,6 +1343,12 @@ function parseArgs(argv: string[]): Args { } else if (arg === '-k' && i + 1 < argv.length) { args.apiKey = argv[++i]; i++; + } else if (arg === '-p' && i + 1 < argv.length) { + args.publicKey = argv[++i]; + i++; + } else if (arg === '--account' && i + 1 < argv.length) { + args.account = parseInt(argv[++i]); + i++; } else if (arg === '-s' || arg === '--shell') { args.shell = argv[++i]; i++; @@ -1453,7 +1511,7 @@ async function main(): Promise { } else if (args.command === 'service') { // Check for "service env" subcommand if (args.envAction) { - const keys = getApiKeys(args.apiKey); + const keys = getApiKeys(args.publicKey, args.apiKey, args.account); await cmdServiceEnv(args.envAction, args.envTarget!, args.env, args.envFile, keys); } else { await cmdService(args); @@ -1484,7 +1542,9 @@ Execute options: -o DIR Output directory for artifacts -n MODE Network mode (zerotrust|semitrusted) -v N vCPU count (1-8) - -k KEY API key + -p KEY Public key (use with -k for secret key) + -k KEY Secret/API key + --account N Use row N from accounts.csv (0-based) Session options: -s, --shell NAME Shell/REPL (default: bash)