Add --account N flag and full credential resolution to TypeScript, Nim, Dart, Crystal SDKs

All four implementations now support:
- loadAccountsCSV/loadCredentialsFromCsv helper to parse accounts.csv files
- 5-tier credential resolution: -p/-k flags > --account N > env vars >
  ~/.unsandbox/accounts.csv > ./accounts.csv (with UNSANDBOX_ACCOUNT env var support)
- --account N CLI flag to select a specific row from accounts.csv (0-based)
- -p PUBLIC_KEY flag for explicit public key (separate from -k secret key)
This commit is contained in:
russell@unturf.com 2026-03-23 15:11:46 -04:00
parent 59eb474995
commit 6860871128
4 changed files with 336 additions and 69 deletions

View file

@ -132,21 +132,72 @@ def save_languages_cache(response : JSON::Any)
end end
end end
def get_api_keys(args_key : String?) : {String, String?} def load_accounts_csv(path : String, index : Int32) : {String, String}?
public_key = ENV["UNSANDBOX_PUBLIC_KEY"]? return nil unless File.exists?(path)
secret_key = ENV["UNSANDBOX_SECRET_KEY"]? begin
lines = File.read(path).split('\n').select do |l|
# Fall back to UNSANDBOX_API_KEY for backwards compatibility t = l.strip
if public_key.nil? || public_key.empty? || secret_key.nil? || secret_key.empty? !t.empty? && !t.starts_with?('#')
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
end 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} return {legacy_key, nil}
end end
{public_key, secret_key} STDERR.puts "#{RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set#{RESET}"
exit 1
end end
def extract_challenge_id(response_body : String) : String? def extract_challenge_id(response_body : String) : String?
@ -394,7 +445,7 @@ def build_env_content(envs : Array(String), env_file : String?) : String
end end
def cmd_service_env(args) 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) || "" action = args[:env_action]?.as?(String) || ""
target = args[:env_target]?.as?(String) || "" target = args[:env_target]?.as?(String) || ""
@ -463,7 +514,7 @@ def cmd_service_env(args)
end end
def cmd_execute(args) 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) filename = args[:source_file].as(String)
unless File.exists?(filename) unless File.exists?(filename)
@ -553,7 +604,7 @@ def cmd_execute(args)
end end
def cmd_session(args) 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) if args[:list]?.as?(Bool)
result = api_request("/sessions", public_key, secret_key) result = api_request("/sessions", public_key, secret_key)
@ -661,7 +712,7 @@ def cmd_session(args)
end end
def cmd_languages(args) 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 # Try to load from cache first
cached_response = load_languages_cache cached_response = load_languages_cache
@ -694,7 +745,7 @@ def cmd_languages(args)
end end
def cmd_key(args) 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 # Validate key
url = URI.parse(PORTAL_BASE + "/keys/validate") url = URI.parse(PORTAL_BASE + "/keys/validate")
@ -788,7 +839,7 @@ def cmd_key(args)
end end
def cmd_image(args) 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) if args[:list]?.as?(Bool)
result = api_request("/images", public_key, secret_key) result = api_request("/images", public_key, secret_key)
@ -949,7 +1000,7 @@ def cmd_image(args)
end end
def cmd_snapshot(args) 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) if args[:list]?.as?(Bool)
result = api_request("/snapshots", public_key, secret_key) result = api_request("/snapshots", public_key, secret_key)
@ -1072,7 +1123,7 @@ def cmd_snapshot(args)
end end
def cmd_logs(args) 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" source = args[:logs_source]?.as?(String) || "all"
lines = args[:logs_lines]?.as?(Int32) || 100 lines = args[:logs_lines]?.as?(Int32) || 100
@ -1187,7 +1238,7 @@ def cmd_service(args)
end end
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) if args[:list]?.as?(Bool)
result = api_request("/services", public_key, secret_key) result = api_request("/services", public_key, secret_key)
@ -1441,6 +1492,8 @@ def main
args = { args = {
source_file: nil, source_file: nil,
api_key: nil, api_key: nil,
public_key: nil,
account: nil,
network: nil, network: nil,
env: [] of String, env: [] of String,
files: [] of String, files: [] of String,
@ -1529,7 +1582,9 @@ def main
parser = OptionParser.new do |opts| parser = OptionParser.new do |opts|
opts.banner = "Usage: un.cr [options] <source_file>\n un.cr languages [--json]\n un.cr session [options]\n un.cr service [options]\n un.cr service env <action> <service_id> [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 <id> Show vault status\n env set <id> Set vault (-e KEY=VALUE or --env-file FILE)\n env export <id> Export vault contents\n env delete <id> Delete vault" opts.banner = "Usage: un.cr [options] <source_file>\n un.cr languages [--json]\n un.cr session [options]\n un.cr service [options]\n un.cr service env <action> <service_id> [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 <id> Show vault status\n env set <id> Set vault (-e KEY=VALUE or --env-file FILE)\n env export <id> Export vault contents\n env delete <id> 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("-n NETWORK", "--network=NETWORK", "Network mode") { |n| args[:network] = n }
opts.on("-e ENV", "--env=ENV", "Environment variable (KEY=VALUE)") { |e| opts.on("-e ENV", "--env=ENV", "Environment variable (KEY=VALUE)") { |e|
args[:env].as(Array(String)) << e args[:env].as(Array(String)) << e

View file

@ -73,6 +73,8 @@ class Args {
String? command; String? command;
String? sourceFile; String? sourceFile;
String? apiKey; String? apiKey;
String? publicKey;
int? account;
String? network; String? network;
int vcpu = 0; int vcpu = 0;
List<String> env = []; List<String> env = [];
@ -141,21 +143,73 @@ class Args {
bool snapshotHot = false; bool snapshotHot = false;
} }
List<String?> getApiKeys(String? argsKey) { Map<String, String>? loadAccountsCSV(String path, int index) {
final publicKey = Platform.environment['UNSANDBOX_PUBLIC_KEY']; try {
final secretKey = Platform.environment['UNSANDBOX_SECRET_KEY']; 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 List<String?> getApiKeys(String? argsKey, {String? argsPublicKey, int? account}) {
if (publicKey == null || publicKey.isEmpty || secretKey == null || secretKey.isEmpty) { // Tier 1: explicit -p/-k flags
final legacyKey = argsKey ?? Platform.environment['UNSANDBOX_API_KEY']; if (argsPublicKey != null && argsPublicKey.isNotEmpty && argsKey != null && argsKey.isNotEmpty) {
if (legacyKey == null || legacyKey.isEmpty) { return [argsPublicKey, argsKey];
stderr.writeln('${red}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set$reset'); }
exit(1);
// 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 [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) { String detectLanguage(String filename) {
@ -509,7 +563,7 @@ Future<bool> serviceEnvDelete(String serviceId, String publicKey, String? secret
} }
Future<void> cmdServiceEnv(Args args) async { Future<void> 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 publicKey = keys[0]!;
final secretKey = keys[1]; final secretKey = keys[1];
final action = args.envAction; final action = args.envAction;
@ -579,7 +633,7 @@ Future<void> cmdServiceEnv(Args args) async {
} }
Future<void> cmdExecute(Args args) async { Future<void> 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 publicKey = keys[0]!;
final secretKey = keys[1]; final secretKey = keys[1];
final code = await File(args.sourceFile!).readAsString(); final code = await File(args.sourceFile!).readAsString();
@ -657,7 +711,7 @@ Future<void> cmdExecute(Args args) async {
} }
Future<void> cmdSession(Args args) async { Future<void> 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 publicKey = keys[0]!;
final secretKey = keys[1]; final secretKey = keys[1];
@ -717,7 +771,7 @@ Future<void> cmdSession(Args args) async {
} }
Future<void> cmdService(Args args) async { Future<void> 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 publicKey = keys[0]!;
final secretKey = keys[1]; final secretKey = keys[1];
@ -941,7 +995,7 @@ Future<void> cmdService(Args args) async {
} }
Future<void> cmdLanguages(Args args) async { Future<void> 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 publicKey = keys[0]!;
final secretKey = keys[1]; final secretKey = keys[1];
@ -973,7 +1027,7 @@ Future<void> cmdLanguages(Args args) async {
} }
Future<void> cmdImage(Args args) async { Future<void> 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 publicKey = keys[0]!;
final secretKey = keys[1]; final secretKey = keys[1];
@ -1115,7 +1169,7 @@ Future<void> imageTransfer(String id, String toKey, String publicKey, String? se
// Snapshot functions // Snapshot functions
Future<void> cmdSnapshot(Args args) async { Future<void> 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 publicKey = keys[0]!;
final secretKey = keys[1]; final secretKey = keys[1];
@ -1319,7 +1373,7 @@ String sdkVersion() {
} }
Future<void> cmdKey(Args args) async { Future<void> 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 publicKey = keys[0]!;
final secretKey = keys[1]; final secretKey = keys[1];
@ -1408,6 +1462,13 @@ Args parseArgs(List<String> argv) {
case '--api-key': case '--api-key':
args.apiKey = argv[++i]; args.apiKey = argv[++i];
break; break;
case '-p':
case '--public-key':
args.publicKey = argv[++i];
break;
case '--account':
args.account = int.parse(argv[++i]);
break;
case '-n': case '-n':
case '--network': case '--network':
args.network = argv[++i]; args.network = argv[++i];
@ -1658,7 +1719,9 @@ Execute options:
-o DIR Output directory for artifacts -o DIR Output directory for artifacts
-n MODE Network mode (zerotrust/semitrusted) -n MODE Network mode (zerotrust/semitrusted)
-v N vCPU count (1-8) -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: Session options:
--list List active sessions --list List active sessions

View file

@ -43,7 +43,7 @@
# un.nim session --list # un.nim session --list
# un.nim service --name web --ports 8080 # un.nim service --name web --ports 8080
import os, strutils, osproc, strformat, times import os, strutils, osproc, strformat, times, sequtils
const const
API_BASE = "https://api.unsandbox.com" 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) stderr.writeLine(RED & "Error: Use --list, --info, --delete, --lock, --unlock, --publish, --visibility, --spawn, --clone, --grant, --revoke, --trusted, or --transfer" & RESET)
quit(1) quit(1)
proc main() = proc loadCredentialsFromCsv(csvPath: string, accountIndex: int): tuple[pk: string, sk: string] =
var publicKey = getEnv("UNSANDBOX_PUBLIC_KEY", "") ## Load public_key,secret_key from CSV at given row index (0-based, skipping comments/blanks).
var secretKey = getEnv("UNSANDBOX_SECRET_KEY", "") 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 proc resolveCredentials(argPk: string, argSk: string, accountIndex: int): tuple[pk: string, sk: string] =
if publicKey == "": ## Resolve credentials using 5-tier priority:
publicKey = getEnv("UNSANDBOX_API_KEY", "") ## 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() let args = commandLineParams()
@ -1265,6 +1347,11 @@ proc main() =
stderr.writeLine("Service options:") stderr.writeLine("Service options:")
stderr.writeLine(" -e KEY=VALUE Set environment variable (for vault)") stderr.writeLine(" -e KEY=VALUE Set environment variable (for vault)")
stderr.writeLine(" --env-file FILE Load env vars from file (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) quit(1)
if args[0] == "languages": if args[0] == "languages":
@ -1506,6 +1593,8 @@ proc main() =
of "-n": network = args[i+1]; inc i of "-n": network = args[i+1]; inc i
of "-v": vcpu = parseInt(args[i+1]); inc i of "-v": vcpu = parseInt(args[i+1]); inc i
of "-k": publicKey = 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: else:
if args[i].startsWith("-"): if args[i].startsWith("-"):
stderr.writeLine(RED & "Unknown option: " & args[i] & RESET) stderr.writeLine(RED & "Unknown option: " & args[i] & RESET)

View file

@ -141,6 +141,8 @@ interface Args {
network: string | null; network: string | null;
vcpu: number | null; vcpu: number | null;
apiKey: string | null; apiKey: string | null;
publicKey: string | null;
account: number | null;
shell: string | null; shell: string | null;
list: boolean; list: boolean;
attach: string | null; attach: string | null;
@ -203,23 +205,71 @@ interface ApiKeys {
secretKey: string; secretKey: string;
} }
function getApiKeys(argsKey: string | null): ApiKeys { function loadAccountsCSV(filePath: string, index: number): { pk: string; sk: string } | null {
let publicKey = process.env.UNSANDBOX_PUBLIC_KEY; try {
let secretKey = process.env.UNSANDBOX_SECRET_KEY; 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) { function getApiKeys(argsPublicKey: string | null, argsSecretKey: string | null, account: number | null): ApiKeys {
const oldKey = argsKey || process.env.UNSANDBOX_API_KEY; // Tier 1: explicit -p/-k flags
if (oldKey) { if (argsPublicKey && argsSecretKey) {
publicKey = oldKey; return { publicKey: argsPublicKey, secretKey: argsSecretKey };
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);
}
} }
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 { 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<void> { async function cmdExecute(args: Args): Promise<void> {
const keys = getApiKeys(args.apiKey); const keys = getApiKeys(args.publicKey, args.apiKey, args.account);
let code: string; let code: string;
try { try {
@ -759,7 +809,7 @@ async function cmdExecute(args: Args): Promise<void> {
} }
async function cmdSession(args: Args): Promise<void> { async function cmdSession(args: Args): Promise<void> {
const keys = getApiKeys(args.apiKey); const keys = getApiKeys(args.publicKey, args.apiKey, args.account);
if (args.list) { if (args.list) {
const result = await apiRequest("/sessions", "GET", null, keys); const result = await apiRequest("/sessions", "GET", null, keys);
@ -817,7 +867,7 @@ async function cmdSession(args: Args): Promise<void> {
} }
async function cmdService(args: Args): Promise<void> { async function cmdService(args: Args): Promise<void> {
const keys = getApiKeys(args.apiKey); const keys = getApiKeys(args.publicKey, args.apiKey, args.account);
if (args.list) { if (args.list) {
const result = await apiRequest("/services", "GET", null, keys); const result = await apiRequest("/services", "GET", null, keys);
@ -1083,7 +1133,7 @@ async function validateKey(keys: ApiKeys, shouldExtend: boolean): Promise<void>
} }
async function cmdLanguages(args: Args): Promise<void> { async function cmdLanguages(args: Args): Promise<void> {
const keys = getApiKeys(args.apiKey); const keys = getApiKeys(args.publicKey, args.apiKey, args.account);
// Try cache first // Try cache first
let langs = loadLanguagesCache(); let langs = loadLanguagesCache();
@ -1106,7 +1156,7 @@ async function cmdLanguages(args: Args): Promise<void> {
} }
async function cmdImage(args: Args): Promise<void> { async function cmdImage(args: Args): Promise<void> {
const keys = getApiKeys(args.apiKey); const keys = getApiKeys(args.publicKey, args.apiKey, args.account);
if (args.list) { if (args.list) {
const result = await apiRequest("/images", "GET", null, keys); const result = await apiRequest("/images", "GET", null, keys);
@ -1199,7 +1249,7 @@ async function cmdImage(args: Args): Promise<void> {
} }
async function cmdKey(args: Args): Promise<void> { async function cmdKey(args: Args): Promise<void> {
const keys = getApiKeys(args.apiKey); const keys = getApiKeys(args.publicKey, args.apiKey, args.account);
await validateKey(keys, args.extend); await validateKey(keys, args.extend);
} }
@ -1214,6 +1264,8 @@ function parseArgs(argv: string[]): Args {
network: null, network: null,
vcpu: null, vcpu: null,
apiKey: null, apiKey: null,
publicKey: null,
account: null,
shell: null, shell: null,
list: false, list: false,
attach: null, attach: null,
@ -1291,6 +1343,12 @@ function parseArgs(argv: string[]): Args {
} else if (arg === '-k' && i + 1 < argv.length) { } else if (arg === '-k' && i + 1 < argv.length) {
args.apiKey = argv[++i]; args.apiKey = argv[++i];
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') { } else if (arg === '-s' || arg === '--shell') {
args.shell = argv[++i]; args.shell = argv[++i];
i++; i++;
@ -1453,7 +1511,7 @@ async function main(): Promise<void> {
} else if (args.command === 'service') { } else if (args.command === 'service') {
// Check for "service env" subcommand // Check for "service env" subcommand
if (args.envAction) { 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); await cmdServiceEnv(args.envAction, args.envTarget!, args.env, args.envFile, keys);
} else { } else {
await cmdService(args); await cmdService(args);
@ -1484,7 +1542,9 @@ Execute options:
-o DIR Output directory for artifacts -o DIR Output directory for artifacts
-n MODE Network mode (zerotrust|semitrusted) -n MODE Network mode (zerotrust|semitrusted)
-v N vCPU count (1-8) -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: Session options:
-s, --shell NAME Shell/REPL (default: bash) -s, --shell NAME Shell/REPL (default: bash)