#!/usr/bin/env php executeCode('python', 'print("hello")'); * * // Execute asynchronously * $jobId = $client->executeAsync('javascript', 'console.log("hello")'); * * // Wait for job completion with exponential backoff * $result = $client->waitForJob($jobId); * * // List all jobs * $jobs = $client->listJobs(); * * // Get supported languages (cached for 1 hour) * $languages = $client->getLanguages(); * * // Snapshot operations * $snapshotId = $client->sessionSnapshot($sessionId); * * Authentication Priority (4-tier): * 1. Method arguments (publicKey, secretKey) * 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) * 3. Config file (~/.unsandbox/accounts.csv, line 0 by default) * 4. Local directory (./accounts.csv, line 0 by default) * * Request Authentication (HMAC-SHA256): * Authorization: Bearer * X-Timestamp: * X-Signature: HMAC-SHA256(secret_key, "timestamp:METHOD:path:body") * * Languages Cache: * - Cached in ~/.unsandbox/languages.json * - TTL: 1 hour * - Updated on successful API calls */ namespace Unsandbox; /** * Exception thrown when credentials cannot be found or are invalid. */ class CredentialsException extends \Exception {} /** * Exception thrown when an API request fails. */ class ApiException extends \Exception { private ?array $response; public function __construct(string $message, int $code = 0, ?array $response = null, ?\Throwable $previous = null) { parent::__construct($message, $code, $previous); $this->response = $response; } public function getResponse(): ?array { return $this->response; } } /** * Unsandbox PHP SDK - Synchronous Client * * Provides methods to execute code, manage jobs, and handle snapshots * using the unsandbox.com API. */ class Unsandbox { private const API_BASE = 'https://api.unsandbox.com'; private const LANGUAGES_CACHE_TTL = 3600; // 1 hour private const POLL_DELAYS_MS = [300, 450, 700, 900, 650, 1600, 2000]; /** * Language detection mapping (file extension -> language) */ private const LANGUAGE_MAP = [ 'py' => 'python', 'js' => 'javascript', 'ts' => 'typescript', 'rb' => 'ruby', 'php' => 'php', 'pl' => 'perl', 'sh' => 'bash', 'r' => 'r', 'R' => 'r', 'lua' => 'lua', 'go' => 'go', 'rs' => 'rust', 'c' => 'c', 'cpp' => 'cpp', 'cc' => 'cpp', 'cxx' => 'cpp', 'java' => 'java', 'kt' => 'kotlin', 'm' => 'objc', 'cs' => 'csharp', 'fs' => 'fsharp', 'hs' => 'haskell', 'ml' => 'ocaml', 'clj' => 'clojure', 'scm' => 'scheme', 'ss' => 'scheme', 'erl' => 'erlang', 'ex' => 'elixir', 'exs' => 'elixir', 'jl' => 'julia', 'd' => 'd', 'nim' => 'nim', 'zig' => 'zig', 'v' => 'v', 'cr' => 'crystal', 'dart' => 'dart', 'groovy' => 'groovy', 'f90' => 'fortran', 'f95' => 'fortran', 'lisp' => 'commonlisp', 'lsp' => 'commonlisp', 'cob' => 'cobol', 'tcl' => 'tcl', 'raku' => 'raku', 'pro' => 'prolog', 'p' => 'prolog', '4th' => 'forth', 'forth' => 'forth', 'fth' => 'forth', ]; private ?string $defaultPublicKey = null; private ?string $defaultSecretKey = null; private int $accountIndex = 0; /** * Create a new Unsandbox client. * * @param string|null $publicKey Default public key (optional) * @param string|null $secretKey Default secret key (optional) * @param int $accountIndex Account index for CSV files (default: 0) */ public function __construct(?string $publicKey = null, ?string $secretKey = null, int $accountIndex = 0) { $this->defaultPublicKey = $publicKey; $this->defaultSecretKey = $secretKey; $this->accountIndex = $accountIndex; } /** * Execute code synchronously (awaits until completion). * * @param string $language Programming language (e.g., "python", "javascript", "go") * @param string $code Source code to execute * @param string|null $publicKey Optional API key (uses credentials resolution if not provided) * @param string|null $secretKey Optional API secret (uses credentials resolution if not provided) * @return array Response array containing stdout, stderr, exit code, etc. * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function executeCode(string $language, string $code, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); $response = $this->makeRequest( 'POST', '/execute', $publicKey, $secretKey, ['language' => $language, 'code' => $code] ); // If we got a job_id, poll until completion $jobId = $response['job_id'] ?? null; $status = $response['status'] ?? null; if ($jobId && in_array($status, ['pending', 'running'], true)) { return $this->waitForJob($jobId, $publicKey, $secretKey); } return $response; } /** * Execute code asynchronously (returns immediately with job_id). * * @param string $language Programming language (e.g., "python", "javascript") * @param string $code Source code to execute * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return string Job ID string * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function executeAsync(string $language, string $code, ?string $publicKey = null, ?string $secretKey = null): string { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); $response = $this->makeRequest( 'POST', '/execute', $publicKey, $secretKey, ['language' => $language, 'code' => $code] ); return $response['job_id'] ?? ''; } /** * Get current status/result of a job (single poll, no waiting). * * @param string $jobId Job ID from executeAsync() * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Job response array * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function getJob(string $jobId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequest('GET', "/jobs/{$jobId}", $publicKey, $secretKey); } /** * Wait for job completion with exponential backoff polling. * * Polling delays (ms): [300, 450, 700, 900, 650, 1600, 2000, ...] * Cumulative: 300, 750, 1450, 2350, 3000, 4600, 6600ms+ * * @param string $jobId Job ID from executeAsync() * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @param int $timeout Maximum wait time in seconds (default: 3600) * @return array Final job result when status is terminal (completed, failed, timeout, cancelled) * @throws CredentialsException Missing credentials * @throws ApiException API request failed or timeout exceeded */ public function waitForJob(string $jobId, ?string $publicKey = null, ?string $secretKey = null, int $timeout = 3600): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); $pollCount = 0; $startTime = time(); while (true) { // Check timeout if ((time() - $startTime) >= $timeout) { throw new ApiException("Timeout waiting for job {$jobId}"); } // Sleep before polling $delayIdx = min($pollCount, count(self::POLL_DELAYS_MS) - 1); usleep(self::POLL_DELAYS_MS[$delayIdx] * 1000); $pollCount++; $response = $this->getJob($jobId, $publicKey, $secretKey); $status = $response['status'] ?? null; if (in_array($status, ['completed', 'failed', 'timeout', 'cancelled'], true)) { return $response; } // Still running, continue polling } } /** * Cancel a running job. * * @param string $jobId Job ID to cancel * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with cancellation confirmation * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function cancelJob(string $jobId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequest('DELETE', "/jobs/{$jobId}", $publicKey, $secretKey); } /** * List all jobs for the authenticated account. * * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array List of job arrays * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function listJobs(?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); $response = $this->makeRequest('GET', '/jobs', $publicKey, $secretKey); return $response['jobs'] ?? []; } /** * Get list of supported programming languages. * * Results are cached for 1 hour in ~/.unsandbox/languages.json * * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array List of language identifiers (e.g., ["python", "javascript", "go", ...]) * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function getLanguages(?string $publicKey = null, ?string $secretKey = null): array { // Try cache first $cached = $this->loadLanguagesCache(); if ($cached !== null) { return $cached; } [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); $response = $this->makeRequest('GET', '/languages', $publicKey, $secretKey); $languages = $response['languages'] ?? []; // Cache the result $this->saveLanguagesCache($languages); return $languages; } /** * Detect programming language from filename extension. * * @param string $filename Filename to detect language from (e.g., "script.py") * @return string|null Language identifier (e.g., "python") or null if unknown */ public static function detectLanguage(string $filename): ?string { if (empty($filename) || strpos($filename, '.') === false) { return null; } $parts = explode('.', $filename); $ext = end($parts); return self::LANGUAGE_MAP[$ext] ?? null; } /** * Create a snapshot of a session. * * @param string $sessionId Session ID to snapshot * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @param string|null $name Optional snapshot name * @param bool $ephemeral If true, snapshot is ephemeral (hot snapshot) * @return string Snapshot ID * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function sessionSnapshot( string $sessionId, ?string $publicKey = null, ?string $secretKey = null, ?string $name = null, bool $ephemeral = false ): string { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); $data = ['session_id' => $sessionId, 'hot' => $ephemeral]; if ($name !== null) { $data['name'] = $name; } $response = $this->makeRequest('POST', '/snapshots', $publicKey, $secretKey, $data); return $response['snapshot_id'] ?? ''; } /** * Create a snapshot of a service. * * @param string $serviceId Service ID to snapshot * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @param string|null $name Optional snapshot name * @return string Snapshot ID * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function serviceSnapshot( string $serviceId, ?string $publicKey = null, ?string $secretKey = null, ?string $name = null ): string { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); $data = ['service_id' => $serviceId]; if ($name !== null) { $data['name'] = $name; } $response = $this->makeRequest('POST', '/snapshots', $publicKey, $secretKey, $data); return $response['snapshot_id'] ?? ''; } /** * List all snapshots. * * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array List of snapshot arrays * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function listSnapshots(?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); $response = $this->makeRequest('GET', '/snapshots', $publicKey, $secretKey); return $response['snapshots'] ?? []; } /** * Get details of a specific snapshot. * * @param string $snapshotId Snapshot ID to get details for * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Snapshot details array containing: * - id: Snapshot ID * - name: Snapshot name * - type: "session" or "service" * - source_id: Original resource ID * - hot: Whether snapshot preserves running state * - locked: Whether snapshot is locked * - created_at: Creation timestamp * - size_bytes: Size in bytes * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function getSnapshot(string $snapshotId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequest('GET', "/snapshots/{$snapshotId}", $publicKey, $secretKey); } /** * Restore a snapshot. * * @param string $snapshotId Snapshot ID to restore * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with restored resource info * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function restoreSnapshot(string $snapshotId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequest('POST', "/snapshots/{$snapshotId}/restore", $publicKey, $secretKey, []); } /** * Delete a snapshot. * * @param string $snapshotId Snapshot ID to delete * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with deletion confirmation * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function deleteSnapshot(string $snapshotId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequestWithSudo('DELETE', "/snapshots/{$snapshotId}", $publicKey, $secretKey); } /** * Lock a snapshot to prevent deletion. * * @param string $snapshotId Snapshot ID to lock * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with lock confirmation * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function lockSnapshot(string $snapshotId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequest('POST', "/snapshots/{$snapshotId}/lock", $publicKey, $secretKey, []); } /** * Unlock a snapshot to allow deletion. * * @param string $snapshotId Snapshot ID to unlock * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with unlock confirmation * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function unlockSnapshot(string $snapshotId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequestWithSudo('POST', "/snapshots/{$snapshotId}/unlock", $publicKey, $secretKey, []); } /** * Clone a snapshot to create a new session or service. * * @param string $snapshotId Snapshot ID to clone * @param string|null $name Optional name for the new resource * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @param array $opts Optional parameters: 'type' (session|service), 'shell', 'ports' * @return array Response array with cloned resource info * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function cloneSnapshot( string $snapshotId, ?string $name = null, ?string $publicKey = null, ?string $secretKey = null, array $opts = [] ): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); $data = []; if ($name !== null) { $data['name'] = $name; } if (isset($opts['type'])) { $data['type'] = $opts['type']; } if (isset($opts['shell'])) { $data['shell'] = $opts['shell']; } if (isset($opts['ports'])) { $data['ports'] = $opts['ports']; } return $this->makeRequest('POST', "/snapshots/{$snapshotId}/clone", $publicKey, $secretKey, $data); } // ========================================================================= // Images Methods (LXD Container Images) // ========================================================================= /** * Publish a new image from a session or service container. * * @param string $sourceType Source type: "session" or "service" * @param string $sourceId Session ID or Service ID to publish from * @param string|null $name Optional name for the image * @param string|null $description Optional description for the image * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with image_id and image details * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function imagePublish( string $sourceType, string $sourceId, ?string $name = null, ?string $description = null, ?string $publicKey = null, ?string $secretKey = null ): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); $data = [ 'source_type' => $sourceType, 'source_id' => $sourceId, ]; if ($name !== null) { $data['name'] = $name; } if ($description !== null) { $data['description'] = $description; } return $this->makeRequest('POST', '/images', $publicKey, $secretKey, $data); } /** * List all images owned by the authenticated account. * * @param string|null $filterType Optional filter: "owned", "shared", "public", or null for all * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array List of image arrays * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function listImages(?string $filterType = null, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); $path = $filterType !== null ? "/images/{$filterType}" : '/images'; $response = $this->makeRequest('GET', $path, $publicKey, $secretKey); return $response['images'] ?? []; } /** * Get details of a specific image. * * @param string $imageId Image ID * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Image details * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function getImage(string $imageId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequest('GET', "/images/{$imageId}", $publicKey, $secretKey); } /** * Delete an image. * * @param string $imageId Image ID to delete * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with deletion confirmation * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function deleteImage(string $imageId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequestWithSudo('DELETE', "/images/{$imageId}", $publicKey, $secretKey); } /** * Lock an image to prevent deletion. * * @param string $imageId Image ID to lock * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with lock confirmation * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function lockImage(string $imageId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequest('POST', "/images/{$imageId}/lock", $publicKey, $secretKey, []); } /** * Unlock an image to allow deletion. * * @param string $imageId Image ID to unlock * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with unlock confirmation * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function unlockImage(string $imageId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequestWithSudo('POST', "/images/{$imageId}/unlock", $publicKey, $secretKey, []); } /** * Set visibility of an image. * * @param string $imageId Image ID * @param string $visibility Visibility level: "private" or "public" * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with visibility update confirmation * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function setImageVisibility( string $imageId, string $visibility, ?string $publicKey = null, ?string $secretKey = null ): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequest('POST', "/images/{$imageId}/visibility", $publicKey, $secretKey, [ 'visibility' => $visibility, ]); } /** * Grant access to an image for another API key. * * @param string $imageId Image ID * @param string $trustedApiKey The API key to grant access to * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with grant confirmation * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function grantImageAccess( string $imageId, string $trustedApiKey, ?string $publicKey = null, ?string $secretKey = null ): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequest('POST', "/images/{$imageId}/grant", $publicKey, $secretKey, [ 'trusted_api_key' => $trustedApiKey, ]); } /** * Revoke access to an image from another API key. * * @param string $imageId Image ID * @param string $trustedApiKey The API key to revoke access from * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with revoke confirmation * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function revokeImageAccess( string $imageId, string $trustedApiKey, ?string $publicKey = null, ?string $secretKey = null ): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequest('POST', "/images/{$imageId}/revoke", $publicKey, $secretKey, [ 'trusted_api_key' => $trustedApiKey, ]); } /** * List API keys that have been granted access to an image. * * @param string $imageId Image ID * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array List of trusted API key information * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function listImageTrusted(string $imageId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); $response = $this->makeRequest('GET', "/images/{$imageId}/trusted", $publicKey, $secretKey); return $response['trusted'] ?? []; } /** * Transfer ownership of an image to another API key. * * @param string $imageId Image ID to transfer * @param string $toApiKey The API key to transfer ownership to * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with transfer confirmation * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function transferImage( string $imageId, string $toApiKey, ?string $publicKey = null, ?string $secretKey = null ): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequest('POST', "/images/{$imageId}/transfer", $publicKey, $secretKey, [ 'to_api_key' => $toApiKey, ]); } /** * Spawn a new service from an image. * * @param string $imageId Image ID to spawn from * @param string|null $name Optional service name * @param array|string|null $ports Optional port(s) to expose (array of ints or comma-separated string) * @param string|null $bootstrap Optional bootstrap command or URL * @param string $networkMode Network mode: "zerotrust" or "semitrusted" * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with spawned service info * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function spawnFromImage( string $imageId, ?string $name = null, $ports = null, ?string $bootstrap = null, string $networkMode = 'zerotrust', ?string $publicKey = null, ?string $secretKey = null ): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); $data = [ 'network_mode' => $networkMode, ]; if ($name !== null) { $data['name'] = $name; } if ($ports !== null) { // Convert ports to array if string if (is_string($ports)) { $ports = array_map('intval', explode(',', $ports)); } $data['ports'] = $ports; } if ($bootstrap !== null) { $data['bootstrap'] = $bootstrap; } return $this->makeRequest('POST', "/images/{$imageId}/spawn", $publicKey, $secretKey, $data); } /** * Clone an image to create a new image with a different name/description. * * @param string $imageId Image ID to clone * @param string|null $name Optional name for the cloned image * @param string|null $description Optional description for the cloned image * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with cloned image info * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function cloneImage( string $imageId, ?string $name = null, ?string $description = null, ?string $publicKey = null, ?string $secretKey = null ): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); $data = []; if ($name !== null) { $data['name'] = $name; } if ($description !== null) { $data['description'] = $description; } return $this->makeRequest('POST', "/images/{$imageId}/clone", $publicKey, $secretKey, $data); } // ========================================================================= // Session Methods // ========================================================================= /** * List all active sessions for the authenticated account. * * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array List of session arrays * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function listSessions(?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); $response = $this->makeRequest('GET', '/sessions', $publicKey, $secretKey); return $response['sessions'] ?? []; } /** * Get details of a specific session. * * @param string $sessionId Session ID * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Session details * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function getSession(string $sessionId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequest('GET', "/sessions/{$sessionId}", $publicKey, $secretKey); } /** * Create a new interactive session. * * @param string $language Programming language or shell (e.g., "bash", "python3") * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @param array $opts Optional parameters: 'network_mode', 'ttl', 'shell', 'multiplexer', 'vcpu' * @return array Session info including session_id and container_name * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function createSession( string $language, ?string $publicKey = null, ?string $secretKey = null, array $opts = [] ): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); $data = [ 'network_mode' => $opts['network_mode'] ?? 'zerotrust', 'ttl' => $opts['ttl'] ?? 3600, ]; if (!empty($language)) { $data['shell'] = $language; } if (isset($opts['shell'])) { $data['shell'] = $opts['shell']; } if (isset($opts['multiplexer'])) { $data['multiplexer'] = $opts['multiplexer']; } if (isset($opts['vcpu']) && $opts['vcpu'] > 1) { $data['vcpu'] = $opts['vcpu']; } if (isset($opts['input_files'])) { $data['input_files'] = $opts['input_files']; } return $this->makeRequest('POST', '/sessions', $publicKey, $secretKey, $data); } /** * Delete (terminate) a session. * * @param string $sessionId Session ID to delete * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with deletion confirmation * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function deleteSession(string $sessionId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequest('DELETE', "/sessions/{$sessionId}", $publicKey, $secretKey); } /** * Freeze a session to pause execution and reduce resource usage. * * @param string $sessionId Session ID to freeze * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with freeze confirmation * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function freezeSession(string $sessionId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequest('POST', "/sessions/{$sessionId}/freeze", $publicKey, $secretKey, []); } /** * Unfreeze a session to resume execution. * * @param string $sessionId Session ID to unfreeze * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with unfreeze confirmation * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function unfreezeSession(string $sessionId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequest('POST', "/sessions/{$sessionId}/unfreeze", $publicKey, $secretKey, []); } /** * Boost a session's resources (increase vCPU, memory is derived: vcpu * 2048MB). * * @param string $sessionId Session ID to boost * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @param int $vcpu Number of vCPUs (default: 2) * @return array Response array with boost confirmation * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function boostSession( string $sessionId, ?string $publicKey = null, ?string $secretKey = null, int $vcpu = 2 ): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequest('POST', "/sessions/{$sessionId}/boost", $publicKey, $secretKey, ['vcpu' => $vcpu]); } /** * Remove boost from a session (return to base resources). * * @param string $sessionId Session ID to unboost * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with unboost confirmation * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function unboostSession(string $sessionId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequest('POST', "/sessions/{$sessionId}/unboost", $publicKey, $secretKey, []); } /** * Execute a shell command in an active session. * * Note: This is for one-shot commands. For interactive sessions, use WebSocket connection. * * @param string $sessionId Session ID * @param string $command Command to execute * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with command output * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function shellSession( string $sessionId, string $command, ?string $publicKey = null, ?string $secretKey = null ): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequest('POST', "/sessions/{$sessionId}/shell", $publicKey, $secretKey, ['command' => $command]); } // ========================================================================= // Service Methods // ========================================================================= /** * List all services for the authenticated account. * * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array List of service arrays * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function listServices(?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); $response = $this->makeRequest('GET', '/services', $publicKey, $secretKey); return $response['services'] ?? []; } /** * Create a new persistent service. * * @param string $name Service name * @param array|string $ports Port(s) to expose (array of ints or comma-separated string) * @param string $bootstrap Bootstrap command or URL * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @param array $opts Optional parameters: 'network_mode', 'vcpu', 'service_type', 'custom_domains', 'bootstrap_content', 'input_files' * @return array Service info including service_id * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function createService( string $name, $ports, string $bootstrap, ?string $publicKey = null, ?string $secretKey = null, array $opts = [] ): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); // Convert ports to array if string if (is_string($ports)) { $ports = array_map('intval', explode(',', $ports)); } $data = [ 'name' => $name, 'ports' => $ports, 'bootstrap' => $bootstrap, ]; if (isset($opts['network_mode'])) { $data['network_mode'] = $opts['network_mode']; } if (isset($opts['vcpu']) && $opts['vcpu'] > 1) { $data['vcpu'] = $opts['vcpu']; } if (isset($opts['service_type'])) { $data['service_type'] = $opts['service_type']; } if (isset($opts['custom_domains'])) { $data['custom_domains'] = $opts['custom_domains']; } if (isset($opts['bootstrap_content'])) { $data['bootstrap_content'] = $opts['bootstrap_content']; } if (isset($opts['input_files'])) { $data['input_files'] = $opts['input_files']; } if (isset($opts['unfreeze_on_demand']) && $opts['unfreeze_on_demand']) { $data['unfreeze_on_demand'] = true; } return $this->makeRequest('POST', '/services', $publicKey, $secretKey, $data); } /** * Get details of a specific service. * * @param string $serviceId Service ID * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Service details * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function getService(string $serviceId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequest('GET', "/services/{$serviceId}", $publicKey, $secretKey); } /** * Update a service (e.g., resize vCPU). * * @param string $serviceId Service ID * @param array $opts Update parameters: 'vcpu', 'name', etc. * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Updated service details * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function updateService( string $serviceId, array $opts, ?string $publicKey = null, ?string $secretKey = null ): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequest('PATCH', "/services/{$serviceId}", $publicKey, $secretKey, $opts); } /** * Delete (destroy) a service. * * @param string $serviceId Service ID to delete * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with deletion confirmation * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function deleteService(string $serviceId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequestWithSudo('DELETE', "/services/{$serviceId}", $publicKey, $secretKey); } /** * Freeze a service to pause execution and reduce resource usage. * * @param string $serviceId Service ID to freeze * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with freeze confirmation * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function freezeService(string $serviceId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequest('POST', "/services/{$serviceId}/freeze", $publicKey, $secretKey, []); } /** * Unfreeze a service to resume execution. * * @param string $serviceId Service ID to unfreeze * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with unfreeze confirmation * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function unfreezeService(string $serviceId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequest('POST', "/services/{$serviceId}/unfreeze", $publicKey, $secretKey, []); } /** * Lock a service to prevent deletion. * * @param string $serviceId Service ID to lock * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with lock confirmation * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function lockService(string $serviceId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequest('POST', "/services/{$serviceId}/lock", $publicKey, $secretKey, []); } /** * Unlock a service to allow deletion. * * @param string $serviceId Service ID to unlock * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with unlock confirmation * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function unlockService(string $serviceId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequestWithSudo('POST', "/services/{$serviceId}/unlock", $publicKey, $secretKey, []); } /** * Set unfreeze_on_demand flag for a service. * * @param string $serviceId Service ID * @param bool $enabled Whether to enable unfreeze on demand * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with update confirmation * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function setUnfreezeOnDemand(string $serviceId, bool $enabled, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequest('PATCH', "/services/{$serviceId}", $publicKey, $secretKey, ['unfreeze_on_demand' => $enabled]); } /** * Set show_freeze_page flag for a service. * * @param string $serviceId Service ID * @param bool $enabled Whether to enable show freeze page * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with update confirmation * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function setShowFreezePage(string $serviceId, bool $enabled, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequest('PATCH', "/services/{$serviceId}", $publicKey, $secretKey, ['show_freeze_page' => $enabled]); } /** * Get bootstrap logs for a service. * * @param string $serviceId Service ID * @param bool $all If true, get all logs; if false, get last 9000 lines * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with log content * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function getServiceLogs( string $serviceId, bool $all = false, ?string $publicKey = null, ?string $secretKey = null ): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); $path = "/services/{$serviceId}/logs" . ($all ? '?all=true' : ''); return $this->makeRequest('GET', $path, $publicKey, $secretKey); } /** * Get environment vault status for a service. * * @param string $serviceId Service ID * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with vault status (has_vault, count, updated_at) * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function getServiceEnv(string $serviceId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequest('GET', "/services/{$serviceId}/env", $publicKey, $secretKey); } /** * Set environment vault for a service. * * @param string $serviceId Service ID * @param string $env Environment content in .env format (KEY=VALUE\nKEY2=VALUE2) * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with update confirmation * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function setServiceEnv( string $serviceId, string $env, ?string $publicKey = null, ?string $secretKey = null ): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequestRaw('PUT', "/services/{$serviceId}/env", $publicKey, $secretKey, $env, 'text/plain'); } /** * Delete environment vault for a service. * * @param string $serviceId Service ID * @param array|null $keys Optional specific keys to delete; if null, deletes entire vault * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with deletion confirmation * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function deleteServiceEnv( string $serviceId, ?array $keys = null, ?string $publicKey = null, ?string $secretKey = null ): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequest('DELETE', "/services/{$serviceId}/env", $publicKey, $secretKey); } /** * Export environment vault for a service (returns .env format). * * @param string $serviceId Service ID * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with env content * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function exportServiceEnv(string $serviceId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequest('POST', "/services/{$serviceId}/env/export", $publicKey, $secretKey, []); } /** * Redeploy a service (re-run bootstrap script). * * @param string $serviceId Service ID * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with redeploy confirmation * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function redeployService(string $serviceId, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequest('POST', "/services/{$serviceId}/redeploy", $publicKey, $secretKey, []); } /** * Execute a command in a running service container. * * @param string $serviceId Service ID * @param string $command Command to execute * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @param int $timeout Timeout in milliseconds (default: 30000) * @return array Response array with command output (stdout, stderr, exit_code) * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function executeInService( string $serviceId, string $command, ?string $publicKey = null, ?string $secretKey = null, int $timeout = 30000 ): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); $response = $this->makeRequest('POST', "/services/{$serviceId}/execute", $publicKey, $secretKey, [ 'command' => $command, 'timeout' => $timeout, ]); // If we got a job_id, poll until completion $jobId = $response['job_id'] ?? null; if ($jobId) { return $this->waitForJob($jobId, $publicKey, $secretKey); } return $response; } /** * Resize a service's vCPU allocation. * * @param string $serviceId Service ID to resize * @param int $vcpu Number of vCPUs (1-8 typically) * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with updated service info * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function resizeService(string $serviceId, int $vcpu, ?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequest('PATCH', "/services/{$serviceId}", $publicKey, $secretKey, ['vcpu' => $vcpu]); } // ========================================================================= // Key Validation // ========================================================================= /** * Validate API keys. * * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Response array with validation result * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function validateKeys(?string $publicKey = null, ?string $secretKey = null): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); return $this->makeRequest('POST', '/keys/validate', $publicKey, $secretKey, []); } // ========================================================================= // Image Generation // ========================================================================= /** * Generate images from text prompt using AI. * * @param string $prompt Text description of the image to generate * @param string|null $model Model to use (optional) * @param string $size Image size (default: "1024x1024") * @param string $quality "standard" or "hd" (default: "standard") * @param int $n Number of images to generate (default: 1) * @param string|null $publicKey API public key * @param string|null $secretKey API secret key * @return array Result with 'images' array and 'created_at' * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function image( string $prompt, ?string $model = null, string $size = "1024x1024", string $quality = "standard", int $n = 1, ?string $publicKey = null, ?string $secretKey = null ): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); $payload = [ 'prompt' => $prompt, 'size' => $size, 'quality' => $quality, 'n' => $n, ]; if ($model !== null) { $payload['model'] = $model; } return $this->makeRequest('POST', '/image', $publicKey, $secretKey, $payload); } // ========================================================================= // PaaS Logs Functions // ========================================================================= private static ?string $lastError = null; /** * Fetch batch logs from the PaaS platform. * * @param string $source Log source - "all", "api", "portal", "pool/cammy", "pool/ai" * @param int $lines Number of lines to fetch (1-10000) * @param string $since Time window - "1m", "5m", "1h", "1d" * @param string|null $grep Optional filter pattern * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @return array Log entries * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function logsFetch( string $source = 'all', int $lines = 100, string $since = '5m', ?string $grep = null, ?string $publicKey = null, ?string $secretKey = null ): array { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); $path = "/logs?source=" . urlencode($source) . "&lines={$lines}&since=" . urlencode($since); if ($grep !== null) { $path .= "&grep=" . urlencode($grep); } return $this->makeRequest('GET', $path, $publicKey, $secretKey); } /** * Stream logs via Server-Sent Events. * * Blocks until interrupted or server closes connection. * * @param string $source Log source - "all", "api", "portal", "pool/cammy", "pool/ai" * @param string|null $grep Optional filter pattern * @param callable|null $callback Function called for each log line (signature: callback(source, line)) * @param string|null $publicKey Optional API key * @param string|null $secretKey Optional API secret * @throws CredentialsException Missing credentials * @throws ApiException API request failed */ public function logsStream( string $source = 'all', ?string $grep = null, ?callable $callback = null, ?string $publicKey = null, ?string $secretKey = null ): void { [$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey); $path = "/logs/stream?source=" . urlencode($source); if ($grep !== null) { $path .= "&grep=" . urlencode($grep); } $timestamp = time(); $signature = $this->signRequest($secretKey, $timestamp, 'GET', $path); $ch = curl_init(self::API_BASE . $path); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => false, CURLOPT_HTTPHEADER => [ "Authorization: Bearer {$publicKey}", "X-Timestamp: {$timestamp}", "X-Signature: {$signature}", "Accept: text/event-stream", ], CURLOPT_WRITEFUNCTION => function($ch, $data) use ($source, $callback) { $lines = explode("\n", $data); foreach ($lines as $line) { if (strpos($line, 'data: ') === 0) { $json = substr($line, 6); $entry = @json_decode($json, true); $entrySource = $entry['source'] ?? $source; $entryLine = $entry['line'] ?? $json; if ($callback !== null) { $callback($entrySource, $entryLine); } else { echo "[{$entrySource}] {$entryLine}\n"; } } } return strlen($data); }, ]); curl_exec($ch); curl_close($ch); } // ========================================================================= // Utility Functions // ========================================================================= public const SDK_VERSION = '4.2.0'; /** * Get the SDK version string. * * @return string Version string (e.g., "4.2.0") */ public static function version(): string { return self::SDK_VERSION; } /** * Check if the API is healthy and responding. * * @return bool true if API is healthy, false otherwise */ public static function healthCheck(): bool { $ch = curl_init(self::API_BASE . '/health'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); if ($error) { self::$lastError = "Health check failed: {$error}"; return false; } if ($httpCode !== 200) { self::$lastError = "Health check failed: HTTP {$httpCode}"; return false; } return true; } /** * Get the last error message. * * @return string|null Last error message or null */ public static function lastError(): ?string { return self::$lastError; } /** * Sign a message using HMAC-SHA256. * * This is the underlying signing function used for request authentication. * Exposed for testing and debugging purposes. * * @param string $secretKey The secret key for signing * @param string $message The message to sign * @return string 64-character lowercase hex string */ public static function hmacSign(string $secretKey, string $message): string { return hash_hmac('sha256', $message, $secretKey); } /** * Get path to ~/.unsandbox directory, creating if necessary. * * @return string Path to unsandbox directory */ private function getUnsandboxDir(): string { $home = getenv('HOME') ?: (getenv('USERPROFILE') ?: ''); if (empty($home)) { $home = posix_getpwuid(posix_getuid())['dir'] ?? '/tmp'; } $dir = $home . '/.unsandbox'; if (!is_dir($dir)) { mkdir($dir, 0700, true); } return $dir; } /** * Load credentials from a CSV file. * * @param string $csvPath Path to CSV file * @param int $accountIndex Account index (0-based) * @return array|null [publicKey, secretKey] or null if not found */ private function loadCredentialsFromCsv(string $csvPath, int $accountIndex = 0): ?array { if (!file_exists($csvPath)) { return null; } $handle = fopen($csvPath, 'r'); if ($handle === false) { return null; } $currentIndex = 0; while (($line = fgets($handle)) !== false) { $line = trim($line); if (empty($line) || $line[0] === '#') { continue; } if ($currentIndex === $accountIndex) { $parts = explode(',', $line); if (count($parts) >= 2) { $publicKey = trim($parts[0]); $secretKey = trim($parts[1]); fclose($handle); return [$publicKey, $secretKey]; } } $currentIndex++; } fclose($handle); return null; } /** * Resolve credentials from 4-tier priority system. * * Priority: * 1. Method arguments * 2. Environment variables * 3. ~/.unsandbox/accounts.csv * 4. ./accounts.csv * * @param string|null $publicKey Public key from method argument * @param string|null $secretKey Secret key from method argument * @return array [publicKey, secretKey] * @throws CredentialsException If no credentials found */ private function resolveCredentials(?string $publicKey, ?string $secretKey): array { // Tier 1: Method arguments if (!empty($publicKey) && !empty($secretKey)) { return [$publicKey, $secretKey]; } // Use default keys if provided to constructor if (!empty($this->defaultPublicKey) && !empty($this->defaultSecretKey)) { return [$this->defaultPublicKey, $this->defaultSecretKey]; } // Tier 2: Environment variables $envPk = getenv('UNSANDBOX_PUBLIC_KEY'); $envSk = getenv('UNSANDBOX_SECRET_KEY'); if (!empty($envPk) && !empty($envSk)) { return [$envPk, $envSk]; } // Determine account index $accountIndex = $this->accountIndex; $envAccount = getenv('UNSANDBOX_ACCOUNT'); if ($envAccount !== false && $envAccount !== '') { $accountIndex = (int)$envAccount; } // Tier 3: ~/.unsandbox/accounts.csv $unsandboxDir = $this->getUnsandboxDir(); $creds = $this->loadCredentialsFromCsv($unsandboxDir . '/accounts.csv', $accountIndex); if ($creds !== null) { return $creds; } // Tier 4: ./accounts.csv $creds = $this->loadCredentialsFromCsv('./accounts.csv', $accountIndex); if ($creds !== null) { return $creds; } throw new CredentialsException( "No credentials found. Please provide via:\n" . " 1. Method arguments (publicKey, secretKey)\n" . " 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n" . " 3. ~/.unsandbox/accounts.csv\n" . " 4. ./accounts.csv" ); } /** * Sign a request using HMAC-SHA256. * * Message format: "timestamp:METHOD:path:body" * * @param string $secretKey Secret key for signing * @param int $timestamp Unix timestamp * @param string $method HTTP method * @param string $path API endpoint path * @param string|null $body Request body (optional) * @return string 64-character hex signature */ private function signRequest(string $secretKey, int $timestamp, string $method, string $path, ?string $body = null): string { $bodyStr = $body ?? ''; $message = "{$timestamp}:{$method}:{$path}:{$bodyStr}"; return hash_hmac('sha256', $message, $secretKey); } /** * Make an authenticated HTTP request to the API. * * @param string $method HTTP method (GET, POST, DELETE) * @param string $path API endpoint path * @param string $publicKey API public key * @param string $secretKey API secret key * @param array|null $data Request data (optional) * @return array Decoded JSON response * @throws ApiException On network errors or non-2xx response */ private function makeRequest(string $method, string $path, string $publicKey, string $secretKey, ?array $data = null): array { $url = self::API_BASE . $path; $timestamp = time(); $body = $data !== null ? json_encode($data) : ''; $signature = $this->signRequest($secretKey, $timestamp, $method, $path, $data !== null ? $body : null); $headers = [ 'Authorization: Bearer ' . $publicKey, 'X-Timestamp: ' . $timestamp, 'X-Signature: ' . $signature, 'Content-Type: application/json', ]; $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_TIMEOUT, 120); switch ($method) { case 'POST': curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $body); break; case 'PUT': curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'); curl_setopt($ch, CURLOPT_POSTFIELDS, $body); break; case 'PATCH': curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH'); curl_setopt($ch, CURLOPT_POSTFIELDS, $body); break; case 'DELETE': curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); break; case 'GET': default: // GET is the default break; } $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); if ($response === false) { throw new ApiException("cURL error: {$error}"); } $decoded = json_decode($response, true); if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) { throw new ApiException("Invalid JSON response: " . json_last_error_msg()); } if ($httpCode >= 400) { $errorMessage = $decoded['error'] ?? $decoded['message'] ?? "HTTP {$httpCode}"; throw new ApiException($errorMessage, $httpCode, $decoded); } return $decoded; } /** * Make an authenticated HTTP request with sudo OTP challenge handling. * * If the server returns 428 (Precondition Required), prompts for OTP * and retries the request with X-Sudo-OTP and X-Sudo-Challenge headers. * * Used for destructive operations: service destroy/unlock, snapshot delete/unlock, * image delete/unlock. * * @param string $method HTTP method (GET, POST, DELETE, etc.) * @param string $path API endpoint path * @param string $publicKey API public key * @param string $secretKey API secret key * @param array|null $data Request data (optional) * @return array Decoded JSON response * @throws ApiException On network errors or non-2xx response */ private function makeRequestWithSudo(string $method, string $path, string $publicKey, string $secretKey, ?array $data = null): array { $url = self::API_BASE . $path; $timestamp = time(); $body = $data !== null ? json_encode($data) : ''; $signature = $this->signRequest($secretKey, $timestamp, $method, $path, $data !== null ? $body : null); $headers = [ 'Authorization: Bearer ' . $publicKey, 'X-Timestamp: ' . $timestamp, 'X-Signature: ' . $signature, 'Content-Type: application/json', ]; $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_TIMEOUT, 120); switch ($method) { case 'POST': curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $body); break; case 'PUT': curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'); curl_setopt($ch, CURLOPT_POSTFIELDS, $body); break; case 'PATCH': curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH'); curl_setopt($ch, CURLOPT_POSTFIELDS, $body); break; case 'DELETE': curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); break; case 'GET': default: break; } $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); if ($response === false) { throw new ApiException("cURL error: {$error}"); } // Handle 428 sudo OTP challenge if ($httpCode === 428) { $challengeId = ''; $challengeData = json_decode($response, true); if ($challengeData !== null && isset($challengeData['challenge_id'])) { $challengeId = $challengeData['challenge_id']; } fwrite(STDERR, "\033[33mConfirmation required. Check your email for a one-time code.\033[0m\n"); fwrite(STDERR, "Enter OTP: "); $otp = ''; if (stream_isatty(STDIN)) { $otp = trim(fgets(STDIN)); } else { throw new ApiException("Cannot read OTP in non-interactive mode"); } if (empty($otp)) { throw new ApiException("Operation cancelled"); } // Retry with sudo headers $retryTimestamp = time(); $retrySignature = $this->signRequest($secretKey, $retryTimestamp, $method, $path, $data !== null ? $body : null); $retryHeaders = [ 'Authorization: Bearer ' . $publicKey, 'X-Timestamp: ' . $retryTimestamp, 'X-Signature: ' . $retrySignature, 'Content-Type: application/json', 'X-Sudo-OTP: ' . $otp, 'X-Sudo-Challenge: ' . $challengeId, ]; $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HTTPHEADER, $retryHeaders); curl_setopt($ch, CURLOPT_TIMEOUT, 120); switch ($method) { case 'POST': curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $body); break; case 'PUT': curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'); curl_setopt($ch, CURLOPT_POSTFIELDS, $body); break; case 'PATCH': curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH'); curl_setopt($ch, CURLOPT_POSTFIELDS, $body); break; case 'DELETE': curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); break; case 'GET': default: break; } $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); if ($response === false) { throw new ApiException("cURL error: {$error}"); } } $decoded = json_decode($response, true); if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) { throw new ApiException("Invalid JSON response: " . json_last_error_msg()); } if ($httpCode >= 400) { $errorMessage = $decoded['error'] ?? $decoded['message'] ?? "HTTP {$httpCode}"; throw new ApiException($errorMessage, $httpCode, $decoded); } return $decoded; } /** * Make an authenticated HTTP request with raw body content (non-JSON). * * @param string $method HTTP method (PUT, POST, etc.) * @param string $path API endpoint path * @param string $publicKey API public key * @param string $secretKey API secret key * @param string $body Raw request body * @param string $contentType Content type header (e.g., 'text/plain') * @return array Decoded JSON response * @throws ApiException On network errors or non-2xx response */ private function makeRequestRaw(string $method, string $path, string $publicKey, string $secretKey, string $body, string $contentType = 'text/plain'): array { $url = self::API_BASE . $path; $timestamp = time(); $signature = $this->signRequest($secretKey, $timestamp, $method, $path, $body); $headers = [ 'Authorization: Bearer ' . $publicKey, 'X-Timestamp: ' . $timestamp, 'X-Signature: ' . $signature, 'Content-Type: ' . $contentType, ]; $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_TIMEOUT, 120); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); curl_setopt($ch, CURLOPT_POSTFIELDS, $body); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); if ($response === false) { throw new ApiException("cURL error: {$error}"); } $decoded = json_decode($response, true); if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) { throw new ApiException("Invalid JSON response: " . json_last_error_msg()); } if ($httpCode >= 400) { $errorMessage = $decoded['error'] ?? $decoded['message'] ?? "HTTP {$httpCode}"; throw new ApiException($errorMessage, $httpCode, $decoded); } return $decoded; } /** * Get path to languages cache file. * * @return string Path to cache file */ private function getLanguagesCachePath(): string { return $this->getUnsandboxDir() . '/languages.json'; } /** * Load languages from cache if valid (< 1 hour old). * * @return array|null List of languages or null if cache invalid */ private function loadLanguagesCache(): ?array { $cachePath = $this->getLanguagesCachePath(); if (!file_exists($cachePath)) { return null; } $mtime = filemtime($cachePath); $ageSeconds = time() - $mtime; if ($ageSeconds >= self::LANGUAGES_CACHE_TTL) { return null; } $content = file_get_contents($cachePath); if ($content === false) { return null; } $data = json_decode($content, true); if ($data === null) { return null; } return $data['languages'] ?? null; } /** * Save languages to cache. * * @param array $languages List of languages */ private function saveLanguagesCache(array $languages): void { $cachePath = $this->getLanguagesCachePath(); $data = [ 'languages' => $languages, 'timestamp' => time(), ]; file_put_contents($cachePath, json_encode($data)); } // ========================================================================= // CLI Methods // ========================================================================= /** * Main CLI entry point. * * @param array $argv Command line arguments */ public function cliMain(array $argv): void { $script = array_shift($argv); if (empty($argv)) { $this->cliShowHelp(); exit(0); } // Parse global options and determine command $globalOpts = $this->cliParseGlobalOptions($argv); $args = $globalOpts['args']; $opts = $globalOpts['opts']; // Handle help flag if ($opts['help']) { $this->cliShowHelp(); exit(0); } // Set credentials from options if (!empty($opts['public_key'])) { $this->defaultPublicKey = $opts['public_key']; } if (!empty($opts['secret_key'])) { $this->defaultSecretKey = $opts['secret_key']; } // Determine the command if (empty($args)) { $this->cliShowHelp(); exit(0); } $command = $args[0]; try { switch ($command) { case 'session': $this->cliHandleSession(array_slice($args, 1), $opts); break; case 'service': $this->cliHandleService(array_slice($args, 1), $opts); break; case 'snapshot': $this->cliHandleSnapshot(array_slice($args, 1), $opts); break; case 'image': $this->cliHandleImage(array_slice($args, 1), $opts); break; case 'key': $this->cliHandleKey($opts); break; case 'languages': $this->cliHandleLanguages(array_slice($args, 1), $opts); break; case '-h': case '--help': case 'help': $this->cliShowHelp(); break; default: // Default: execute code file or inline code $this->cliHandleExecute($args, $opts); break; } } catch (CredentialsException $e) { $this->cliError("Authentication error: " . $e->getMessage()); exit(3); } catch (ApiException $e) { $this->cliError("API error: " . $e->getMessage()); exit(4); } catch (\Exception $e) { $this->cliError("Error: " . $e->getMessage()); exit(1); } } /** * Parse global CLI options. * * @param array $argv Arguments to parse * @return array ['opts' => [...], 'args' => [...]] */ private function cliParseGlobalOptions(array $argv): array { $opts = [ 'shell' => null, 'env' => [], 'files' => [], 'files_path' => [], 'artifacts' => false, 'output' => null, 'public_key' => null, 'secret_key' => null, 'network' => 'zerotrust', 'vcpu' => 1, 'yes' => false, 'help' => false, ]; $args = []; $i = 0; while ($i < count($argv)) { $arg = $argv[$i]; if ($arg === '-s' || $arg === '--shell') { $i++; $opts['shell'] = $argv[$i] ?? null; } elseif ($arg === '-e' || $arg === '--env') { $i++; if (isset($argv[$i])) { $opts['env'][] = $argv[$i]; } } elseif ($arg === '-f' || $arg === '--file') { $i++; if (isset($argv[$i])) { $opts['files'][] = $argv[$i]; } } elseif ($arg === '-F' || $arg === '--file-path') { $i++; if (isset($argv[$i])) { $opts['files_path'][] = $argv[$i]; } } elseif ($arg === '-a' || $arg === '--artifacts') { $opts['artifacts'] = true; } elseif ($arg === '-o' || $arg === '--output') { $i++; $opts['output'] = $argv[$i] ?? null; } elseif ($arg === '-p' || $arg === '--public-key') { $i++; $opts['public_key'] = $argv[$i] ?? null; } elseif ($arg === '-k' || $arg === '--secret-key') { $i++; $opts['secret_key'] = $argv[$i] ?? null; } elseif ($arg === '-n' || $arg === '--network') { $i++; $opts['network'] = $argv[$i] ?? 'zerotrust'; } elseif ($arg === '-v' || $arg === '--vcpu') { $i++; $opts['vcpu'] = (int)($argv[$i] ?? 1); } elseif ($arg === '-y' || $arg === '--yes') { $opts['yes'] = true; } elseif ($arg === '-h' || $arg === '--help') { $opts['help'] = true; } elseif (strpos($arg, '-') !== 0) { $args[] = $arg; } $i++; } return ['opts' => $opts, 'args' => $args]; } /** * Handle execute command (default command). * * @param array $args Positional arguments * @param array $opts Options */ private function cliHandleExecute(array $args, array $opts): void { $code = null; $language = null; // If shell option is set, treat first arg as code if (!empty($opts['shell'])) { $language = $opts['shell']; $code = $args[0] ?? ''; } else { // First arg is a file $file = $args[0] ?? ''; if (empty($file)) { $this->cliError("No source file specified"); exit(2); } if (!file_exists($file)) { $this->cliError("File not found: {$file}"); exit(2); } $code = file_get_contents($file); if ($code === false) { $this->cliError("Cannot read file: {$file}"); exit(2); } $language = self::detectLanguage($file); if ($language === null) { $this->cliError("Cannot detect language from file extension: {$file}"); exit(2); } } // Build request options $requestOpts = [ 'network_mode' => $opts['network'], ]; if ($opts['vcpu'] > 1) { $requestOpts['vcpu'] = $opts['vcpu']; } // Handle environment variables $envVars = []; foreach ($opts['env'] as $envPair) { $pos = strpos($envPair, '='); if ($pos !== false) { $key = substr($envPair, 0, $pos); $val = substr($envPair, $pos + 1); $envVars[$key] = $val; } } // Handle input files $inputFiles = []; foreach ($opts['files'] as $filepath) { if (file_exists($filepath)) { $content = file_get_contents($filepath); $inputFiles[] = [ 'name' => basename($filepath), 'content' => base64_encode($content), ]; } } foreach ($opts['files_path'] as $filepath) { if (file_exists($filepath)) { $content = file_get_contents($filepath); $inputFiles[] = [ 'name' => $filepath, 'content' => base64_encode($content), 'preserve_path' => true, ]; } } $result = $this->executeCode($language, $code); // Output result if (isset($result['stdout'])) { echo $result['stdout']; } if (isset($result['stderr']) && !empty($result['stderr'])) { fwrite(STDERR, $result['stderr']); } echo "---\n"; echo "Exit code: " . ($result['exit_code'] ?? 0) . "\n"; if (isset($result['execution_time'])) { echo "Execution time: " . $result['execution_time'] . "ms\n"; } exit($result['exit_code'] ?? 0); } /** * Handle session subcommand. * * @param array $args Positional arguments * @param array $opts Options */ private function cliHandleSession(array $args, array $opts): void { // Parse session-specific options $sessionOpts = $this->cliParseSessionOptions($args); if ($sessionOpts['list']) { $sessions = $this->listSessions(); $this->cliPrintList($sessions, 'session'); return; } if ($sessionOpts['attach']) { $session = $this->getSession($sessionOpts['attach']); $this->cliPrintSessionInfo($session); echo "\nNote: Interactive attachment requires WebSocket support\n"; return; } if ($sessionOpts['kill']) { $result = $this->deleteSession($sessionOpts['kill']); echo "Session terminated: " . $sessionOpts['kill'] . "\n"; return; } if ($sessionOpts['freeze']) { $result = $this->freezeSession($sessionOpts['freeze']); echo "Session frozen: " . $sessionOpts['freeze'] . "\n"; return; } if ($sessionOpts['unfreeze']) { $result = $this->unfreezeSession($sessionOpts['unfreeze']); echo "Session unfrozen: " . $sessionOpts['unfreeze'] . "\n"; return; } if ($sessionOpts['boost']) { $result = $this->boostSession($sessionOpts['boost'], null, null, $opts['vcpu'] ?: 2); echo "Session boosted: " . $sessionOpts['boost'] . "\n"; return; } if ($sessionOpts['unboost']) { $result = $this->unboostSession($sessionOpts['unboost']); echo "Session unboost: " . $sessionOpts['unboost'] . "\n"; return; } if ($sessionOpts['snapshot']) { $snapshotId = $this->sessionSnapshot( $sessionOpts['snapshot'], null, null, $sessionOpts['snapshot_name'], $sessionOpts['hot'] ); echo "Snapshot created: {$snapshotId}\n"; return; } // Create new session $createOpts = [ 'network_mode' => $opts['network'], ]; if (!empty($sessionOpts['shell'])) { $createOpts['shell'] = $sessionOpts['shell']; } if ($sessionOpts['tmux']) { $createOpts['multiplexer'] = 'tmux'; } elseif ($sessionOpts['screen']) { $createOpts['multiplexer'] = 'screen'; } if ($opts['vcpu'] > 1) { $createOpts['vcpu'] = $opts['vcpu']; } $session = $this->createSession($sessionOpts['shell'] ?? 'bash', null, null, $createOpts); $this->cliPrintSessionInfo($session); } /** * Parse session-specific options. * * @param array $args Arguments to parse * @return array Parsed options */ private function cliParseSessionOptions(array $args): array { $opts = [ 'list' => false, 'attach' => null, 'kill' => null, 'freeze' => null, 'unfreeze' => null, 'boost' => null, 'unboost' => null, 'snapshot' => null, 'snapshot_name' => null, 'hot' => false, 'tmux' => false, 'screen' => false, 'shell' => null, 'audit' => false, ]; $i = 0; while ($i < count($args)) { $arg = $args[$i]; if ($arg === '--list' || $arg === '-l') { $opts['list'] = true; } elseif ($arg === '--attach') { $i++; $opts['attach'] = $args[$i] ?? null; } elseif ($arg === '--kill') { $i++; $opts['kill'] = $args[$i] ?? null; } elseif ($arg === '--freeze') { $i++; $opts['freeze'] = $args[$i] ?? null; } elseif ($arg === '--unfreeze') { $i++; $opts['unfreeze'] = $args[$i] ?? null; } elseif ($arg === '--boost') { $i++; $opts['boost'] = $args[$i] ?? null; } elseif ($arg === '--unboost') { $i++; $opts['unboost'] = $args[$i] ?? null; } elseif ($arg === '--snapshot') { $i++; $opts['snapshot'] = $args[$i] ?? null; } elseif ($arg === '--snapshot-name') { $i++; $opts['snapshot_name'] = $args[$i] ?? null; } elseif ($arg === '--hot') { $opts['hot'] = true; } elseif ($arg === '--tmux') { $opts['tmux'] = true; } elseif ($arg === '--screen') { $opts['screen'] = true; } elseif ($arg === '--shell') { $i++; $opts['shell'] = $args[$i] ?? null; } elseif ($arg === '--audit') { $opts['audit'] = true; } $i++; } return $opts; } /** * Handle service subcommand. * * @param array $args Positional arguments * @param array $opts Global options */ private function cliHandleService(array $args, array $opts): void { // Check for env subcommand if (!empty($args) && $args[0] === 'env') { $this->cliHandleServiceEnv(array_slice($args, 1), $opts); return; } // Parse service-specific options $serviceOpts = $this->cliParseServiceOptions($args); if ($serviceOpts['list']) { $services = $this->listServices(); $this->cliPrintList($services, 'service'); return; } if ($serviceOpts['info']) { $service = $this->getService($serviceOpts['info']); $this->cliPrintServiceInfo($service); return; } if ($serviceOpts['logs']) { $logs = $this->getServiceLogs($serviceOpts['logs'], true); echo $logs['logs'] ?? ''; return; } if ($serviceOpts['tail']) { $logs = $this->getServiceLogs($serviceOpts['tail'], false); echo $logs['logs'] ?? ''; return; } if ($serviceOpts['freeze']) { $result = $this->freezeService($serviceOpts['freeze']); echo "Service frozen: " . $serviceOpts['freeze'] . "\n"; return; } if ($serviceOpts['unfreeze']) { $result = $this->unfreezeService($serviceOpts['unfreeze']); echo "Service unfrozen: " . $serviceOpts['unfreeze'] . "\n"; return; } if ($serviceOpts['destroy']) { if (!$opts['yes']) { fwrite(STDERR, "Warning: This will permanently destroy the service. Use -y to confirm.\n"); exit(2); } $result = $this->deleteService($serviceOpts['destroy']); echo "Service destroyed: " . $serviceOpts['destroy'] . "\n"; return; } if ($serviceOpts['lock']) { $result = $this->lockService($serviceOpts['lock']); echo "Service locked: " . $serviceOpts['lock'] . "\n"; return; } if ($serviceOpts['unlock']) { $result = $this->unlockService($serviceOpts['unlock']); echo "Service unlocked: " . $serviceOpts['unlock'] . "\n"; return; } if ($serviceOpts['execute']) { $result = $this->executeInService($serviceOpts['execute'], $serviceOpts['execute_cmd']); if (isset($result['stdout'])) { echo $result['stdout']; } if (isset($result['stderr']) && !empty($result['stderr'])) { fwrite(STDERR, $result['stderr']); } exit($result['exit_code'] ?? 0); } if ($serviceOpts['redeploy']) { $result = $this->redeployService($serviceOpts['redeploy']); echo "Service redeploying: " . $serviceOpts['redeploy'] . "\n"; return; } if ($serviceOpts['snapshot']) { $snapshotId = $this->serviceSnapshot($serviceOpts['snapshot'], null, null, $serviceOpts['snapshot_name']); echo "Snapshot created: {$snapshotId}\n"; return; } if ($serviceOpts['resize']) { $result = $this->updateService($serviceOpts['resize'], ['vcpu' => $opts['vcpu']]); echo "Service resized: " . $serviceOpts['resize'] . "\n"; return; } if ($serviceOpts['set_unfreeze_on_demand']) { $enabled = in_array($serviceOpts['set_unfreeze_on_demand_enabled'], ['true', '1'], true); $result = $this->setUnfreezeOnDemand($serviceOpts['set_unfreeze_on_demand'], $enabled); $status = $enabled ? 'enabled' : 'disabled'; echo "Unfreeze-on-demand {$status} for service: " . $serviceOpts['set_unfreeze_on_demand'] . "\n"; return; } // Create new service if (!empty($serviceOpts['name'])) { $createOpts = []; if (!empty($opts['network'])) { $createOpts['network_mode'] = $opts['network']; } if ($opts['vcpu'] > 1) { $createOpts['vcpu'] = $opts['vcpu']; } if (!empty($serviceOpts['type'])) { $createOpts['service_type'] = $serviceOpts['type']; } if (!empty($serviceOpts['domains'])) { $createOpts['custom_domains'] = explode(',', $serviceOpts['domains']); } if ($serviceOpts['unfreeze_on_demand']) { $createOpts['unfreeze_on_demand'] = true; } // Handle bootstrap from file $bootstrap = $serviceOpts['bootstrap'] ?? ''; if (!empty($serviceOpts['bootstrap_file'])) { if (file_exists($serviceOpts['bootstrap_file'])) { $bootstrap = file_get_contents($serviceOpts['bootstrap_file']); } else { $this->cliError("Bootstrap file not found: " . $serviceOpts['bootstrap_file']); exit(2); } } // Handle env file if (!empty($serviceOpts['env_file'])) { if (file_exists($serviceOpts['env_file'])) { // Will be applied after creation } } $service = $this->createService( $serviceOpts['name'], $serviceOpts['ports'] ?? '80', $bootstrap, null, null, $createOpts ); $this->cliPrintServiceInfo($service); return; } $this->cliError("No action specified for service command. Use --list, --info, --name, etc."); exit(2); } /** * Parse service-specific options. * * @param array $args Arguments to parse * @return array Parsed options */ private function cliParseServiceOptions(array $args): array { $opts = [ 'list' => false, 'name' => null, 'ports' => null, 'domains' => null, 'type' => null, 'bootstrap' => null, 'bootstrap_file' => null, 'env_file' => null, 'info' => null, 'logs' => null, 'tail' => null, 'freeze' => null, 'unfreeze' => null, 'destroy' => null, 'lock' => null, 'unlock' => null, 'resize' => null, 'redeploy' => null, 'execute' => null, 'execute_cmd' => null, 'snapshot' => null, 'snapshot_name' => null, 'unfreeze_on_demand' => false, 'set_unfreeze_on_demand' => null, 'set_unfreeze_on_demand_enabled' => null, ]; $i = 0; while ($i < count($args)) { $arg = $args[$i]; if ($arg === '--list' || $arg === '-l') { $opts['list'] = true; } elseif ($arg === '--name') { $i++; $opts['name'] = $args[$i] ?? null; } elseif ($arg === '--ports') { $i++; $opts['ports'] = $args[$i] ?? null; } elseif ($arg === '--domains') { $i++; $opts['domains'] = $args[$i] ?? null; } elseif ($arg === '--type') { $i++; $opts['type'] = $args[$i] ?? null; } elseif ($arg === '--bootstrap') { $i++; $opts['bootstrap'] = $args[$i] ?? null; } elseif ($arg === '--bootstrap-file') { $i++; $opts['bootstrap_file'] = $args[$i] ?? null; } elseif ($arg === '--env-file') { $i++; $opts['env_file'] = $args[$i] ?? null; } elseif ($arg === '--info') { $i++; $opts['info'] = $args[$i] ?? null; } elseif ($arg === '--logs') { $i++; $opts['logs'] = $args[$i] ?? null; } elseif ($arg === '--tail') { $i++; $opts['tail'] = $args[$i] ?? null; } elseif ($arg === '--freeze') { $i++; $opts['freeze'] = $args[$i] ?? null; } elseif ($arg === '--unfreeze') { $i++; $opts['unfreeze'] = $args[$i] ?? null; } elseif ($arg === '--destroy') { $i++; $opts['destroy'] = $args[$i] ?? null; } elseif ($arg === '--lock') { $i++; $opts['lock'] = $args[$i] ?? null; } elseif ($arg === '--unlock') { $i++; $opts['unlock'] = $args[$i] ?? null; } elseif ($arg === '--resize') { $i++; $opts['resize'] = $args[$i] ?? null; } elseif ($arg === '--redeploy') { $i++; $opts['redeploy'] = $args[$i] ?? null; } elseif ($arg === '--execute') { $i++; $opts['execute'] = $args[$i] ?? null; // Next argument is the command $i++; $opts['execute_cmd'] = $args[$i] ?? ''; } elseif ($arg === '--snapshot') { $i++; $opts['snapshot'] = $args[$i] ?? null; } elseif ($arg === '--snapshot-name') { $i++; $opts['snapshot_name'] = $args[$i] ?? null; } elseif ($arg === '--unfreeze-on-demand') { $opts['unfreeze_on_demand'] = true; } elseif ($arg === '--set-unfreeze-on-demand') { $i++; $opts['set_unfreeze_on_demand'] = $args[$i] ?? null; $i++; $opts['set_unfreeze_on_demand_enabled'] = $args[$i] ?? null; } $i++; } return $opts; } /** * Handle service env subcommand. * * @param array $args Positional arguments * @param array $opts Global options */ private function cliHandleServiceEnv(array $args, array $opts): void { if (empty($args)) { $this->cliError("Usage: service env "); exit(2); } $action = $args[0]; $serviceId = $args[1] ?? null; if (empty($serviceId)) { $this->cliError("Service ID required"); exit(2); } switch ($action) { case 'status': $result = $this->getServiceEnv($serviceId); echo "Has vault: " . ($result['has_vault'] ? 'yes' : 'no') . "\n"; if (isset($result['count'])) { echo "Variables: " . $result['count'] . "\n"; } if (isset($result['updated_at'])) { echo "Updated: " . $result['updated_at'] . "\n"; } break; case 'set': // Read from stdin or --env-file $envContent = ''; if (stream_isatty(STDIN)) { $this->cliError("Provide env content via stdin or --env-file"); exit(2); } else { $envContent = file_get_contents('php://stdin'); } $result = $this->setServiceEnv($serviceId, $envContent); echo "Environment vault updated\n"; break; case 'export': $result = $this->exportServiceEnv($serviceId); echo $result['env'] ?? ''; break; case 'delete': if (!$opts['yes']) { fwrite(STDERR, "Warning: This will delete the environment vault. Use -y to confirm.\n"); exit(2); } $result = $this->deleteServiceEnv($serviceId); echo "Environment vault deleted\n"; break; default: $this->cliError("Unknown env action: {$action}"); exit(2); } } /** * Handle snapshot subcommand. * * @param array $args Positional arguments * @param array $opts Global options */ private function cliHandleSnapshot(array $args, array $opts): void { $snapshotOpts = $this->cliParseSnapshotOptions($args); if ($snapshotOpts['list']) { $snapshots = $this->listSnapshots(); $this->cliPrintList($snapshots, 'snapshot'); return; } if ($snapshotOpts['info']) { // Get snapshot info via restore endpoint without actually restoring $snapshots = $this->listSnapshots(); foreach ($snapshots as $snapshot) { if (($snapshot['snapshot_id'] ?? $snapshot['id'] ?? '') === $snapshotOpts['info']) { $this->cliPrintSnapshotInfo($snapshot); return; } } $this->cliError("Snapshot not found: " . $snapshotOpts['info']); exit(4); } if ($snapshotOpts['delete']) { if (!$opts['yes']) { fwrite(STDERR, "Warning: This will permanently delete the snapshot. Use -y to confirm.\n"); exit(2); } $result = $this->deleteSnapshot($snapshotOpts['delete']); echo "Snapshot deleted: " . $snapshotOpts['delete'] . "\n"; return; } if ($snapshotOpts['lock']) { $result = $this->lockSnapshot($snapshotOpts['lock']); echo "Snapshot locked: " . $snapshotOpts['lock'] . "\n"; return; } if ($snapshotOpts['unlock']) { $result = $this->unlockSnapshot($snapshotOpts['unlock']); echo "Snapshot unlocked: " . $snapshotOpts['unlock'] . "\n"; return; } if ($snapshotOpts['clone']) { $cloneOpts = []; if (!empty($snapshotOpts['type'])) { $cloneOpts['type'] = $snapshotOpts['type']; } if (!empty($snapshotOpts['shell'])) { $cloneOpts['shell'] = $snapshotOpts['shell']; } if (!empty($snapshotOpts['ports'])) { $cloneOpts['ports'] = explode(',', $snapshotOpts['ports']); } $result = $this->cloneSnapshot($snapshotOpts['clone'], $snapshotOpts['name'], null, null, $cloneOpts); echo "Snapshot cloned:\n"; echo json_encode($result, JSON_PRETTY_PRINT) . "\n"; return; } $this->cliError("No action specified for snapshot command. Use --list, --info, --delete, --clone, etc."); exit(2); } /** * Parse snapshot-specific options. * * @param array $args Arguments to parse * @return array Parsed options */ private function cliParseSnapshotOptions(array $args): array { $opts = [ 'list' => false, 'info' => null, 'delete' => null, 'lock' => null, 'unlock' => null, 'clone' => null, 'type' => null, 'name' => null, 'shell' => null, 'ports' => null, ]; $i = 0; while ($i < count($args)) { $arg = $args[$i]; if ($arg === '--list' || $arg === '-l') { $opts['list'] = true; } elseif ($arg === '--info') { $i++; $opts['info'] = $args[$i] ?? null; } elseif ($arg === '--delete') { $i++; $opts['delete'] = $args[$i] ?? null; } elseif ($arg === '--lock') { $i++; $opts['lock'] = $args[$i] ?? null; } elseif ($arg === '--unlock') { $i++; $opts['unlock'] = $args[$i] ?? null; } elseif ($arg === '--clone') { $i++; $opts['clone'] = $args[$i] ?? null; } elseif ($arg === '--type') { $i++; $opts['type'] = $args[$i] ?? null; } elseif ($arg === '--name') { $i++; $opts['name'] = $args[$i] ?? null; } elseif ($arg === '--shell') { $i++; $opts['shell'] = $args[$i] ?? null; } elseif ($arg === '--ports') { $i++; $opts['ports'] = $args[$i] ?? null; } $i++; } return $opts; } /** * Handle image subcommand. * * @param array $args Command arguments * @param array $opts Global options */ private function cliHandleImage(array $args, array $opts): void { $imageOpts = $this->cliParseImageOptions($args); if ($imageOpts['list']) { $images = $this->listImages(); $this->cliPrintImageList($images); return; } if ($imageOpts['info']) { $image = $this->getImage($imageOpts['info']); $this->cliPrintImageInfo($image); return; } if ($imageOpts['delete']) { if (!$opts['yes']) { fwrite(STDERR, "Warning: This will permanently delete the image. Use -y to confirm.\n"); exit(2); } $result = $this->deleteImage($imageOpts['delete']); echo "Image deleted: " . $imageOpts['delete'] . "\n"; return; } if ($imageOpts['lock']) { $result = $this->lockImage($imageOpts['lock']); echo "Image locked: " . $imageOpts['lock'] . "\n"; return; } if ($imageOpts['unlock']) { $result = $this->unlockImage($imageOpts['unlock']); echo "Image unlocked: " . $imageOpts['unlock'] . "\n"; return; } if ($imageOpts['publish']) { if (empty($imageOpts['sourceType'])) { $this->cliError("--source-type required for --publish"); exit(2); } $result = $this->imagePublish($imageOpts['sourceType'], $imageOpts['publish'], $imageOpts['name']); $imageId = $result['image_id'] ?? $result['id'] ?? ''; echo "Image published: {$imageId}\n"; return; } if ($imageOpts['visibility'] && $imageOpts['visibilityMode']) { if (!in_array($imageOpts['visibilityMode'], ['private', 'unlisted', 'public'])) { $this->cliError("visibility must be private, unlisted, or public"); exit(2); } $result = $this->setImageVisibility($imageOpts['visibility'], $imageOpts['visibilityMode']); echo "Image {$imageOpts['visibility']} visibility set to {$imageOpts['visibilityMode']}\n"; return; } if ($imageOpts['spawn']) { if (empty($imageOpts['name'])) { $this->cliError("--name required for --spawn"); exit(2); } $ports = null; if (!empty($imageOpts['ports'])) { $ports = array_map('intval', explode(',', $imageOpts['ports'])); } $result = $this->spawnFromImage($imageOpts['spawn'], $imageOpts['name'], $ports); $serviceId = $result['service_id'] ?? $result['id'] ?? ''; echo "Service spawned: {$serviceId}\n"; return; } if ($imageOpts['clone']) { $result = $this->cloneImage($imageOpts['clone'], $imageOpts['name']); $imageId = $result['image_id'] ?? $result['id'] ?? ''; echo "Image cloned: {$imageId}\n"; return; } $this->cliError("No action specified for image command. Use --list, --info, --delete, --publish, --spawn, --clone, etc."); exit(2); } /** * Parse image-specific options. * * @param array $args Arguments to parse * @return array Parsed options */ private function cliParseImageOptions(array $args): array { $opts = [ 'list' => false, 'info' => null, 'delete' => null, 'lock' => null, 'unlock' => null, 'publish' => null, 'sourceType' => null, 'visibility' => null, 'visibilityMode' => null, 'spawn' => null, 'clone' => null, 'name' => null, 'ports' => null, ]; $i = 0; while ($i < count($args)) { $arg = $args[$i]; if ($arg === '--list' || $arg === '-l') { $opts['list'] = true; } elseif ($arg === '--info') { $i++; $opts['info'] = $args[$i] ?? null; } elseif ($arg === '--delete') { $i++; $opts['delete'] = $args[$i] ?? null; } elseif ($arg === '--lock') { $i++; $opts['lock'] = $args[$i] ?? null; } elseif ($arg === '--unlock') { $i++; $opts['unlock'] = $args[$i] ?? null; } elseif ($arg === '--publish') { $i++; $opts['publish'] = $args[$i] ?? null; } elseif ($arg === '--source-type') { $i++; $opts['sourceType'] = $args[$i] ?? null; } elseif ($arg === '--visibility') { $i++; $opts['visibility'] = $args[$i] ?? null; // Check if next arg is the mode (not a flag) if (isset($args[$i + 1]) && !str_starts_with($args[$i + 1], '-')) { $i++; $opts['visibilityMode'] = $args[$i]; } } elseif ($arg === '--spawn') { $i++; $opts['spawn'] = $args[$i] ?? null; } elseif ($arg === '--clone') { $i++; $opts['clone'] = $args[$i] ?? null; } elseif ($arg === '--name') { $i++; $opts['name'] = $args[$i] ?? null; } elseif ($arg === '--ports') { $i++; $opts['ports'] = $args[$i] ?? null; } $i++; } return $opts; } /** * Print images list in tabular format. * * @param array $images Images to print */ private function cliPrintImageList(array $images): void { if (empty($images)) { echo "No images found.\n"; return; } printf("%-38s %-20s %-10s %-10s %s\n", 'ID', 'NAME', 'VISIBILITY', 'SOURCE', 'CREATED'); foreach ($images as $image) { printf( "%-38s %-20s %-10s %-10s %s\n", substr($image['image_id'] ?? $image['id'] ?? '-', 0, 38), substr($image['name'] ?? '-', 0, 20), substr($image['visibility'] ?? 'private', 0, 10), substr($image['source_type'] ?? '-', 0, 10), $image['created_at'] ?? '-' ); } } /** * Print image info. * * @param array $image Image data */ private function cliPrintImageInfo(array $image): void { echo "ID: " . ($image['image_id'] ?? $image['id'] ?? '-') . "\n"; if (!empty($image['name'])) { echo "Name: " . $image['name'] . "\n"; } if (!empty($image['visibility'])) { echo "Visibility: " . $image['visibility'] . "\n"; } if (!empty($image['source_type'])) { echo "Source Type: " . $image['source_type'] . "\n"; } if (!empty($image['source_id'])) { echo "Source ID: " . $image['source_id'] . "\n"; } if (!empty($image['size'])) { echo "Size: " . $image['size'] . "\n"; } if (isset($image['locked'])) { echo "Locked: " . ($image['locked'] ? 'yes' : 'no') . "\n"; } if (!empty($image['created_at'])) { echo "Created: " . $image['created_at'] . "\n"; } } /** * Handle key subcommand. * * @param array $opts Global options */ private function cliHandleKey(array $opts): void { $result = $this->validateKeys(); echo "API Key Valid: " . ($result['valid'] ? 'yes' : 'no') . "\n"; if (isset($result['account_id'])) { echo "Account ID: " . $result['account_id'] . "\n"; } if (isset($result['email'])) { echo "Email: " . $result['email'] . "\n"; } } /** * Handle languages command. * * @param array $args Command arguments * @param array $opts Global options */ private function cliHandleLanguages(array $args, array $opts): void { // Check for --json flag $jsonOutput = in_array('--json', $args); $languages = $this->getLanguages(); if ($jsonOutput) { // Output as JSON array echo json_encode($languages) . "\n"; } else { // Output one language per line (pipe-friendly) foreach ($languages as $lang) { echo $lang . "\n"; } } } /** * Print list of resources in tabular format. * * @param array $items Items to print * @param string $type Resource type (session, service, snapshot) */ private function cliPrintList(array $items, string $type): void { if (empty($items)) { echo "No {$type}s found.\n"; return; } // Print header printf("%-40s %-20s %-12s %s\n", 'ID', 'NAME', 'STATUS', 'CREATED'); echo str_repeat('-', 90) . "\n"; foreach ($items as $item) { $id = $item["{$type}_id"] ?? $item['id'] ?? 'N/A'; $name = $item['name'] ?? 'N/A'; $status = $item['status'] ?? 'N/A'; $created = $item['created_at'] ?? $item['created'] ?? 'N/A'; printf("%-40s %-20s %-12s %s\n", $id, $name, $status, $created); } } /** * Print session information. * * @param array $session Session data */ private function cliPrintSessionInfo(array $session): void { echo "Session ID: " . ($session['session_id'] ?? $session['id'] ?? 'N/A') . "\n"; if (isset($session['container_name'])) { echo "Container: " . $session['container_name'] . "\n"; } if (isset($session['status'])) { echo "Status: " . $session['status'] . "\n"; } if (isset($session['shell'])) { echo "Shell: " . $session['shell'] . "\n"; } if (isset($session['network_mode'])) { echo "Network: " . $session['network_mode'] . "\n"; } if (isset($session['websocket_url'])) { echo "WebSocket URL: " . $session['websocket_url'] . "\n"; } } /** * Print service information. * * @param array $service Service data */ private function cliPrintServiceInfo(array $service): void { echo "Service ID: " . ($service['service_id'] ?? $service['id'] ?? 'N/A') . "\n"; if (isset($service['name'])) { echo "Name: " . $service['name'] . "\n"; } if (isset($service['status'])) { echo "Status: " . $service['status'] . "\n"; } if (isset($service['ports'])) { echo "Ports: " . (is_array($service['ports']) ? implode(', ', $service['ports']) : $service['ports']) . "\n"; } if (isset($service['url'])) { echo "URL: " . $service['url'] . "\n"; } if (isset($service['network_mode'])) { echo "Network: " . $service['network_mode'] . "\n"; } } /** * Print snapshot information. * * @param array $snapshot Snapshot data */ private function cliPrintSnapshotInfo(array $snapshot): void { echo "Snapshot ID: " . ($snapshot['snapshot_id'] ?? $snapshot['id'] ?? 'N/A') . "\n"; if (isset($snapshot['name'])) { echo "Name: " . $snapshot['name'] . "\n"; } if (isset($snapshot['type'])) { echo "Type: " . $snapshot['type'] . "\n"; } if (isset($snapshot['status'])) { echo "Status: " . $snapshot['status'] . "\n"; } if (isset($snapshot['size'])) { echo "Size: " . $snapshot['size'] . "\n"; } if (isset($snapshot['locked'])) { echo "Locked: " . ($snapshot['locked'] ? 'yes' : 'no') . "\n"; } if (isset($snapshot['created_at'])) { echo "Created: " . $snapshot['created_at'] . "\n"; } } /** * Print error message to stderr. * * @param string $message Error message */ private function cliError(string $message): void { fwrite(STDERR, "Error: {$message}\n"); } /** * Show CLI help. */ private function cliShowHelp(): void { $help = << php un.php -s '' php un.php session [options] php un.php service [options] php un.php snapshot [options] php un.php key php un.php languages [--json] GLOBAL OPTIONS: -s, --shell LANG Language for inline code execution -e, --env KEY=VAL Set environment variable (can be repeated) -f, --file FILE Add input file to /tmp/ (can be repeated) -F, --file-path FILE Add input file preserving path (can be repeated) -a, --artifacts Return compiled artifacts -o, --output DIR Output directory for artifacts -p, --public-key KEY API public key -k, --secret-key KEY API secret key -n, --network MODE Network mode: zerotrust (default) or semitrusted -v, --vcpu N vCPU count (1-8) -y, --yes Skip confirmation prompts -h, --help Show this help COMMANDS: Execute code file (default command) session Manage interactive sessions service Manage persistent services snapshot Manage snapshots key Validate API key languages [--json] List available languages SESSION OPTIONS: --list, -l List active sessions --attach ID Reconnect to session --kill ID Terminate session --freeze ID Pause session --unfreeze ID Resume session --boost ID Boost session resources --unboost ID Remove session boost --snapshot ID Create session snapshot --snapshot-name NAME Name for snapshot --hot Hot snapshot (no freeze) --tmux Use tmux for persistence --screen Use screen for persistence --shell SHELL Shell/REPL to use SERVICE OPTIONS: --list, -l List all services --name NAME Create service with name --ports PORTS Ports to expose (comma-separated) --domains DOMAINS Custom domains (comma-separated) --type TYPE Service type (minecraft, tcp, udp) --bootstrap CMD Bootstrap command --bootstrap-file FILE Bootstrap from file --env-file FILE Load environment from file --info ID Get service details --logs ID Get all service logs --tail ID Get last 9000 lines of logs --freeze ID Pause service --unfreeze ID Resume service --destroy ID Delete service (requires -y) --lock ID Prevent service deletion --unlock ID Allow service deletion --resize ID Resize service (with --vcpu) --redeploy ID Re-run bootstrap --execute ID 'cmd' Execute command in service --snapshot ID Create service snapshot SERVICE ENV SUBCOMMAND: service env status ID Show vault status service env set ID Set vault (stdin or --env-file) service env export ID Export vault to stdout service env delete ID Delete vault (requires -y) SNAPSHOT OPTIONS: --list, -l List all snapshots --info ID Get snapshot details --delete ID Delete snapshot (requires -y) --lock ID Prevent snapshot deletion --unlock ID Allow snapshot deletion --clone ID Clone snapshot --type TYPE Clone type: session or service --name NAME Name for cloned resource --shell SHELL Shell for cloned session --ports PORTS Ports for cloned service EXAMPLES: php un.php script.py php un.php -s bash 'echo hello' php un.php -n semitrusted crawler.py php un.php session --shell python3 --tmux php un.php session --list php un.php service --name myapp --ports 80 --bootstrap 'python -m http.server 80' php un.php service --execute 'ls -la' php un.php snapshot --list php un.php key HELP; echo $help; } } // CLI entry point if (php_sapi_name() === 'cli' && basename(__FILE__) === basename($argv[0])) { $client = new Unsandbox(); $client->cliMain($argv); }