un-inception/clients/php/sync/src/un.php
russell@unturf.com 369f3aa39d Support -f on service create and redeploy across all SDKs
Add input_files support to service redeploy for all 12 SDK
implementations that have service commands. Files passed via -f are
read, base64-encoded, and sent as input_files in the JSON payload.
Service create also gains -f support where it was missing.

Updated: bash, cpp, csharp, dotnet, go, java, javascript, perl,
php, ruby, rust, typescript
2026-03-02 06:13:43 -05:00

3537 lines
128 KiB
PHP

#!/usr/bin/env php
<?php
/**
* PUBLIC DOMAIN - NO LICENSE, NO WARRANTY
*
* unsandbox.com PHP SDK (Synchronous)
*
* Library Usage:
* require_once 'un.php';
* use Unsandbox\Unsandbox;
*
* $client = new Unsandbox();
*
* // Execute code synchronously
* $result = $client->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 <public_key>
* X-Timestamp: <unix_seconds>
* 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
* @param array $opts Optional parameters: 'input_files'
* @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 $opts = []): array {
[$publicKey, $secretKey] = $this->resolveCredentials($publicKey, $secretKey);
$data = [];
if (isset($opts['input_files'])) {
$data['input_files'] = $opts['input_files'];
}
return $this->makeRequest('POST', "/services/{$serviceId}/redeploy", $publicKey, $secretKey, $data);
}
/**
* 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']) {
$redeployOpts = [];
$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,
];
}
}
if (!empty($inputFiles)) {
$redeployOpts['input_files'] = $inputFiles;
}
$result = $this->redeployService($serviceOpts['redeploy'], null, null, $redeployOpts);
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 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,
];
}
}
if (!empty($inputFiles)) {
$createOpts['input_files'] = $inputFiles;
}
// 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 <status|set|export|delete> <service_id>");
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 = <<<HELP
Unsandbox PHP CLI
USAGE:
php un.php [options] <source_file>
php un.php -s <language> '<code>'
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:
<file> 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 (supports -f/-F for input files)
--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 <id> '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);
}