- Rust SDK: Complete refactoring with public library functions (execute, execute_async, wait, get_job, cancel_job, list_jobs, languages, detect_language) - C SDK: Added library function documentation with 4-tier authentication (args, env vars, ~/.unsandbox/accounts.csv, ./accounts.csv) - C++ SDK: Added library function documentation with proper HMAC-SHA256 authentication - Zig SDK: Added library function documentation with exponential backoff polling - JavaScript SDK: Exported internal _signRequest, _getCredentials, _apiRequest functions for testing - All implementations support 1-hour language cache and language detection from file extensions All SDKs follow the same API pattern from un.py reference implementation: - execute(language, code, opts) - synchronous execution - execute_async(language, code, opts) - async submission - wait(job_id, opts) - polling with exponential backoff [300, 450, 700, 900, 650, 1600, 2000]ms - languages(cache_ttl) - cached language list - detect_language(filename) - extension-based detection
2064 lines
63 KiB
Go
2064 lines
63 KiB
Go
// PUBLIC DOMAIN - NO LICENSE, NO WARRANTY
|
|
//
|
|
// This is free public domain software for the public good of a permacomputer hosted
|
|
// at permacomputer.com - an always-on computer by the people, for the people. One
|
|
// which is durable, easy to repair, and distributed like tap water for machine
|
|
// learning intelligence.
|
|
//
|
|
// The permacomputer is community-owned infrastructure optimized around four values:
|
|
//
|
|
// TRUTH - First principles, math & science, open source code freely distributed
|
|
// FREEDOM - Voluntary partnerships, freedom from tyranny & corporate control
|
|
// HARMONY - Minimal waste, self-renewing systems with diverse thriving connections
|
|
// LOVE - Be yourself without hurting others, cooperation through natural law
|
|
//
|
|
// This software contributes to that vision by enabling code execution across 42+
|
|
// programming languages through a unified interface, accessible to all. Code is
|
|
// seeds to sprout on any abandoned technology.
|
|
//
|
|
// Learn more: https://www.permacomputer.com
|
|
//
|
|
// Anyone is free to copy, modify, publish, use, compile, sell, or distribute this
|
|
// software, either in source code form or as a compiled binary, for any purpose,
|
|
// commercial or non-commercial, and by any means.
|
|
//
|
|
// NO WARRANTY. THE SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND.
|
|
//
|
|
// That said, our permacomputer's digital membrane stratum continuously runs unit,
|
|
// integration, and functional tests on all of it's own software - with our
|
|
// permacomputer monitoring itself, repairing itself, with minimal human in the
|
|
// loop guidance. Our agents do their best.
|
|
//
|
|
// Copyright 2025 TimeHexOn & foxhop & russell@unturf
|
|
// https://www.timehexon.com
|
|
// https://www.foxhop.net
|
|
// https://www.unturf.com/software
|
|
//
|
|
// unsandbox SDK for Go - Execute code in secure sandboxes
|
|
// https://unsandbox.com | https://api.unsandbox.com/openapi
|
|
//
|
|
// Library Usage:
|
|
// // Change "package main" to "package unsandbox" to use as library
|
|
// import "unsandbox"
|
|
// result, err := unsandbox.Execute("python", `print("Hello")`, nil)
|
|
// job, err := unsandbox.ExecuteAsync("python", code, nil)
|
|
// result, err := unsandbox.Wait(job.JobID, nil)
|
|
//
|
|
// CLI Usage:
|
|
// go run un.go script.py
|
|
// go run un.go -s python 'print("Hello")'
|
|
// go run un.go session --shell python3
|
|
//
|
|
// Authentication (in priority order):
|
|
// 1. Function arguments: Execute(..., &Options{PublicKey: "...", SecretKey: "..."})
|
|
// 2. Environment variables: UNSANDBOX_PUBLIC_KEY + UNSANDBOX_SECRET_KEY
|
|
// 3. Config file: ~/.unsandbox/accounts.csv (public_key,secret_key per line)
|
|
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"os/user"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ============================================================================
|
|
// Configuration
|
|
// ============================================================================
|
|
|
|
const (
|
|
// APIBase is the base URL for the unsandbox API
|
|
APIBase = "https://api.unsandbox.com"
|
|
// PortalBase is the base URL for the unsandbox portal
|
|
PortalBase = "https://unsandbox.com"
|
|
// DefaultTimeout is the default HTTP request timeout in seconds
|
|
DefaultTimeout = 300
|
|
// DefaultTTL is the default execution timeout in seconds
|
|
DefaultTTL = 60
|
|
// Version is the SDK version
|
|
Version = "2.0.0"
|
|
)
|
|
|
|
// ANSI color codes for terminal output
|
|
const (
|
|
Blue = "\033[34m"
|
|
Red = "\033[31m"
|
|
Green = "\033[32m"
|
|
Yellow = "\033[33m"
|
|
Reset = "\033[0m"
|
|
)
|
|
|
|
// PollDelays defines the exponential backoff delays in milliseconds
|
|
var PollDelays = []int{300, 450, 700, 900, 650, 1600, 2000}
|
|
|
|
// ExtMap maps file extensions to language names
|
|
var ExtMap = map[string]string{
|
|
".py": "python", ".js": "javascript", ".ts": "typescript",
|
|
".rb": "ruby", ".php": "php", ".pl": "perl", ".lua": "lua",
|
|
".sh": "bash", ".go": "go", ".rs": "rust", ".c": "c",
|
|
".cpp": "cpp", ".cc": "cpp", ".cxx": "cpp",
|
|
".java": "java", ".kt": "kotlin", ".cs": "csharp", ".fs": "fsharp",
|
|
".hs": "haskell", ".ml": "ocaml", ".clj": "clojure", ".scm": "scheme",
|
|
".lisp": "commonlisp", ".erl": "erlang", ".ex": "elixir", ".exs": "elixir",
|
|
".jl": "julia", ".r": "r", ".R": "r", ".cr": "crystal",
|
|
".d": "d", ".nim": "nim", ".zig": "zig", ".v": "v",
|
|
".dart": "dart", ".groovy": "groovy", ".scala": "scala",
|
|
".f90": "fortran", ".f95": "fortran", ".cob": "cobol",
|
|
".pro": "prolog", ".forth": "forth", ".4th": "forth",
|
|
".tcl": "tcl", ".raku": "raku", ".m": "objc", ".awk": "awk",
|
|
}
|
|
|
|
// ============================================================================
|
|
// Errors
|
|
// ============================================================================
|
|
|
|
var (
|
|
// ErrNoCredentials is returned when no API credentials are found
|
|
ErrNoCredentials = errors.New("no credentials found: set UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY, or create ~/.unsandbox/accounts.csv, or pass credentials to function")
|
|
// ErrAuthenticationFailed is returned when authentication fails
|
|
ErrAuthenticationFailed = errors.New("authentication failed")
|
|
// ErrTimestampExpired is returned when the request timestamp is expired
|
|
ErrTimestampExpired = errors.New("request timestamp expired: your system clock may be out of sync")
|
|
// ErrTimeout is returned when a job times out
|
|
ErrTimeout = errors.New("job timed out")
|
|
// ErrMaxPollsExceeded is returned when max polling attempts are exceeded
|
|
ErrMaxPollsExceeded = errors.New("max polls exceeded")
|
|
)
|
|
|
|
// UnsandboxError represents an API error with status code and response
|
|
type UnsandboxError struct {
|
|
Message string
|
|
StatusCode int
|
|
Response string
|
|
}
|
|
|
|
func (e *UnsandboxError) Error() string {
|
|
return e.Message
|
|
}
|
|
|
|
// ExecutionError represents a code execution failure
|
|
type ExecutionError struct {
|
|
Message string
|
|
ExitCode int
|
|
Stderr string
|
|
}
|
|
|
|
func (e *ExecutionError) Error() string {
|
|
return e.Message
|
|
}
|
|
|
|
// ============================================================================
|
|
// Types
|
|
// ============================================================================
|
|
|
|
// Options contains optional parameters for API requests
|
|
type Options struct {
|
|
PublicKey string
|
|
SecretKey string
|
|
AccountIndex int
|
|
Env map[string]string
|
|
InputFiles []InputFile
|
|
NetworkMode string // "zerotrust" or "semitrusted"
|
|
TTL int // Execution timeout in seconds (1-900)
|
|
VCPU int // Virtual CPUs (1-8)
|
|
ReturnArtifact bool
|
|
ReturnWasm bool
|
|
Timeout int // HTTP request timeout in seconds
|
|
MaxPolls int // Maximum polling attempts for wait()
|
|
}
|
|
|
|
// InputFile represents a file to be sent with the execution request
|
|
type InputFile struct {
|
|
Filename string
|
|
Content string
|
|
ContentBase64 string
|
|
}
|
|
|
|
// ExecuteResult represents the result of a code execution
|
|
type ExecuteResult struct {
|
|
Success bool `json:"success"`
|
|
Stdout string `json:"stdout"`
|
|
Stderr string `json:"stderr"`
|
|
ExitCode int `json:"exit_code"`
|
|
Language string `json:"language"`
|
|
JobID string `json:"job_id"`
|
|
TotalTimeMs int `json:"total_time_ms"`
|
|
NetworkMode string `json:"network_mode"`
|
|
Artifacts []Artifact `json:"artifacts,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// JobResult represents an async job status
|
|
type JobResult struct {
|
|
JobID string `json:"job_id"`
|
|
Status string `json:"status"`
|
|
DetectedLanguage string `json:"detected_language,omitempty"`
|
|
Result *ExecuteResult `json:"result,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
SubmittedAt string `json:"submitted_at,omitempty"`
|
|
CompletedAt string `json:"completed_at,omitempty"`
|
|
}
|
|
|
|
// Artifact represents a build artifact
|
|
type Artifact struct {
|
|
Filename string `json:"filename"`
|
|
ContentBase64 string `json:"content_base64"`
|
|
Size int `json:"size,omitempty"`
|
|
}
|
|
|
|
// LanguagesResult represents the result of languages() call
|
|
type LanguagesResult struct {
|
|
Languages []string `json:"languages"`
|
|
Count int `json:"count"`
|
|
Aliases map[string]string `json:"aliases,omitempty"`
|
|
}
|
|
|
|
// ImageResult represents the result of image generation
|
|
type ImageResult struct {
|
|
Images []string `json:"images"`
|
|
CreatedAt string `json:"created_at,omitempty"`
|
|
}
|
|
|
|
// Credentials holds API key pair
|
|
type Credentials struct {
|
|
PublicKey string
|
|
SecretKey string
|
|
}
|
|
|
|
// ============================================================================
|
|
// HMAC Authentication
|
|
// ============================================================================
|
|
|
|
// SignRequest generates an HMAC-SHA256 signature for an API request.
|
|
// Signature = HMAC-SHA256(secretKey, "timestamp:METHOD:path:body")
|
|
func SignRequest(secretKey string, timestamp int64, method, path, body string) string {
|
|
message := fmt.Sprintf("%d:%s:%s:%s", timestamp, method, path, body)
|
|
h := hmac.New(sha256.New, []byte(secretKey))
|
|
h.Write([]byte(message))
|
|
return hex.EncodeToString(h.Sum(nil))
|
|
}
|
|
|
|
// GetCredentials retrieves API credentials in priority order:
|
|
// 1. Function arguments (publicKey, secretKey)
|
|
// 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
|
|
// 3. Config file ~/.unsandbox/accounts.csv
|
|
func GetCredentials(publicKey, secretKey string, accountIndex int) (*Credentials, error) {
|
|
// Priority 1: Function arguments
|
|
if publicKey != "" && secretKey != "" {
|
|
return &Credentials{PublicKey: publicKey, SecretKey: secretKey}, nil
|
|
}
|
|
|
|
// Priority 2: Environment variables
|
|
envPk := os.Getenv("UNSANDBOX_PUBLIC_KEY")
|
|
envSk := os.Getenv("UNSANDBOX_SECRET_KEY")
|
|
if envPk != "" && envSk != "" {
|
|
return &Credentials{PublicKey: envPk, SecretKey: envSk}, nil
|
|
}
|
|
|
|
// Priority 3: Config file
|
|
usr, err := user.Current()
|
|
if err == nil {
|
|
accountsPath := filepath.Join(usr.HomeDir, ".unsandbox", "accounts.csv")
|
|
if data, err := os.ReadFile(accountsPath); err == nil {
|
|
var validAccounts []Credentials
|
|
scanner := bufio.NewScanner(bytes.NewReader(data))
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
parts := strings.SplitN(line, ",", 2)
|
|
if len(parts) == 2 {
|
|
pk := strings.TrimSpace(parts[0])
|
|
sk := strings.TrimSpace(parts[1])
|
|
if strings.HasPrefix(pk, "unsb-pk-") && strings.HasPrefix(sk, "unsb-sk-") {
|
|
validAccounts = append(validAccounts, Credentials{PublicKey: pk, SecretKey: sk})
|
|
}
|
|
}
|
|
}
|
|
if len(validAccounts) > accountIndex {
|
|
return &validAccounts[accountIndex], nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil, ErrNoCredentials
|
|
}
|
|
|
|
// ============================================================================
|
|
// HTTP Client
|
|
// ============================================================================
|
|
|
|
// apiRequest makes an authenticated API request with HMAC signature
|
|
func apiRequest(endpoint, method string, data interface{}, contentType string, opts *Options) (map[string]interface{}, error) {
|
|
if opts == nil {
|
|
opts = &Options{}
|
|
}
|
|
|
|
creds, err := GetCredentials(opts.PublicKey, opts.SecretKey, opts.AccountIndex)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
urlStr := APIBase + endpoint
|
|
var reqBody io.Reader
|
|
bodyStr := ""
|
|
|
|
if data != nil {
|
|
switch v := data.(type) {
|
|
case string:
|
|
bodyStr = v
|
|
reqBody = strings.NewReader(v)
|
|
default:
|
|
jsonData, err := json.Marshal(data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error marshaling JSON: %w", err)
|
|
}
|
|
bodyStr = string(jsonData)
|
|
reqBody = bytes.NewBuffer(jsonData)
|
|
}
|
|
}
|
|
|
|
req, err := http.NewRequest(method, urlStr, reqBody)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error creating request: %w", err)
|
|
}
|
|
|
|
// HMAC authentication
|
|
timestamp := time.Now().Unix()
|
|
signature := SignRequest(creds.SecretKey, timestamp, method, endpoint, bodyStr)
|
|
|
|
req.Header.Set("Authorization", "Bearer "+creds.PublicKey)
|
|
req.Header.Set("X-Timestamp", strconv.FormatInt(timestamp, 10))
|
|
req.Header.Set("X-Signature", signature)
|
|
if contentType == "" {
|
|
contentType = "application/json"
|
|
}
|
|
req.Header.Set("Content-Type", contentType)
|
|
|
|
timeout := opts.Timeout
|
|
if timeout <= 0 {
|
|
timeout = DefaultTimeout
|
|
}
|
|
|
|
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error making request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error reading response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode >= 400 {
|
|
if resp.StatusCode == 401 && strings.Contains(strings.ToLower(string(body)), "timestamp") {
|
|
return nil, ErrTimestampExpired
|
|
}
|
|
if resp.StatusCode == 401 {
|
|
return nil, &UnsandboxError{
|
|
Message: fmt.Sprintf("authentication failed: %s", string(body)),
|
|
StatusCode: resp.StatusCode,
|
|
Response: string(body),
|
|
}
|
|
}
|
|
return nil, &UnsandboxError{
|
|
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(body)),
|
|
StatusCode: resp.StatusCode,
|
|
Response: string(body),
|
|
}
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
if len(body) > 0 {
|
|
if err := json.Unmarshal(body, &result); err != nil {
|
|
return nil, fmt.Errorf("error parsing response: %w", err)
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// ============================================================================
|
|
// Core Execution Functions
|
|
// ============================================================================
|
|
|
|
// Execute runs code synchronously and returns results.
|
|
//
|
|
// Parameters:
|
|
// - language: Programming language (python, javascript, go, rust, etc.)
|
|
// - code: Source code to execute
|
|
// - opts: Optional parameters (env, inputFiles, networkMode, ttl, vcpu, etc.)
|
|
//
|
|
// Returns ExecuteResult with stdout, stderr, exit_code, etc.
|
|
//
|
|
// Example:
|
|
// result, err := un.Execute("python", `print("Hello World")`, nil)
|
|
// if err != nil { log.Fatal(err) }
|
|
// fmt.Println(result.Stdout)
|
|
func Execute(language, code string, opts *Options) (*ExecuteResult, error) {
|
|
if opts == nil {
|
|
opts = &Options{}
|
|
}
|
|
|
|
payload := map[string]interface{}{
|
|
"language": language,
|
|
"code": code,
|
|
"network_mode": getNetworkMode(opts.NetworkMode),
|
|
"ttl": getTTL(opts.TTL),
|
|
"vcpu": getVCPU(opts.VCPU),
|
|
}
|
|
|
|
if opts.Env != nil && len(opts.Env) > 0 {
|
|
payload["env"] = opts.Env
|
|
}
|
|
|
|
if len(opts.InputFiles) > 0 {
|
|
payload["input_files"] = processInputFiles(opts.InputFiles)
|
|
}
|
|
|
|
if opts.ReturnArtifact {
|
|
payload["return_artifact"] = true
|
|
}
|
|
if opts.ReturnWasm {
|
|
payload["return_wasm_artifact"] = true
|
|
}
|
|
|
|
result, err := apiRequest("/execute", "POST", payload, "application/json", opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return parseExecuteResult(result), nil
|
|
}
|
|
|
|
// ExecuteAsync executes code asynchronously and returns a job ID for polling.
|
|
//
|
|
// Parameters:
|
|
// - language: Programming language
|
|
// - code: Source code to execute
|
|
// - opts: Optional parameters
|
|
//
|
|
// Returns JobResult with job_id and status ("pending")
|
|
//
|
|
// Example:
|
|
// job, err := un.ExecuteAsync("python", longRunningCode, nil)
|
|
// fmt.Printf("Job submitted: %s\n", job.JobID)
|
|
// result, err := un.Wait(job.JobID, nil)
|
|
func ExecuteAsync(language, code string, opts *Options) (*JobResult, error) {
|
|
if opts == nil {
|
|
opts = &Options{}
|
|
}
|
|
|
|
payload := map[string]interface{}{
|
|
"language": language,
|
|
"code": code,
|
|
"network_mode": getNetworkMode(opts.NetworkMode),
|
|
"ttl": getTTL(opts.TTL),
|
|
"vcpu": getVCPU(opts.VCPU),
|
|
}
|
|
|
|
if opts.Env != nil && len(opts.Env) > 0 {
|
|
payload["env"] = opts.Env
|
|
}
|
|
|
|
if len(opts.InputFiles) > 0 {
|
|
payload["input_files"] = processInputFiles(opts.InputFiles)
|
|
}
|
|
|
|
if opts.ReturnArtifact {
|
|
payload["return_artifact"] = true
|
|
}
|
|
if opts.ReturnWasm {
|
|
payload["return_wasm_artifact"] = true
|
|
}
|
|
|
|
result, err := apiRequest("/execute/async", "POST", payload, "application/json", opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return parseJobResult(result), nil
|
|
}
|
|
|
|
// Run executes code with automatic language detection from shebang.
|
|
//
|
|
// Parameters:
|
|
// - code: Source code with shebang (e.g., #!/usr/bin/env python3)
|
|
// - opts: Optional parameters
|
|
//
|
|
// Returns ExecuteResult with detected_language, stdout, stderr, etc.
|
|
//
|
|
// Example:
|
|
// code := "#!/usr/bin/env python3\nprint('Auto-detected!')"
|
|
// result, err := un.Run(code, nil)
|
|
// fmt.Println(result.Language) // "python"
|
|
func Run(code string, opts *Options) (*ExecuteResult, error) {
|
|
if opts == nil {
|
|
opts = &Options{}
|
|
}
|
|
|
|
params := url.Values{}
|
|
params.Set("ttl", strconv.Itoa(getTTL(opts.TTL)))
|
|
params.Set("network_mode", getNetworkMode(opts.NetworkMode))
|
|
|
|
if opts.Env != nil && len(opts.Env) > 0 {
|
|
envJSON, _ := json.Marshal(opts.Env)
|
|
params.Set("env", string(envJSON))
|
|
}
|
|
|
|
endpoint := "/run?" + params.Encode()
|
|
result, err := apiRequest(endpoint, "POST", code, "text/plain", opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return parseExecuteResult(result), nil
|
|
}
|
|
|
|
// RunAsync executes code asynchronously with automatic language detection.
|
|
//
|
|
// Parameters:
|
|
// - code: Source code with shebang
|
|
// - opts: Optional parameters
|
|
//
|
|
// Returns JobResult with job_id, detected_language, status ("pending")
|
|
func RunAsync(code string, opts *Options) (*JobResult, error) {
|
|
if opts == nil {
|
|
opts = &Options{}
|
|
}
|
|
|
|
params := url.Values{}
|
|
params.Set("ttl", strconv.Itoa(getTTL(opts.TTL)))
|
|
params.Set("network_mode", getNetworkMode(opts.NetworkMode))
|
|
|
|
if opts.Env != nil && len(opts.Env) > 0 {
|
|
envJSON, _ := json.Marshal(opts.Env)
|
|
params.Set("env", string(envJSON))
|
|
}
|
|
|
|
endpoint := "/run/async?" + params.Encode()
|
|
result, err := apiRequest(endpoint, "POST", code, "text/plain", opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return parseJobResult(result), nil
|
|
}
|
|
|
|
// ============================================================================
|
|
// Job Management
|
|
// ============================================================================
|
|
|
|
// GetJob retrieves job status and results.
|
|
//
|
|
// Parameters:
|
|
// - jobID: Job ID from ExecuteAsync or RunAsync
|
|
// - opts: Optional parameters (credentials)
|
|
//
|
|
// Returns JobResult with status: pending, running, completed, failed, timeout, cancelled
|
|
func GetJob(jobID string, opts *Options) (*JobResult, error) {
|
|
result, err := apiRequest("/jobs/"+jobID, "GET", nil, "", opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return parseJobResult(result), nil
|
|
}
|
|
|
|
// Wait polls for job completion with exponential backoff.
|
|
//
|
|
// Parameters:
|
|
// - jobID: Job ID from ExecuteAsync or RunAsync
|
|
// - opts: Optional parameters (MaxPolls defaults to 100)
|
|
//
|
|
// Returns final JobResult when job completes
|
|
//
|
|
// Example:
|
|
// job, _ := un.ExecuteAsync("python", code, nil)
|
|
// result, err := un.Wait(job.JobID, nil)
|
|
// fmt.Println(result.Result.Stdout)
|
|
func Wait(jobID string, opts *Options) (*JobResult, error) {
|
|
if opts == nil {
|
|
opts = &Options{}
|
|
}
|
|
|
|
maxPolls := opts.MaxPolls
|
|
if maxPolls <= 0 {
|
|
maxPolls = 100
|
|
}
|
|
|
|
terminalStates := map[string]bool{
|
|
"completed": true,
|
|
"failed": true,
|
|
"timeout": true,
|
|
"cancelled": true,
|
|
}
|
|
|
|
for i := 0; i < maxPolls; i++ {
|
|
// Exponential backoff delay
|
|
delayIdx := i
|
|
if delayIdx >= len(PollDelays) {
|
|
delayIdx = len(PollDelays) - 1
|
|
}
|
|
time.Sleep(time.Duration(PollDelays[delayIdx]) * time.Millisecond)
|
|
|
|
result, err := GetJob(jobID, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if terminalStates[result.Status] {
|
|
if result.Status == "failed" {
|
|
return nil, &ExecutionError{
|
|
Message: fmt.Sprintf("job failed: %s", result.Error),
|
|
ExitCode: -1,
|
|
Stderr: result.Error,
|
|
}
|
|
}
|
|
if result.Status == "timeout" {
|
|
return nil, fmt.Errorf("%w: %s", ErrTimeout, jobID)
|
|
}
|
|
return result, nil
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("%w: job %s after %d polls", ErrMaxPollsExceeded, jobID, maxPolls)
|
|
}
|
|
|
|
// CancelJob cancels a running job.
|
|
//
|
|
// Returns partial output and artifacts collected before cancellation.
|
|
func CancelJob(jobID string, opts *Options) (*JobResult, error) {
|
|
result, err := apiRequest("/jobs/"+jobID, "DELETE", nil, "", opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return parseJobResult(result), nil
|
|
}
|
|
|
|
// ListJobs returns all active jobs for this API key.
|
|
//
|
|
// Returns slice of JobResult with job_id, language, status, submitted_at
|
|
func ListJobs(opts *Options) ([]JobResult, error) {
|
|
result, err := apiRequest("/jobs", "GET", nil, "", opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var jobs []JobResult
|
|
if jobsRaw, ok := result["jobs"].([]interface{}); ok {
|
|
for _, j := range jobsRaw {
|
|
if jMap, ok := j.(map[string]interface{}); ok {
|
|
jobs = append(jobs, *parseJobResult(jMap))
|
|
}
|
|
}
|
|
}
|
|
return jobs, nil
|
|
}
|
|
|
|
// ============================================================================
|
|
// Image Generation
|
|
// ============================================================================
|
|
|
|
// ImageOptions contains options for image generation
|
|
type ImageOptions struct {
|
|
PublicKey string
|
|
SecretKey string
|
|
Model string
|
|
Size string // e.g., "1024x1024", "512x512"
|
|
Quality string // "standard" or "hd"
|
|
N int // Number of images to generate
|
|
}
|
|
|
|
// Image generates images from a text prompt.
|
|
//
|
|
// Parameters:
|
|
// - prompt: Text description of the image to generate
|
|
// - opts: Optional parameters (model, size, quality, n)
|
|
//
|
|
// Example:
|
|
// result, err := un.Image("A sunset over mountains", nil)
|
|
// fmt.Println(result.Images[0])
|
|
func Image(prompt string, opts *ImageOptions) (*ImageResult, error) {
|
|
if opts == nil {
|
|
opts = &ImageOptions{}
|
|
}
|
|
|
|
payload := map[string]interface{}{
|
|
"prompt": prompt,
|
|
}
|
|
|
|
size := opts.Size
|
|
if size == "" {
|
|
size = "1024x1024"
|
|
}
|
|
payload["size"] = size
|
|
|
|
quality := opts.Quality
|
|
if quality == "" {
|
|
quality = "standard"
|
|
}
|
|
payload["quality"] = quality
|
|
|
|
n := opts.N
|
|
if n <= 0 {
|
|
n = 1
|
|
}
|
|
payload["n"] = n
|
|
|
|
if opts.Model != "" {
|
|
payload["model"] = opts.Model
|
|
}
|
|
|
|
apiOpts := &Options{
|
|
PublicKey: opts.PublicKey,
|
|
SecretKey: opts.SecretKey,
|
|
}
|
|
|
|
result, err := apiRequest("/image", "POST", payload, "application/json", apiOpts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
imgResult := &ImageResult{}
|
|
if images, ok := result["images"].([]interface{}); ok {
|
|
for _, img := range images {
|
|
if imgStr, ok := img.(string); ok {
|
|
imgResult.Images = append(imgResult.Images, imgStr)
|
|
}
|
|
}
|
|
}
|
|
if createdAt, ok := result["created_at"].(string); ok {
|
|
imgResult.CreatedAt = createdAt
|
|
}
|
|
|
|
return imgResult, nil
|
|
}
|
|
|
|
// ============================================================================
|
|
// Utility Functions
|
|
// ============================================================================
|
|
|
|
// Languages returns the list of supported programming languages.
|
|
// Results are cached in ~/.unsandbox/languages.json for 1 hour.
|
|
//
|
|
// Parameters:
|
|
// - forceRefresh: Bypass cache and fetch fresh data
|
|
// - opts: Optional parameters (credentials)
|
|
func Languages(forceRefresh bool, opts *Options) (*LanguagesResult, error) {
|
|
usr, err := user.Current()
|
|
if err == nil && !forceRefresh {
|
|
cachePath := filepath.Join(usr.HomeDir, ".unsandbox", "languages.json")
|
|
if info, err := os.Stat(cachePath); err == nil {
|
|
cacheMaxAge := time.Hour
|
|
if time.Since(info.ModTime()) < cacheMaxAge {
|
|
if data, err := os.ReadFile(cachePath); err == nil {
|
|
var cached LanguagesResult
|
|
if json.Unmarshal(data, &cached) == nil {
|
|
return &cached, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
result, err := apiRequest("/languages", "GET", nil, "", opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
langResult := &LanguagesResult{}
|
|
if langs, ok := result["languages"].([]interface{}); ok {
|
|
for _, l := range langs {
|
|
if lStr, ok := l.(string); ok {
|
|
langResult.Languages = append(langResult.Languages, lStr)
|
|
}
|
|
}
|
|
}
|
|
if count, ok := result["count"].(float64); ok {
|
|
langResult.Count = int(count)
|
|
}
|
|
if aliases, ok := result["aliases"].(map[string]interface{}); ok {
|
|
langResult.Aliases = make(map[string]string)
|
|
for k, v := range aliases {
|
|
if vStr, ok := v.(string); ok {
|
|
langResult.Aliases[k] = vStr
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save to cache
|
|
if usr != nil {
|
|
cacheDir := filepath.Join(usr.HomeDir, ".unsandbox")
|
|
os.MkdirAll(cacheDir, 0755)
|
|
cachePath := filepath.Join(cacheDir, "languages.json")
|
|
if data, err := json.Marshal(langResult); err == nil {
|
|
os.WriteFile(cachePath, data, 0644)
|
|
}
|
|
}
|
|
|
|
return langResult, nil
|
|
}
|
|
|
|
// DetectLanguage detects programming language from file extension or shebang.
|
|
// Returns language name or empty string if undetected.
|
|
func DetectLanguage(filename string) string {
|
|
ext := strings.ToLower(filepath.Ext(filename))
|
|
if lang, ok := ExtMap[ext]; ok {
|
|
return lang
|
|
}
|
|
|
|
// Try shebang
|
|
data, err := os.ReadFile(filename)
|
|
if err == nil && len(data) > 0 {
|
|
firstLine := strings.Split(string(data), "\n")[0]
|
|
if strings.HasPrefix(firstLine, "#!") {
|
|
if strings.Contains(firstLine, "python") {
|
|
return "python"
|
|
}
|
|
if strings.Contains(firstLine, "node") {
|
|
return "javascript"
|
|
}
|
|
if strings.Contains(firstLine, "ruby") {
|
|
return "ruby"
|
|
}
|
|
if strings.Contains(firstLine, "perl") {
|
|
return "perl"
|
|
}
|
|
if strings.Contains(firstLine, "bash") || strings.Contains(firstLine, "/sh") {
|
|
return "bash"
|
|
}
|
|
if strings.Contains(firstLine, "lua") {
|
|
return "lua"
|
|
}
|
|
if strings.Contains(firstLine, "php") {
|
|
return "php"
|
|
}
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// ============================================================================
|
|
// Client
|
|
// ============================================================================
|
|
|
|
// Client is an API client with stored credentials.
|
|
//
|
|
// Example:
|
|
// client, err := un.NewClient("unsb-pk-...", "unsb-sk-...")
|
|
// result, err := client.Execute("python", `print("Hello")`, nil)
|
|
//
|
|
// // Or load from environment/config automatically:
|
|
// client, err := un.NewClientFromEnv()
|
|
// result, err := client.Execute("python", code, nil)
|
|
type Client struct {
|
|
PublicKey string
|
|
SecretKey string
|
|
}
|
|
|
|
// NewClient creates a new Client with explicit credentials.
|
|
func NewClient(publicKey, secretKey string) (*Client, error) {
|
|
if publicKey == "" || secretKey == "" {
|
|
return nil, ErrNoCredentials
|
|
}
|
|
return &Client{
|
|
PublicKey: publicKey,
|
|
SecretKey: secretKey,
|
|
}, nil
|
|
}
|
|
|
|
// NewClientFromEnv creates a new Client loading credentials from environment or config.
|
|
func NewClientFromEnv() (*Client, error) {
|
|
creds, err := GetCredentials("", "", 0)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Client{
|
|
PublicKey: creds.PublicKey,
|
|
SecretKey: creds.SecretKey,
|
|
}, nil
|
|
}
|
|
|
|
// NewClientFromConfig creates a new Client loading credentials from config file at specified index.
|
|
func NewClientFromConfig(accountIndex int) (*Client, error) {
|
|
creds, err := GetCredentials("", "", accountIndex)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Client{
|
|
PublicKey: creds.PublicKey,
|
|
SecretKey: creds.SecretKey,
|
|
}, nil
|
|
}
|
|
|
|
func (c *Client) opts(opts *Options) *Options {
|
|
if opts == nil {
|
|
opts = &Options{}
|
|
}
|
|
opts.PublicKey = c.PublicKey
|
|
opts.SecretKey = c.SecretKey
|
|
return opts
|
|
}
|
|
|
|
// Execute runs code synchronously. See package-level Execute() for details.
|
|
func (c *Client) Execute(language, code string, opts *Options) (*ExecuteResult, error) {
|
|
return Execute(language, code, c.opts(opts))
|
|
}
|
|
|
|
// ExecuteAsync executes code asynchronously. See package-level ExecuteAsync() for details.
|
|
func (c *Client) ExecuteAsync(language, code string, opts *Options) (*JobResult, error) {
|
|
return ExecuteAsync(language, code, c.opts(opts))
|
|
}
|
|
|
|
// Run executes with auto-detect. See package-level Run() for details.
|
|
func (c *Client) Run(code string, opts *Options) (*ExecuteResult, error) {
|
|
return Run(code, c.opts(opts))
|
|
}
|
|
|
|
// RunAsync executes async with auto-detect. See package-level RunAsync() for details.
|
|
func (c *Client) RunAsync(code string, opts *Options) (*JobResult, error) {
|
|
return RunAsync(code, c.opts(opts))
|
|
}
|
|
|
|
// GetJob retrieves job status. See package-level GetJob() for details.
|
|
func (c *Client) GetJob(jobID string) (*JobResult, error) {
|
|
return GetJob(jobID, c.opts(nil))
|
|
}
|
|
|
|
// Wait polls for job completion. See package-level Wait() for details.
|
|
func (c *Client) Wait(jobID string, opts *Options) (*JobResult, error) {
|
|
return Wait(jobID, c.opts(opts))
|
|
}
|
|
|
|
// CancelJob cancels a job. See package-level CancelJob() for details.
|
|
func (c *Client) CancelJob(jobID string) (*JobResult, error) {
|
|
return CancelJob(jobID, c.opts(nil))
|
|
}
|
|
|
|
// ListJobs lists active jobs. See package-level ListJobs() for details.
|
|
func (c *Client) ListJobs() ([]JobResult, error) {
|
|
return ListJobs(c.opts(nil))
|
|
}
|
|
|
|
// Image generates an image. See package-level Image() for details.
|
|
func (c *Client) Image(prompt string, opts *ImageOptions) (*ImageResult, error) {
|
|
if opts == nil {
|
|
opts = &ImageOptions{}
|
|
}
|
|
opts.PublicKey = c.PublicKey
|
|
opts.SecretKey = c.SecretKey
|
|
return Image(prompt, opts)
|
|
}
|
|
|
|
// Languages returns supported languages. See package-level Languages() for details.
|
|
func (c *Client) Languages(forceRefresh bool) (*LanguagesResult, error) {
|
|
return Languages(forceRefresh, c.opts(nil))
|
|
}
|
|
|
|
// ============================================================================
|
|
// Helper Functions
|
|
// ============================================================================
|
|
|
|
func getNetworkMode(mode string) string {
|
|
if mode == "" {
|
|
return "zerotrust"
|
|
}
|
|
return mode
|
|
}
|
|
|
|
func getTTL(ttl int) int {
|
|
if ttl <= 0 {
|
|
return DefaultTTL
|
|
}
|
|
return ttl
|
|
}
|
|
|
|
func getVCPU(vcpu int) int {
|
|
if vcpu <= 0 {
|
|
return 1
|
|
}
|
|
return vcpu
|
|
}
|
|
|
|
func processInputFiles(files []InputFile) []map[string]string {
|
|
result := make([]map[string]string, 0, len(files))
|
|
for _, f := range files {
|
|
entry := map[string]string{"filename": f.Filename}
|
|
if f.ContentBase64 != "" {
|
|
entry["content_base64"] = f.ContentBase64
|
|
} else if f.Content != "" {
|
|
entry["content_base64"] = base64.StdEncoding.EncodeToString([]byte(f.Content))
|
|
}
|
|
result = append(result, entry)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func parseExecuteResult(m map[string]interface{}) *ExecuteResult {
|
|
r := &ExecuteResult{}
|
|
if v, ok := m["success"].(bool); ok {
|
|
r.Success = v
|
|
}
|
|
if v, ok := m["stdout"].(string); ok {
|
|
r.Stdout = v
|
|
}
|
|
if v, ok := m["stderr"].(string); ok {
|
|
r.Stderr = v
|
|
}
|
|
if v, ok := m["exit_code"].(float64); ok {
|
|
r.ExitCode = int(v)
|
|
}
|
|
if v, ok := m["language"].(string); ok {
|
|
r.Language = v
|
|
}
|
|
if v, ok := m["detected_language"].(string); ok {
|
|
r.Language = v
|
|
}
|
|
if v, ok := m["job_id"].(string); ok {
|
|
r.JobID = v
|
|
}
|
|
if v, ok := m["total_time_ms"].(float64); ok {
|
|
r.TotalTimeMs = int(v)
|
|
}
|
|
if v, ok := m["network_mode"].(string); ok {
|
|
r.NetworkMode = v
|
|
}
|
|
if v, ok := m["error"].(string); ok {
|
|
r.Error = v
|
|
}
|
|
if arts, ok := m["artifacts"].([]interface{}); ok {
|
|
for _, a := range arts {
|
|
if aMap, ok := a.(map[string]interface{}); ok {
|
|
art := Artifact{}
|
|
if fn, ok := aMap["filename"].(string); ok {
|
|
art.Filename = fn
|
|
}
|
|
if cb, ok := aMap["content_base64"].(string); ok {
|
|
art.ContentBase64 = cb
|
|
}
|
|
if sz, ok := aMap["size"].(float64); ok {
|
|
art.Size = int(sz)
|
|
}
|
|
r.Artifacts = append(r.Artifacts, art)
|
|
}
|
|
}
|
|
}
|
|
return r
|
|
}
|
|
|
|
func parseJobResult(m map[string]interface{}) *JobResult {
|
|
r := &JobResult{}
|
|
if v, ok := m["job_id"].(string); ok {
|
|
r.JobID = v
|
|
}
|
|
if v, ok := m["status"].(string); ok {
|
|
r.Status = v
|
|
}
|
|
if v, ok := m["detected_language"].(string); ok {
|
|
r.DetectedLanguage = v
|
|
}
|
|
if v, ok := m["error"].(string); ok {
|
|
r.Error = v
|
|
}
|
|
if v, ok := m["submitted_at"].(string); ok {
|
|
r.SubmittedAt = v
|
|
}
|
|
if v, ok := m["completed_at"].(string); ok {
|
|
r.CompletedAt = v
|
|
}
|
|
if res, ok := m["result"].(map[string]interface{}); ok {
|
|
r.Result = parseExecuteResult(res)
|
|
}
|
|
return r
|
|
}
|
|
|
|
// ============================================================================
|
|
// CLI Interface
|
|
// ============================================================================
|
|
|
|
const MaxEnvContentSize = 64 * 1024 // 64KB max env vault size
|
|
|
|
type envVars []string
|
|
|
|
func (e *envVars) String() string { return "" }
|
|
func (e *envVars) Set(value string) error {
|
|
*e = append(*e, value)
|
|
return nil
|
|
}
|
|
|
|
type inputFilesFlag []string
|
|
|
|
func (i *inputFilesFlag) String() string { return "" }
|
|
func (i *inputFilesFlag) Set(value string) error {
|
|
*i = append(*i, value)
|
|
return nil
|
|
}
|
|
|
|
func cliGetAPIKeys(keyArg string) (string, string) {
|
|
creds, err := GetCredentials("", "", 0)
|
|
if err != nil {
|
|
// Fall back to UNSANDBOX_API_KEY for backwards compatibility
|
|
fallbackKey := keyArg
|
|
if fallbackKey == "" {
|
|
fallbackKey = os.Getenv("UNSANDBOX_API_KEY")
|
|
}
|
|
if fallbackKey == "" {
|
|
fmt.Fprintf(os.Stderr, "%sError: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set (or UNSANDBOX_API_KEY for backwards compat)%s\n", Red, Reset)
|
|
os.Exit(1)
|
|
}
|
|
return fallbackKey, fallbackKey
|
|
}
|
|
return creds.PublicKey, creds.SecretKey
|
|
}
|
|
|
|
func cliApiRequest(endpoint, method string, data map[string]interface{}, publicKey, secretKey string) map[string]interface{} {
|
|
opts := &Options{PublicKey: publicKey, SecretKey: secretKey}
|
|
result, err := apiRequest(endpoint, method, data, "application/json", opts)
|
|
if err != nil {
|
|
if errors.Is(err, ErrTimestampExpired) {
|
|
fmt.Fprintf(os.Stderr, "%sError: Request timestamp expired (must be within 5 minutes of server time)%s\n", Red, Reset)
|
|
fmt.Fprintf(os.Stderr, "%sYour computer's clock may have drifted.%s\n", Yellow, Reset)
|
|
fmt.Fprintf(os.Stderr, "%sCheck your system time and sync with NTP if needed:%s\n", Yellow, Reset)
|
|
fmt.Fprintf(os.Stderr, " Linux: sudo ntpdate -s time.nist.gov\n")
|
|
fmt.Fprintf(os.Stderr, " macOS: sudo sntp -sS time.apple.com\n")
|
|
fmt.Fprintf(os.Stderr, " Windows: w32tm /resync\n")
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, "%sError: %v%s\n", Red, err, Reset)
|
|
}
|
|
os.Exit(1)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func cliApiRequestText(endpoint, method, body, publicKey, secretKey string) (map[string]interface{}, error) {
|
|
opts := &Options{PublicKey: publicKey, SecretKey: secretKey}
|
|
return apiRequest(endpoint, method, body, "text/plain", opts)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Environment Secrets Vault Functions
|
|
// ============================================================================
|
|
|
|
func serviceEnvStatus(serviceID, publicKey, secretKey string) {
|
|
result := cliApiRequest("/services/"+serviceID+"/env", "GET", nil, publicKey, secretKey)
|
|
hasVault, _ := result["has_vault"].(bool)
|
|
|
|
if !hasVault {
|
|
fmt.Println("Vault exists: no")
|
|
fmt.Println("Variable count: 0")
|
|
} else {
|
|
fmt.Println("Vault exists: yes")
|
|
if count, ok := result["count"].(float64); ok {
|
|
fmt.Printf("Variable count: %d\n", int(count))
|
|
}
|
|
if updatedAt, ok := result["updated_at"].(float64); ok {
|
|
t := time.Unix(int64(updatedAt), 0)
|
|
fmt.Printf("Last updated: %s\n", t.Format("2006-01-02 15:04:05"))
|
|
}
|
|
}
|
|
}
|
|
|
|
func serviceEnvSet(serviceID, envContent, publicKey, secretKey string) bool {
|
|
if envContent == "" {
|
|
fmt.Fprintf(os.Stderr, "%sError: No environment content provided%s\n", Red, Reset)
|
|
return false
|
|
}
|
|
|
|
if len(envContent) > MaxEnvContentSize {
|
|
fmt.Fprintf(os.Stderr, "%sError: Environment content too large (max %d bytes)%s\n", Red, MaxEnvContentSize, Reset)
|
|
return false
|
|
}
|
|
|
|
result, err := cliApiRequestText("/services/"+serviceID+"/env", "PUT", envContent, publicKey, secretKey)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%sError: %v%s\n", Red, err, Reset)
|
|
return false
|
|
}
|
|
|
|
if count, ok := result["count"].(float64); ok {
|
|
plural := "s"
|
|
if int(count) == 1 {
|
|
plural = ""
|
|
}
|
|
fmt.Printf("%sEnvironment vault updated: %d variable%s%s\n", Green, int(count), plural, Reset)
|
|
} else {
|
|
fmt.Printf("%sEnvironment vault updated%s\n", Green, Reset)
|
|
}
|
|
|
|
if message, ok := result["message"].(string); ok && message != "" {
|
|
fmt.Println(message)
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func serviceEnvExport(serviceID, publicKey, secretKey string) {
|
|
result := cliApiRequest("/services/"+serviceID+"/env/export", "POST", map[string]interface{}{}, publicKey, secretKey)
|
|
if envContent, ok := result["env"].(string); ok && envContent != "" {
|
|
fmt.Print(envContent)
|
|
if !strings.HasSuffix(envContent, "\n") {
|
|
fmt.Println()
|
|
}
|
|
}
|
|
}
|
|
|
|
func serviceEnvDelete(serviceID, publicKey, secretKey string) {
|
|
cliApiRequest("/services/"+serviceID+"/env", "DELETE", nil, publicKey, secretKey)
|
|
fmt.Printf("%sEnvironment vault deleted%s\n", Green, Reset)
|
|
}
|
|
|
|
func readEnvFile(filepath string) (string, error) {
|
|
content, err := os.ReadFile(filepath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(content), nil
|
|
}
|
|
|
|
func buildEnvContent(envs envVars, envFile string) string {
|
|
var parts []string
|
|
|
|
// Read from env file first
|
|
if envFile != "" {
|
|
content, err := readEnvFile(envFile)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%sError: Env file not found: %s%s\n", Red, envFile, Reset)
|
|
os.Exit(1)
|
|
}
|
|
parts = append(parts, content)
|
|
}
|
|
|
|
// Add -e flags
|
|
for _, e := range envs {
|
|
if strings.Contains(e, "=") {
|
|
parts = append(parts, e)
|
|
}
|
|
}
|
|
|
|
return strings.Join(parts, "\n")
|
|
}
|
|
|
|
func cmdServiceEnv(action, target string, envs envVars, envFile, publicKey, secretKey string) {
|
|
if action == "" {
|
|
fmt.Fprintf(os.Stderr, "%sError: env action required (status, set, export, delete)%s\n", Red, Reset)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if target == "" {
|
|
fmt.Fprintf(os.Stderr, "%sError: Service ID required for env command%s\n", Red, Reset)
|
|
os.Exit(1)
|
|
}
|
|
|
|
switch action {
|
|
case "status":
|
|
serviceEnvStatus(target, publicKey, secretKey)
|
|
case "set":
|
|
envContent := buildEnvContent(envs, envFile)
|
|
if envContent == "" {
|
|
fmt.Fprintf(os.Stderr, "%sError: No env content provided. Use -e KEY=VAL, --env-file, or pipe to stdin%s\n", Red, Reset)
|
|
os.Exit(1)
|
|
}
|
|
serviceEnvSet(target, envContent, publicKey, secretKey)
|
|
case "export":
|
|
serviceEnvExport(target, publicKey, secretKey)
|
|
case "delete":
|
|
serviceEnvDelete(target, publicKey, secretKey)
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "%sError: Unknown env action '%s'. Use: status, set, export, delete%s\n", Red, action, Reset)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func cmdExecute(sourceFile string, envs envVars, files inputFilesFlag, artifacts bool, outputDir, network string, vcpu int, publicKey, secretKey string) {
|
|
code, err := os.ReadFile(sourceFile)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%sError reading file: %v%s\n", Red, err, Reset)
|
|
os.Exit(1)
|
|
}
|
|
|
|
language := DetectLanguage(sourceFile)
|
|
if language == "" {
|
|
fmt.Fprintf(os.Stderr, "%sError: cannot detect language from extension%s\n", Red, Reset)
|
|
os.Exit(1)
|
|
}
|
|
|
|
payload := map[string]interface{}{
|
|
"language": language,
|
|
"code": string(code),
|
|
}
|
|
|
|
// Environment variables
|
|
if len(envs) > 0 {
|
|
envMap := make(map[string]string)
|
|
for _, e := range envs {
|
|
parts := strings.SplitN(e, "=", 2)
|
|
if len(parts) == 2 {
|
|
envMap[parts[0]] = parts[1]
|
|
}
|
|
}
|
|
if len(envMap) > 0 {
|
|
payload["env"] = envMap
|
|
}
|
|
}
|
|
|
|
// Input files
|
|
if len(files) > 0 {
|
|
var inputFilesList []map[string]string
|
|
for _, f := range files {
|
|
content, err := os.ReadFile(f)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%sError reading input file %s: %v%s\n", Red, f, err, Reset)
|
|
os.Exit(1)
|
|
}
|
|
inputFilesList = append(inputFilesList, map[string]string{
|
|
"filename": filepath.Base(f),
|
|
"content_base64": base64.StdEncoding.EncodeToString(content),
|
|
})
|
|
}
|
|
payload["input_files"] = inputFilesList
|
|
}
|
|
|
|
if artifacts {
|
|
payload["return_artifacts"] = true
|
|
}
|
|
if network != "" {
|
|
payload["network"] = network
|
|
}
|
|
if vcpu > 0 {
|
|
payload["vcpu"] = vcpu
|
|
}
|
|
|
|
result := cliApiRequest("/execute", "POST", payload, publicKey, secretKey)
|
|
|
|
// Print output
|
|
if stdout, ok := result["stdout"].(string); ok && stdout != "" {
|
|
fmt.Printf("%s%s%s", Blue, stdout, Reset)
|
|
}
|
|
if stderr, ok := result["stderr"].(string); ok && stderr != "" {
|
|
fmt.Fprintf(os.Stderr, "%s%s%s", Red, stderr, Reset)
|
|
}
|
|
|
|
// Save artifacts
|
|
if artifacts {
|
|
if artList, ok := result["artifacts"].([]interface{}); ok {
|
|
outDir := outputDir
|
|
if outDir == "" {
|
|
outDir = "."
|
|
}
|
|
os.MkdirAll(outDir, 0755)
|
|
|
|
for _, art := range artList {
|
|
artMap := art.(map[string]interface{})
|
|
filename := "artifact"
|
|
if fn, ok := artMap["filename"].(string); ok {
|
|
filename = fn
|
|
}
|
|
content, _ := base64.StdEncoding.DecodeString(artMap["content_base64"].(string))
|
|
path := filepath.Join(outDir, filename)
|
|
os.WriteFile(path, content, 0755)
|
|
fmt.Fprintf(os.Stderr, "%sSaved: %s%s\n", Green, path, Reset)
|
|
}
|
|
}
|
|
}
|
|
|
|
exitCode := 0
|
|
if ec, ok := result["exit_code"].(float64); ok {
|
|
exitCode = int(ec)
|
|
}
|
|
os.Exit(exitCode)
|
|
}
|
|
|
|
func cmdSession(sessionList, sessionKill, sessionShell, sessionSnapshot, sessionRestore, sessionSnapshotName string, sessionHot bool, network string, vcpu int, tmux, screen bool, files inputFilesFlag, publicKey, secretKey string) {
|
|
if sessionSnapshot != "" {
|
|
payload := map[string]interface{}{}
|
|
if sessionSnapshotName != "" {
|
|
payload["name"] = sessionSnapshotName
|
|
}
|
|
if sessionHot {
|
|
payload["hot"] = true
|
|
}
|
|
result := cliApiRequest("/sessions/"+sessionSnapshot+"/snapshot", "POST", payload, publicKey, secretKey)
|
|
fmt.Printf("%sSnapshot created%s\n", Green, Reset)
|
|
jsonData, _ := json.MarshalIndent(result, "", " ")
|
|
fmt.Println(string(jsonData))
|
|
return
|
|
}
|
|
|
|
if sessionRestore != "" {
|
|
result := cliApiRequest("/snapshots/"+sessionRestore+"/restore", "POST", nil, publicKey, secretKey)
|
|
fmt.Printf("%sSession restored from snapshot%s\n", Green, Reset)
|
|
jsonData, _ := json.MarshalIndent(result, "", " ")
|
|
fmt.Println(string(jsonData))
|
|
return
|
|
}
|
|
|
|
if sessionList != "" {
|
|
result := cliApiRequest("/sessions", "GET", nil, publicKey, secretKey)
|
|
sessions := result["sessions"].([]interface{})
|
|
if len(sessions) == 0 {
|
|
fmt.Println("No active sessions")
|
|
} else {
|
|
fmt.Printf("%-40s %-10s %-10s %s\n", "ID", "Shell", "Status", "Created")
|
|
for _, s := range sessions {
|
|
sess := s.(map[string]interface{})
|
|
fmt.Printf("%-40s %-10s %-10s %s\n",
|
|
sess["id"], sess["shell"], sess["status"], sess["created_at"])
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
if sessionKill != "" {
|
|
cliApiRequest("/sessions/"+sessionKill, "DELETE", nil, publicKey, secretKey)
|
|
fmt.Printf("%sSession terminated: %s%s\n", Green, sessionKill, Reset)
|
|
return
|
|
}
|
|
|
|
// Create session
|
|
payload := map[string]interface{}{}
|
|
if sessionShell != "" {
|
|
payload["shell"] = sessionShell
|
|
} else {
|
|
payload["shell"] = "bash"
|
|
}
|
|
if network != "" {
|
|
payload["network"] = network
|
|
}
|
|
if vcpu > 0 {
|
|
payload["vcpu"] = vcpu
|
|
}
|
|
if tmux {
|
|
payload["persistence"] = "tmux"
|
|
}
|
|
if screen {
|
|
payload["persistence"] = "screen"
|
|
}
|
|
|
|
// Input files
|
|
if len(files) > 0 {
|
|
var inputFilesList []map[string]string
|
|
for _, f := range files {
|
|
content, err := os.ReadFile(f)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%sError reading input file %s: %v%s\n", Red, f, err, Reset)
|
|
os.Exit(1)
|
|
}
|
|
inputFilesList = append(inputFilesList, map[string]string{
|
|
"filename": filepath.Base(f),
|
|
"content_base64": base64.StdEncoding.EncodeToString(content),
|
|
})
|
|
}
|
|
payload["input_files"] = inputFilesList
|
|
}
|
|
|
|
fmt.Printf("%sCreating session...%s\n", Yellow, Reset)
|
|
result := cliApiRequest("/sessions", "POST", payload, publicKey, secretKey)
|
|
fmt.Printf("%sSession created: %s%s\n", Green, result["id"], Reset)
|
|
}
|
|
|
|
func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceBootstrap, serviceBootstrapFile, serviceList, serviceInfo, serviceLogs, serviceTail, serviceSleep, serviceWake, serviceDestroy, serviceResize, serviceExecute, serviceCommand, serviceDumpBootstrap, serviceDumpFile, serviceSnapshot, serviceRestore, serviceSnapshotName string, serviceHot bool, network string, vcpu int, files inputFilesFlag, envs envVars, envFile, publicKey, secretKey string) {
|
|
if serviceSnapshot != "" {
|
|
payload := map[string]interface{}{}
|
|
if serviceSnapshotName != "" {
|
|
payload["name"] = serviceSnapshotName
|
|
}
|
|
if serviceHot {
|
|
payload["hot"] = true
|
|
}
|
|
result := cliApiRequest("/services/"+serviceSnapshot+"/snapshot", "POST", payload, publicKey, secretKey)
|
|
fmt.Printf("%sSnapshot created%s\n", Green, Reset)
|
|
jsonData, _ := json.MarshalIndent(result, "", " ")
|
|
fmt.Println(string(jsonData))
|
|
return
|
|
}
|
|
|
|
if serviceRestore != "" {
|
|
result := cliApiRequest("/snapshots/"+serviceRestore+"/restore", "POST", nil, publicKey, secretKey)
|
|
fmt.Printf("%sService restored from snapshot%s\n", Green, Reset)
|
|
jsonData, _ := json.MarshalIndent(result, "", " ")
|
|
fmt.Println(string(jsonData))
|
|
return
|
|
}
|
|
|
|
if serviceList != "" {
|
|
result := cliApiRequest("/services", "GET", nil, publicKey, secretKey)
|
|
services := result["services"].([]interface{})
|
|
if len(services) == 0 {
|
|
fmt.Println("No services")
|
|
} else {
|
|
fmt.Printf("%-20s %-15s %-10s %-15s %s\n", "ID", "Name", "Status", "Ports", "Domains")
|
|
for _, s := range services {
|
|
svc := s.(map[string]interface{})
|
|
ports := ""
|
|
if p, ok := svc["ports"].([]interface{}); ok {
|
|
var ps []string
|
|
for _, port := range p {
|
|
ps = append(ps, fmt.Sprintf("%v", port))
|
|
}
|
|
ports = strings.Join(ps, ",")
|
|
}
|
|
domains := ""
|
|
if d, ok := svc["domains"].([]interface{}); ok {
|
|
var ds []string
|
|
for _, domain := range d {
|
|
ds = append(ds, domain.(string))
|
|
}
|
|
domains = strings.Join(ds, ",")
|
|
}
|
|
fmt.Printf("%-20s %-15s %-10s %-15s %s\n",
|
|
svc["id"], svc["name"], svc["status"], ports, domains)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
if serviceInfo != "" {
|
|
result := cliApiRequest("/services/"+serviceInfo, "GET", nil, publicKey, secretKey)
|
|
jsonData, _ := json.MarshalIndent(result, "", " ")
|
|
fmt.Println(string(jsonData))
|
|
return
|
|
}
|
|
|
|
if serviceLogs != "" {
|
|
result := cliApiRequest("/services/"+serviceLogs+"/logs", "GET", nil, publicKey, secretKey)
|
|
fmt.Print(result["logs"])
|
|
return
|
|
}
|
|
|
|
if serviceTail != "" {
|
|
result := cliApiRequest("/services/"+serviceTail+"/logs?lines=9000", "GET", nil, publicKey, secretKey)
|
|
fmt.Print(result["logs"])
|
|
return
|
|
}
|
|
|
|
if serviceSleep != "" {
|
|
cliApiRequest("/services/"+serviceSleep+"/freeze", "POST", nil, publicKey, secretKey)
|
|
fmt.Printf("%sService frozen: %s%s\n", Green, serviceSleep, Reset)
|
|
return
|
|
}
|
|
|
|
if serviceWake != "" {
|
|
cliApiRequest("/services/"+serviceWake+"/unfreeze", "POST", nil, publicKey, secretKey)
|
|
fmt.Printf("%sService unfreezing: %s%s\n", Green, serviceWake, Reset)
|
|
return
|
|
}
|
|
|
|
if serviceDestroy != "" {
|
|
cliApiRequest("/services/"+serviceDestroy, "DELETE", nil, publicKey, secretKey)
|
|
fmt.Printf("%sService destroyed: %s%s\n", Green, serviceDestroy, Reset)
|
|
return
|
|
}
|
|
|
|
if serviceResize != "" {
|
|
if vcpu <= 0 {
|
|
fmt.Fprintf(os.Stderr, "%sError: --resize requires -v <vcpu>%s\n", Red, Reset)
|
|
os.Exit(1)
|
|
}
|
|
payload := map[string]interface{}{"vcpu": vcpu}
|
|
cliApiRequest("/services/"+serviceResize, "PATCH", payload, publicKey, secretKey)
|
|
fmt.Printf("%sService resized to %d vCPU, %d GB RAM%s\n", Green, vcpu, vcpu*2, Reset)
|
|
return
|
|
}
|
|
|
|
if serviceExecute != "" {
|
|
payload := map[string]interface{}{"command": serviceCommand}
|
|
result := cliApiRequest("/services/"+serviceExecute+"/execute", "POST", payload, publicKey, secretKey)
|
|
if stdout, ok := result["stdout"].(string); ok {
|
|
fmt.Printf("%s%s%s", Blue, stdout, Reset)
|
|
}
|
|
if stderr, ok := result["stderr"].(string); ok {
|
|
fmt.Fprintf(os.Stderr, "%s%s%s", Red, stderr, Reset)
|
|
}
|
|
return
|
|
}
|
|
|
|
if serviceDumpBootstrap != "" {
|
|
fmt.Fprintf(os.Stderr, "Fetching bootstrap script from %s...\n", serviceDumpBootstrap)
|
|
payload := map[string]interface{}{"command": "cat /tmp/bootstrap.sh"}
|
|
result := cliApiRequest("/services/"+serviceDumpBootstrap+"/execute", "POST", payload, publicKey, secretKey)
|
|
|
|
if bootstrap, ok := result["stdout"].(string); ok && bootstrap != "" {
|
|
if serviceDumpFile != "" {
|
|
err := os.WriteFile(serviceDumpFile, []byte(bootstrap), 0755)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%sError: Could not write to %s: %v%s\n", Red, serviceDumpFile, err, Reset)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("Bootstrap saved to %s\n", serviceDumpFile)
|
|
} else {
|
|
fmt.Print(bootstrap)
|
|
}
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, "%sError: Failed to fetch bootstrap (service not running or no bootstrap file)%s\n", Red, Reset)
|
|
os.Exit(1)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Create service
|
|
if serviceName != "" {
|
|
payload := map[string]interface{}{"name": serviceName}
|
|
if servicePorts != "" {
|
|
var ports []int
|
|
for _, p := range strings.Split(servicePorts, ",") {
|
|
port, _ := strconv.Atoi(strings.TrimSpace(p))
|
|
ports = append(ports, port)
|
|
}
|
|
payload["ports"] = ports
|
|
}
|
|
if serviceDomains != "" {
|
|
payload["domains"] = strings.Split(serviceDomains, ",")
|
|
}
|
|
if serviceType != "" {
|
|
payload["service_type"] = serviceType
|
|
}
|
|
if serviceBootstrap != "" {
|
|
payload["bootstrap"] = serviceBootstrap
|
|
}
|
|
if serviceBootstrapFile != "" {
|
|
content, err := os.ReadFile(serviceBootstrapFile)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%sError: Bootstrap file not found: %s%s\n", Red, serviceBootstrapFile, Reset)
|
|
os.Exit(1)
|
|
}
|
|
payload["bootstrap_content"] = string(content)
|
|
}
|
|
// Input files
|
|
if len(files) > 0 {
|
|
var inputFilesList []map[string]string
|
|
for _, f := range files {
|
|
content, err := os.ReadFile(f)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%sError reading input file %s: %v%s\n", Red, f, err, Reset)
|
|
os.Exit(1)
|
|
}
|
|
inputFilesList = append(inputFilesList, map[string]string{
|
|
"filename": filepath.Base(f),
|
|
"content_base64": base64.StdEncoding.EncodeToString(content),
|
|
})
|
|
}
|
|
payload["input_files"] = inputFilesList
|
|
}
|
|
if network != "" {
|
|
payload["network"] = network
|
|
}
|
|
if vcpu > 0 {
|
|
payload["vcpu"] = vcpu
|
|
}
|
|
|
|
result := cliApiRequest("/services", "POST", payload, publicKey, secretKey)
|
|
serviceID := result["id"].(string)
|
|
fmt.Printf("%sService created: %s%s\n", Green, serviceID, Reset)
|
|
fmt.Printf("Name: %s\n", result["name"])
|
|
if url, ok := result["url"]; ok {
|
|
fmt.Printf("URL: %s\n", url)
|
|
}
|
|
|
|
// Auto-set vault if -e or --env-file provided
|
|
envContent := buildEnvContent(envs, envFile)
|
|
if envContent != "" {
|
|
serviceEnvSet(serviceID, envContent, publicKey, secretKey)
|
|
}
|
|
return
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr, "%sError: Specify --name to create a service, or use --list, --info, etc.%s\n", Red, Reset)
|
|
os.Exit(1)
|
|
}
|
|
|
|
func cmdSnapshot(snapshotList, snapshotInfo, snapshotDelete, snapshotClone, snapshotType, snapshotName, snapshotShell, snapshotPorts, publicKey, secretKey string) {
|
|
if snapshotList != "" || snapshotList == "" && snapshotInfo == "" && snapshotDelete == "" && snapshotClone == "" {
|
|
result := cliApiRequest("/snapshots", "GET", nil, publicKey, secretKey)
|
|
jsonData, _ := json.MarshalIndent(result, "", " ")
|
|
fmt.Println(string(jsonData))
|
|
return
|
|
}
|
|
|
|
if snapshotInfo != "" {
|
|
result := cliApiRequest("/snapshots/"+snapshotInfo, "GET", nil, publicKey, secretKey)
|
|
jsonData, _ := json.MarshalIndent(result, "", " ")
|
|
fmt.Println(string(jsonData))
|
|
return
|
|
}
|
|
|
|
if snapshotDelete != "" {
|
|
cliApiRequest("/snapshots/"+snapshotDelete, "DELETE", nil, publicKey, secretKey)
|
|
fmt.Printf("%sSnapshot deleted: %s%s\n", Green, snapshotDelete, Reset)
|
|
return
|
|
}
|
|
|
|
if snapshotClone != "" {
|
|
if snapshotType == "" {
|
|
fmt.Fprintf(os.Stderr, "%sError: --type required (session or service)%s\n", Red, Reset)
|
|
os.Exit(1)
|
|
}
|
|
payload := map[string]interface{}{
|
|
"type": snapshotType,
|
|
}
|
|
if snapshotName != "" {
|
|
payload["name"] = snapshotName
|
|
}
|
|
if snapshotShell != "" {
|
|
payload["shell"] = snapshotShell
|
|
}
|
|
if snapshotPorts != "" {
|
|
var ports []int
|
|
for _, p := range strings.Split(snapshotPorts, ",") {
|
|
port, _ := strconv.Atoi(strings.TrimSpace(p))
|
|
ports = append(ports, port)
|
|
}
|
|
payload["ports"] = ports
|
|
}
|
|
result := cliApiRequest("/snapshots/"+snapshotClone+"/clone", "POST", payload, publicKey, secretKey)
|
|
fmt.Printf("%sCreated from snapshot%s\n", Green, Reset)
|
|
jsonData, _ := json.MarshalIndent(result, "", " ")
|
|
fmt.Println(string(jsonData))
|
|
return
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr, "%sError: Use --list, --info ID, --delete ID, or --clone ID --type TYPE%s\n", Red, Reset)
|
|
os.Exit(1)
|
|
}
|
|
|
|
func openBrowser(url string) error {
|
|
var cmd *exec.Cmd
|
|
switch runtime.GOOS {
|
|
case "linux":
|
|
cmd = exec.Command("xdg-open", url)
|
|
case "darwin":
|
|
cmd = exec.Command("open", url)
|
|
case "windows":
|
|
cmd = exec.Command("cmd", "/c", "start", url)
|
|
default:
|
|
return fmt.Errorf("unsupported platform")
|
|
}
|
|
return cmd.Start()
|
|
}
|
|
|
|
func formatDuration(d time.Duration) string {
|
|
days := int(d.Hours() / 24)
|
|
hours := int(d.Hours()) % 24
|
|
minutes := int(d.Minutes()) % 60
|
|
|
|
if days > 0 {
|
|
return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
|
|
} else if hours > 0 {
|
|
return fmt.Sprintf("%dh %dm", hours, minutes)
|
|
} else {
|
|
return fmt.Sprintf("%dm", minutes)
|
|
}
|
|
}
|
|
|
|
func validateKey(publicKey, secretKey string, extend bool) {
|
|
opts := &Options{PublicKey: publicKey, SecretKey: secretKey}
|
|
result, err := apiRequest("/keys/validate", "POST", nil, "application/json", &Options{
|
|
PublicKey: publicKey,
|
|
SecretKey: secretKey,
|
|
Timeout: 30,
|
|
})
|
|
|
|
if err != nil {
|
|
// Try portal endpoint
|
|
url := PortalBase + "/keys/validate"
|
|
reqBody := bytes.NewBuffer(nil)
|
|
|
|
req, err := http.NewRequest("POST", url, reqBody)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%sError creating request: %v%s\n", Red, err, Reset)
|
|
os.Exit(1)
|
|
}
|
|
|
|
timestamp := time.Now().Unix()
|
|
signature := SignRequest(secretKey, timestamp, "POST", "/keys/validate", "")
|
|
|
|
req.Header.Set("Authorization", "Bearer "+publicKey)
|
|
req.Header.Set("X-Timestamp", strconv.FormatInt(timestamp, 10))
|
|
req.Header.Set("X-Signature", signature)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%sError making request: %v%s\n", Red, err, Reset)
|
|
os.Exit(1)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if err := json.Unmarshal(body, &result); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%sError parsing response: %v%s\n", Red, err, Reset)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if resp.StatusCode >= 400 {
|
|
fmt.Printf("%sInvalid%s\n", Red, Reset)
|
|
if reason, ok := result["error"].(string); ok {
|
|
fmt.Printf("Reason: %s\n", reason)
|
|
}
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
_ = opts // suppress unused warning
|
|
|
|
valid, _ := result["valid"].(bool)
|
|
expired, _ := result["expired"].(bool)
|
|
pubKey, _ := result["public_key"].(string)
|
|
tier, _ := result["tier"].(string)
|
|
status, _ := result["status"].(string)
|
|
|
|
if expired {
|
|
fmt.Printf("%sExpired%s\n", Red, Reset)
|
|
fmt.Printf("Public Key: %s\n", pubKey)
|
|
fmt.Printf("Tier: %s\n", tier)
|
|
if expiresAt, ok := result["expires_at"].(string); ok {
|
|
fmt.Printf("Expired: %s\n", expiresAt)
|
|
}
|
|
fmt.Printf("%sTo renew: Visit https://unsandbox.com/keys/extend%s\n", Yellow, Reset)
|
|
|
|
if extend {
|
|
extendURL := PortalBase + "/keys/extend?pk=" + pubKey
|
|
fmt.Printf("\n%sOpening browser to extend key...%s\n", Green, Reset)
|
|
if err := openBrowser(extendURL); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%sError opening browser: %v%s\n", Red, err, Reset)
|
|
fmt.Printf("Please visit: %s\n", extendURL)
|
|
}
|
|
}
|
|
os.Exit(1)
|
|
}
|
|
|
|
if valid {
|
|
fmt.Printf("%sValid%s\n", Green, Reset)
|
|
fmt.Printf("Public Key: %s\n", pubKey)
|
|
fmt.Printf("Tier: %s\n", tier)
|
|
fmt.Printf("Status: %s\n", status)
|
|
|
|
if expiresAt, ok := result["expires_at"].(string); ok {
|
|
fmt.Printf("Expires: %s\n", expiresAt)
|
|
|
|
expireTime, err := time.Parse(time.RFC3339, expiresAt)
|
|
if err == nil {
|
|
remaining := time.Until(expireTime)
|
|
if remaining > 0 {
|
|
fmt.Printf("Time Remaining: %s\n", formatDuration(remaining))
|
|
}
|
|
}
|
|
}
|
|
|
|
if rateLimit, ok := result["rate_limit"].(float64); ok {
|
|
fmt.Printf("Rate Limit: %.0f req/min\n", rateLimit)
|
|
}
|
|
if burst, ok := result["burst"].(float64); ok {
|
|
fmt.Printf("Burst: %.0f req\n", burst)
|
|
}
|
|
if concurrency, ok := result["concurrency"].(float64); ok {
|
|
fmt.Printf("Concurrency: %.0f\n", concurrency)
|
|
}
|
|
|
|
if extend {
|
|
extendURL := PortalBase + "/keys/extend?pk=" + pubKey
|
|
fmt.Printf("\n%sOpening browser to extend key...%s\n", Green, Reset)
|
|
if err := openBrowser(extendURL); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%sError opening browser: %v%s\n", Red, err, Reset)
|
|
fmt.Printf("Please visit: %s\n", extendURL)
|
|
}
|
|
}
|
|
} else {
|
|
fmt.Printf("%sInvalid%s\n", Red, Reset)
|
|
if reason, ok := result["error"].(string); ok {
|
|
fmt.Printf("Reason: %s\n", reason)
|
|
}
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
// Common flags
|
|
apiKey := flag.String("k", "", "API key (or set UNSANDBOX_API_KEY)")
|
|
network := flag.String("n", "", "Network mode (zerotrust|semitrusted)")
|
|
vcpu := flag.Int("v", 0, "vCPU count (1-8)")
|
|
|
|
// Execute flags
|
|
var envs envVars
|
|
var files inputFilesFlag
|
|
flag.Var(&envs, "e", "Environment variable (KEY=VALUE)")
|
|
flag.Var(&files, "f", "Input file")
|
|
artifacts := flag.Bool("a", false, "Return artifacts")
|
|
outputDir := flag.String("o", "", "Output directory for artifacts")
|
|
|
|
// Session flags
|
|
sessionCmd := flag.NewFlagSet("session", flag.ExitOnError)
|
|
sessionList := sessionCmd.String("list", "", "List active sessions")
|
|
sessionKill := sessionCmd.String("kill", "", "Kill session by ID")
|
|
sessionShell := sessionCmd.String("shell", "", "Shell/REPL to use")
|
|
sessionTmux := sessionCmd.Bool("tmux", false, "Enable tmux persistence")
|
|
sessionScreen := sessionCmd.Bool("screen", false, "Enable screen persistence")
|
|
sessionSnapshot := sessionCmd.String("snapshot", "", "Create snapshot of session")
|
|
sessionRestore := sessionCmd.String("restore", "", "Restore from snapshot ID")
|
|
sessionSnapshotName := sessionCmd.String("snapshot-name", "", "Name for snapshot")
|
|
sessionHot := sessionCmd.Bool("hot", false, "Take snapshot without freezing")
|
|
var sessionFiles inputFilesFlag
|
|
sessionCmd.Var(&sessionFiles, "f", "Input file")
|
|
sessionNetwork := sessionCmd.String("n", "", "Network mode")
|
|
sessionVcpu := sessionCmd.Int("v", 0, "vCPU count")
|
|
sessionKey := sessionCmd.String("k", "", "API key")
|
|
|
|
// Service flags
|
|
serviceCmd := flag.NewFlagSet("service", flag.ExitOnError)
|
|
serviceName := serviceCmd.String("name", "", "Service name")
|
|
servicePorts := serviceCmd.String("ports", "", "Ports (comma-separated)")
|
|
serviceDomains := serviceCmd.String("domains", "", "Custom domains (comma-separated)")
|
|
serviceType := serviceCmd.String("type", "", "Service type for SRV records")
|
|
serviceBootstrap := serviceCmd.String("bootstrap", "", "Bootstrap command or URI")
|
|
serviceBootstrapFile := serviceCmd.String("bootstrap-file", "", "Upload local file as bootstrap script")
|
|
var serviceFiles inputFilesFlag
|
|
serviceCmd.Var(&serviceFiles, "f", "Input file")
|
|
var serviceEnvs envVars
|
|
serviceCmd.Var(&serviceEnvs, "e", "Environment variable (KEY=VALUE)")
|
|
serviceEnvFile := serviceCmd.String("env-file", "", "Environment file (.env format)")
|
|
serviceList := serviceCmd.String("list", "", "List services")
|
|
serviceInfo := serviceCmd.String("info", "", "Get service info")
|
|
serviceLogs := serviceCmd.String("logs", "", "Get service logs")
|
|
serviceTail := serviceCmd.String("tail", "", "Get last 9000 lines")
|
|
serviceSleep := serviceCmd.String("sleep", "", "Freeze service")
|
|
serviceWake := serviceCmd.String("wake", "", "Unfreeze service")
|
|
serviceDestroy := serviceCmd.String("destroy", "", "Destroy service")
|
|
serviceResize := serviceCmd.String("resize", "", "Resize service vCPU")
|
|
serviceExecute := serviceCmd.String("execute", "", "Execute command in service")
|
|
serviceCommand := serviceCmd.String("command", "", "Command to execute (with -execute)")
|
|
serviceDumpBootstrap := serviceCmd.String("dump-bootstrap", "", "Dump bootstrap script")
|
|
serviceDumpFile := serviceCmd.String("dump-file", "", "File to save bootstrap")
|
|
serviceSnapshot := serviceCmd.String("snapshot", "", "Create snapshot of service")
|
|
serviceRestore := serviceCmd.String("restore", "", "Restore from snapshot ID")
|
|
serviceSnapshotName := serviceCmd.String("snapshot-name", "", "Name for snapshot")
|
|
serviceHot := serviceCmd.Bool("hot", false, "Take snapshot without freezing")
|
|
serviceNetwork := serviceCmd.String("n", "", "Network mode")
|
|
serviceVcpu := serviceCmd.Int("v", 0, "vCPU count")
|
|
serviceKey := serviceCmd.String("k", "", "API key")
|
|
|
|
// Snapshot flags
|
|
snapshotCmd := flag.NewFlagSet("snapshot", flag.ExitOnError)
|
|
snapshotList := snapshotCmd.String("list", "", "List snapshots")
|
|
snapshotInfo := snapshotCmd.String("info", "", "Get snapshot info")
|
|
snapshotDelete := snapshotCmd.String("delete", "", "Delete snapshot")
|
|
snapshotClone := snapshotCmd.String("clone", "", "Clone snapshot")
|
|
snapshotType := snapshotCmd.String("type", "", "Clone type (session/service)")
|
|
snapshotName := snapshotCmd.String("name", "", "Name for cloned resource")
|
|
snapshotShell := snapshotCmd.String("shell", "", "Shell for cloned session")
|
|
snapshotPorts := snapshotCmd.String("ports", "", "Ports for cloned service")
|
|
snapshotKey := snapshotCmd.String("k", "", "API key")
|
|
|
|
// Key flags
|
|
keyCmd := flag.NewFlagSet("key", flag.ExitOnError)
|
|
keyExtend := keyCmd.Bool("extend", false, "Open browser to extend key")
|
|
keyKey := keyCmd.String("k", "", "API key")
|
|
|
|
// Parse
|
|
flag.Parse()
|
|
|
|
if len(os.Args) > 1 {
|
|
switch os.Args[1] {
|
|
case "session":
|
|
sessionCmd.Parse(os.Args[2:])
|
|
publicKey, secretKey := cliGetAPIKeys(*sessionKey)
|
|
net := *sessionNetwork
|
|
if net == "" {
|
|
net = *network
|
|
}
|
|
vc := *sessionVcpu
|
|
if vc == 0 {
|
|
vc = *vcpu
|
|
}
|
|
cmdSession(*sessionList, *sessionKill, *sessionShell, *sessionSnapshot, *sessionRestore, *sessionSnapshotName, *sessionHot, net, vc, *sessionTmux, *sessionScreen, sessionFiles, publicKey, secretKey)
|
|
return
|
|
|
|
case "service":
|
|
// Check for "service env" subcommand
|
|
if len(os.Args) > 2 && os.Args[2] == "env" {
|
|
envCmd := flag.NewFlagSet("service env", flag.ExitOnError)
|
|
var envFlags envVars
|
|
envCmd.Var(&envFlags, "e", "Environment variable (KEY=VALUE)")
|
|
envFile := envCmd.String("env-file", "", "Environment file")
|
|
envKey := envCmd.String("k", "", "API key")
|
|
|
|
if len(os.Args) < 4 {
|
|
fmt.Fprintf(os.Stderr, "%sError: env action required (status, set, export, delete)%s\n", Red, Reset)
|
|
os.Exit(1)
|
|
}
|
|
action := os.Args[3]
|
|
target := ""
|
|
argsStart := 4
|
|
if len(os.Args) > 4 && !strings.HasPrefix(os.Args[4], "-") {
|
|
target = os.Args[4]
|
|
argsStart = 5
|
|
}
|
|
envCmd.Parse(os.Args[argsStart:])
|
|
|
|
publicKey, secretKey := cliGetAPIKeys(*envKey)
|
|
cmdServiceEnv(action, target, envFlags, *envFile, publicKey, secretKey)
|
|
return
|
|
}
|
|
|
|
serviceCmd.Parse(os.Args[2:])
|
|
publicKey, secretKey := cliGetAPIKeys(*serviceKey)
|
|
net := *serviceNetwork
|
|
if net == "" {
|
|
net = *network
|
|
}
|
|
vc := *serviceVcpu
|
|
if vc == 0 {
|
|
vc = *vcpu
|
|
}
|
|
cmdService(*serviceName, *servicePorts, *serviceDomains, *serviceType, *serviceBootstrap, *serviceBootstrapFile, *serviceList, *serviceInfo, *serviceLogs, *serviceTail, *serviceSleep, *serviceWake, *serviceDestroy, *serviceResize, *serviceExecute, *serviceCommand, *serviceDumpBootstrap, *serviceDumpFile, *serviceSnapshot, *serviceRestore, *serviceSnapshotName, *serviceHot, net, vc, serviceFiles, serviceEnvs, *serviceEnvFile, publicKey, secretKey)
|
|
return
|
|
|
|
case "snapshot":
|
|
snapshotCmd.Parse(os.Args[2:])
|
|
publicKey, secretKey := cliGetAPIKeys(*snapshotKey)
|
|
cmdSnapshot(*snapshotList, *snapshotInfo, *snapshotDelete, *snapshotClone, *snapshotType, *snapshotName, *snapshotShell, *snapshotPorts, publicKey, secretKey)
|
|
return
|
|
|
|
case "key":
|
|
keyCmd.Parse(os.Args[2:])
|
|
publicKey, secretKey := cliGetAPIKeys(*keyKey)
|
|
validateKey(publicKey, secretKey, *keyExtend)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Execute mode
|
|
if flag.NArg() == 0 {
|
|
fmt.Fprintf(os.Stderr, "Usage: %s [options] <source_file>\n", os.Args[0])
|
|
fmt.Fprintf(os.Stderr, " %s session [options]\n", os.Args[0])
|
|
fmt.Fprintf(os.Stderr, " %s service [options]\n", os.Args[0])
|
|
fmt.Fprintf(os.Stderr, " %s snapshot [options]\n", os.Args[0])
|
|
fmt.Fprintf(os.Stderr, " %s key [options]\n", os.Args[0])
|
|
os.Exit(1)
|
|
}
|
|
|
|
sourceFile := flag.Arg(0)
|
|
publicKey, secretKey := cliGetAPIKeys(*apiKey)
|
|
cmdExecute(sourceFile, envs, files, *artifacts, *outputDir, *network, *vcpu, publicKey, secretKey)
|
|
}
|