Remove bad claude code, start from scratch with lume provider

This commit is contained in:
Morgan Dean
2025-06-17 13:38:32 -04:00
parent fc41333894
commit f6bc547579
33 changed files with 1948 additions and 2615 deletions
@@ -1,705 +0,0 @@
import type { Display, ComputerConfig } from "../types";
import type { BaseComputerInterface } from "../interface/base";
import { InterfaceFactory } from "../interface/factory";
import type { BaseVMProvider } from "../providers/base";
import { VMProviderType } from "../providers/base";
import { VMProviderFactory } from "../providers/factory";
import pino from "pino";
import {
recordComputerInitialization,
recordVMStart,
recordVMStop,
} from "../telemetry";
import { setDefaultComputer } from "../helpers";
import {
parseDisplayString,
parseImageString,
sleep,
withTimeout,
} from "../utils";
import sharp from "sharp";
export type OSType = "macos" | "linux" | "windows";
export interface ComputerOptions {
display?: Display | { width: number; height: number } | string;
memory?: string;
cpu?: string;
osType?: OSType;
name?: string;
image?: string;
sharedDirectories?: string[];
useHostComputerServer?: boolean;
verbosity?: pino.Level;
telemetryEnabled?: boolean;
providerType?: VMProviderType | string;
port?: number;
noVNCPort?: number;
host?: string;
storage?: string;
ephemeral?: boolean;
apiKey?: string;
experiments?: string[];
}
/**
* Computer is the main class for interacting with the computer.
*/
export class Computer {
private logger: pino.Logger;
private image: string;
private port?: number;
private noVNCPort?: number;
private host: string;
private osType: OSType;
private providerType: VMProviderType | string;
private ephemeral: boolean;
private apiKey?: string;
private experiments: string[];
private storage?: string;
private sharedPath?: string;
private sharedDirectories: string[];
private _telemetryEnabled: boolean;
private _initialized: boolean = false;
private _running: boolean = false;
private useHostComputerServer: boolean;
private config?: ComputerConfig;
private _providerContext?: BaseVMProvider;
private _interface?: BaseComputerInterface;
private _stopEvent?: Promise<void>;
private _keepAliveTask?: Promise<void>;
/**
* Initialize a new Computer instance.
*
* @param options Configuration options for the Computer
*/
constructor(options: ComputerOptions = {}) {
const {
display = "1024x768",
memory = "8GB",
cpu = "4",
osType = "macos",
name = "",
image = "macos-sequoia-cua:latest",
sharedDirectories = [],
useHostComputerServer = false,
verbosity = "info",
telemetryEnabled = true,
providerType = VMProviderType.LUME,
port = 7777,
noVNCPort = 8006,
host = process.env.PYLUME_HOST || "localhost",
storage,
ephemeral = false,
apiKey,
experiments = [],
} = options;
this.logger = pino({ name: "cua.computer", level: verbosity });
this.logger.info("Initializing Computer...");
// Store original parameters
this.image = image;
this.port = port;
this.noVNCPort = noVNCPort;
this.host = host;
this.osType = osType;
this.providerType = providerType;
this.ephemeral = ephemeral;
this.apiKey = apiKey;
this.experiments = experiments;
if (this.experiments.includes("app-use")) {
if (this.osType !== "macos") {
throw new Error("App use experiment is only supported on macOS");
}
}
// The default is currently to use non-ephemeral storage
if (storage && ephemeral && storage !== "ephemeral") {
throw new Error(
"Storage path and ephemeral flag cannot be used together"
);
}
this.storage = ephemeral ? "ephemeral" : storage;
// For Lumier provider, store the first shared directory path to use
// for VM file sharing
this.sharedPath = undefined;
if (sharedDirectories && sharedDirectories.length > 0) {
this.sharedPath = sharedDirectories[0];
this.logger.info(
`Using first shared directory for VM file sharing: ${this.sharedPath}`
);
}
// Store telemetry preference
this._telemetryEnabled = telemetryEnabled;
this.useHostComputerServer = useHostComputerServer;
if (!useHostComputerServer) {
const imageInfo = parseImageString(image);
const vmName = name || image.replace(":", "_");
// Convert display parameter to Display object
let displayConfig: Display;
if (typeof display === "string") {
const { width, height } = parseDisplayString(display);
displayConfig = { width, height };
} else if ("width" in display && "height" in display) {
displayConfig = display as Display;
} else {
displayConfig = display as Display;
}
this.config = {
image: imageInfo.name,
tag: imageInfo.tag,
name: vmName,
display: displayConfig,
memory,
cpu,
};
}
// Store shared directories config
this.sharedDirectories = sharedDirectories;
// Record initialization in telemetry (if enabled)
if (telemetryEnabled) {
recordComputerInitialization();
} else {
this.logger.debug(
"Telemetry disabled - skipping initialization tracking"
);
}
}
/**
* Create a virtual desktop from a list of app names, returning a DioramaComputer
* that proxies Diorama.Interface but uses diorama_cmds via the computer interface.
*
* @param apps List of application names to include in the desktop.
* @returns A proxy object with the Diorama interface, but using diorama_cmds.
*/
createDesktopFromApps(apps: string[]): any {
if (!this.experiments.includes("app-use")) {
throw new Error(
"App Usage is an experimental feature. Enable it by passing experiments=['app-use'] to Computer()"
);
}
// DioramaComputer would be imported and used here
throw new Error("DioramaComputer not yet implemented");
}
/**
* Start the computer (async context manager enter).
*/
async __aenter__(): Promise<this> {
await this.run();
return this;
}
/**
* Stop the computer (async context manager exit).
*/
async __aexit__(excType: any, excVal: any, excTb: any): Promise<void> {
await this.disconnect();
}
/**
* Initialize the VM and computer interface.
*/
async run(): Promise<string | undefined> {
// If already initialized, just log and return
if (this._initialized) {
this.logger.info("Computer already initialized, skipping initialization");
return;
}
this.logger.info("Starting computer...");
const startTime = Date.now();
try {
let ipAddress: string;
// If using host computer server
if (this.useHostComputerServer) {
this.logger.info("Using host computer server");
ipAddress = "localhost";
// Create the interface
this._interface = InterfaceFactory.createInterfaceForOS(
this.osType,
ipAddress
);
this.logger.info("Waiting for host computer server to be ready...");
await this._interface.waitForReady();
this.logger.info("Host computer server ready");
} else {
// Start or connect to VM
this.logger.info(`Starting VM: ${this.image}`);
if (!this._providerContext) {
try {
const providerTypeName =
typeof this.providerType === "object"
? this.providerType
: this.providerType;
this.logger.info(
`Initializing ${providerTypeName} provider context...`
);
// Create VM provider instance with explicit parameters
const providerOptions = {
port: this.port,
host: this.host,
storage: this.storage,
sharedPath: this.sharedPath,
image: this.image,
verbose:
this.logger.level === "debug" || this.logger.level === "trace",
ephemeral: this.ephemeral,
noVNCPort: this.noVNCPort,
apiKey: this.apiKey,
};
if (!this.config) {
throw new Error("Computer config not initialized");
}
this.config.vm_provider = await VMProviderFactory.createProvider(
this.providerType,
providerOptions
);
this._providerContext = await this.config.vm_provider.__aenter__();
this.logger.debug("VM provider context initialized successfully");
} catch (error) {
this.logger.error(
`Failed to import provider dependencies: ${error}`
);
throw error;
}
}
// Run the VM
if (!this.config || !this.config.vm_provider) {
throw new Error("VM provider not initialized");
}
const runOpts = {
display: this.config.display,
memory: this.config.memory,
cpu: this.config.cpu,
shared_directories: this.sharedDirectories,
};
this.logger.info(
`Running VM ${this.config.name} with options:`,
runOpts
);
if (this._telemetryEnabled) {
recordVMStart(this.config.name, String(this.providerType));
}
const storageParam = this.ephemeral ? "ephemeral" : this.storage;
try {
await this.config.vm_provider.runVM(
this.image,
this.config.name,
runOpts,
storageParam
);
} catch (error: any) {
if (error.message?.includes("already running")) {
this.logger.info(`VM ${this.config.name} is already running`);
} else {
throw error;
}
}
// Wait for VM to be ready
try {
this.logger.info("Waiting for VM to be ready...");
await this.waitVMReady();
// Get IP address
ipAddress = await this.getIP();
this.logger.info(`VM is ready with IP: ${ipAddress}`);
} catch (error) {
this.logger.error(`Error waiting for VM: ${error}`);
throw new Error(`VM failed to become ready: ${error}`);
}
}
// Initialize the interface
try {
// Verify we have a valid IP before initializing the interface
if (!ipAddress || ipAddress === "unknown" || ipAddress === "0.0.0.0") {
throw new Error(
`Cannot initialize interface - invalid IP address: ${ipAddress}`
);
}
this.logger.info(
`Initializing interface for ${this.osType} at ${ipAddress}`
);
// Pass authentication credentials if using cloud provider
if (
this.providerType === VMProviderType.CLOUD &&
this.apiKey &&
this.config?.name
) {
this._interface = InterfaceFactory.createInterfaceForOS(
this.osType,
ipAddress,
this.apiKey,
this.config.name
);
} else {
this._interface = InterfaceFactory.createInterfaceForOS(
this.osType,
ipAddress
);
}
// Wait for the WebSocket interface to be ready
this.logger.info("Connecting to WebSocket interface...");
try {
await withTimeout(
this._interface.waitForReady(),
30000,
`Could not connect to WebSocket interface at ${ipAddress}:8000/ws`
);
this.logger.info("WebSocket interface connected successfully");
} catch (error) {
this.logger.error(
`Failed to connect to WebSocket interface at ${ipAddress}`
);
throw error;
}
// Create an event to keep the VM running in background if needed
if (!this.useHostComputerServer) {
// In TypeScript, we'll use a Promise instead of asyncio.Event
let resolveStop: () => void;
this._stopEvent = new Promise<void>((resolve) => {
resolveStop = resolve;
});
this._keepAliveTask = this._stopEvent;
}
this.logger.info("Computer is ready");
// Set the initialization flag
this._initialized = true;
// Set this instance as the default computer for remote decorators
setDefaultComputer(this);
this.logger.info("Computer successfully initialized");
} catch (error) {
throw error;
} finally {
// Log initialization time for performance monitoring
const durationMs = Date.now() - startTime;
this.logger.debug(
`Computer initialization took ${durationMs.toFixed(2)}ms`
);
}
} catch (error) {
this.logger.error(`Failed to initialize computer: ${error}`);
throw new Error(`Failed to initialize computer: ${error}`);
}
return;
}
/**
* Disconnect from the computer's WebSocket interface.
*/
async disconnect(): Promise<void> {
if (this._interface) {
// Note: The interface close method would need to be implemented
// this._interface.close();
}
}
/**
* Disconnect from the computer's WebSocket interface and stop the computer.
*/
async stop(): Promise<void> {
const startTime = Date.now();
try {
this.logger.info("Stopping Computer...");
// In VM mode, first explicitly stop the VM, then exit the provider context
if (
!this.useHostComputerServer &&
this._providerContext &&
this.config?.vm_provider
) {
try {
this.logger.info(`Stopping VM ${this.config.name}...`);
await this.config.vm_provider.stopVM(this.config.name, this.storage);
} catch (error) {
this.logger.error(`Error stopping VM: ${error}`);
}
this.logger.info("Closing VM provider context...");
await this.config.vm_provider.__aexit__(null, null, null);
this._providerContext = undefined;
}
await this.disconnect();
this.logger.info("Computer stopped");
} catch (error) {
this.logger.debug(`Error during cleanup: ${error}`);
} finally {
// Log stop time for performance monitoring
const durationMs = Date.now() - startTime;
this.logger.debug(
`Computer stop process took ${durationMs.toFixed(2)}ms`
);
if (this._telemetryEnabled && this.config?.name) {
recordVMStop(this.config.name, durationMs);
}
}
}
/**
* Get the IP address of the VM or localhost if using host computer server.
*/
async getIP(
maxRetries: number = 15,
retryDelay: number = 2
): Promise<string> {
// For host computer server, always return localhost immediately
if (this.useHostComputerServer) {
return "127.0.0.1";
}
// Get IP from the provider
if (!this.config?.vm_provider) {
throw new Error("VM provider is not initialized");
}
// Log that we're waiting for the IP
this.logger.info(
`Waiting for VM ${this.config.name} to get an IP address...`
);
// Call the provider's get_ip method which will wait indefinitely
const storageParam = this.ephemeral ? "ephemeral" : this.storage;
// Log the image being used
this.logger.info(`Running VM using image: ${this.image}`);
// Call provider.getIP with explicit parameters
const ip = await this.config.vm_provider.getIP(
this.config.name,
storageParam,
retryDelay
);
// Log success
this.logger.info(`VM ${this.config.name} has IP address: ${ip}`);
return ip;
}
/**
* Wait for VM to be ready with an IP address.
*/
async waitVMReady(): Promise<Record<string, any> | undefined> {
if (this.useHostComputerServer) {
return undefined;
}
const timeout = 600; // 10 minutes timeout
const interval = 2.0; // 2 seconds between checks
const startTime = Date.now() / 1000;
let lastStatus: string | undefined;
let attempts = 0;
this.logger.info(
`Waiting for VM ${this.config?.name} to be ready (timeout: ${timeout}s)...`
);
while (Date.now() / 1000 - startTime < timeout) {
attempts++;
const elapsed = Date.now() / 1000 - startTime;
try {
// Keep polling for VM info
if (!this.config?.vm_provider) {
this.logger.error("VM provider is not initialized");
return undefined;
}
const vm = await this.config.vm_provider.getVM(this.config.name);
// Log full VM properties for debugging (every 30 attempts)
if (attempts % 30 === 0) {
this.logger.info(
`VM properties at attempt ${attempts}: ${JSON.stringify(vm)}`
);
}
// Get current status for logging
const currentStatus = vm?.status;
if (currentStatus !== lastStatus) {
this.logger.info(
`VM status changed to: ${currentStatus} (after ${elapsed.toFixed(
1
)}s)`
);
lastStatus = currentStatus;
}
// Check if VM is ready
if (
vm &&
vm.status === "running" &&
vm.ip_address &&
vm.ip_address !== "0.0.0.0"
) {
this.logger.info(
`VM ${this.config.name} is ready with IP: ${vm.ip_address}`
);
return vm;
}
// Wait before next check
await sleep(interval * 1000);
} catch (error) {
this.logger.error(`Error checking VM status: ${error}`);
await sleep(interval * 1000);
}
}
throw new Error(
`VM ${this.config?.name} failed to become ready within ${timeout} seconds`
);
}
/**
* Update VM settings.
*/
async update(cpu?: number, memory?: string): Promise<void> {
if (this.useHostComputerServer) {
this.logger.warn("Cannot update settings for host computer server");
return;
}
if (!this.config?.vm_provider) {
throw new Error("VM provider is not initialized");
}
await this.config.vm_provider.updateVM(
this.config.name,
cpu,
memory,
this.storage
);
}
/**
* Get the dimensions of a screenshot.
*/
async getScreenshotSize(
screenshot: Buffer
): Promise<{ width: number; height: number }> {
const metadata = await sharp(screenshot).metadata();
return {
width: metadata.width || 0,
height: metadata.height || 0,
};
}
/**
* Get the computer interface for interacting with the VM.
*/
get interface(): BaseComputerInterface {
if (!this._interface) {
throw new Error("Computer interface not initialized. Call run() first.");
}
return this._interface;
}
/**
* Check if telemetry is enabled for this computer instance.
*/
get telemetryEnabled(): boolean {
return this._telemetryEnabled;
}
/**
* Convert normalized coordinates to screen coordinates.
*/
toScreenCoordinates(x: number, y: number): [number, number] {
if (!this.config?.display) {
throw new Error("Display configuration not available");
}
return [x * this.config.display.width, y * this.config.display.height];
}
/**
* Convert screen coordinates to screenshot coordinates.
*/
async toScreenshotCoordinates(
x: number,
y: number
): Promise<[number, number]> {
// In the Python version, this uses the interface to get screenshot dimensions
// For now, we'll assume 1:1 mapping
return [x, y];
}
/**
* Install packages in a virtual environment.
*/
async venvInstall(
venvName: string,
requirements: string[]
): Promise<[string, string]> {
// This would be implemented using the interface to run commands
// TODO: Implement venvInstall
throw new Error("venvInstall not yet implemented");
}
/**
* Execute a shell command in a virtual environment.
*/
async venvCmd(venvName: string, command: string): Promise<[string, string]> {
// This would be implemented using the interface to run commands
// TODO: Implement venvCmd
throw new Error("venvCmd not yet implemented");
}
/**
* Execute function in a virtual environment using source code extraction.
*/
async venvExec(
venvName: string,
pythonFunc: Function,
...args: any[]
): Promise<any> {
// This would be implemented using the interface to run Python code
// TODO: Implement venvExec
throw new Error("venvExec not yet implemented");
}
}
@@ -0,0 +1,36 @@
import { OSType, VMProviderType } from "./types";
import type { BaseComputerConfig, Display } from "./types";
/**
* Default configuration values for Computer
*/
export const DEFAULT_CONFIG: Partial<BaseComputerConfig> = {
name: "",
osType: OSType.MACOS,
vmProvider: VMProviderType.LUME,
display: "1024x768",
memory: "8GB",
cpu: 4,
image: "macos-sequoia-cua:latest",
sharedDirectories: [],
useHostComputerServer: false,
telemetryEnabled: true,
port: 7777,
noVNCPort: 8006,
host: "localhost",
ephemeral: false,
};
/**
* Apply default values to a computer configuration
* @param config Partial configuration
* @returns Complete configuration with defaults applied
*/
export function applyDefaults<T extends BaseComputerConfig>(
config: Partial<T>
): T {
return {
...DEFAULT_CONFIG,
...config,
} as T;
}
+49 -2
View File
@@ -1,2 +1,49 @@
// Re-export the Computer class and related types
export * from './computer';
import { LumierComputer } from "./providers/lumier";
import type { BaseComputer } from "./providers/base";
import { CloudComputer } from "./providers/cloud";
import { LumeComputer } from "./providers/lume";
import {
VMProviderType,
type BaseComputerConfig,
type CloudComputerConfig,
type LumeComputerConfig,
type LumierComputerConfig,
} from "./types";
import { applyDefaults } from "./defaults";
/**
* Factory class for creating the appropriate Computer instance
*/
export class Computer {
/**
* Create a computer instance based on the provided configuration
* @param config The computer configuration
* @returns The appropriate computer instance based on the VM provider type
*/
static create(
config:
| Partial<BaseComputerConfig>
| Partial<CloudComputerConfig>
| Partial<LumeComputerConfig>
| Partial<LumierComputerConfig>
): BaseComputer {
// Apply defaults to the configuration
const fullConfig = applyDefaults(config);
// Check the vmProvider property to determine which type of computer to create
switch (fullConfig.vmProvider) {
case VMProviderType.CLOUD:
return new CloudComputer(fullConfig as CloudComputerConfig);
case VMProviderType.LUME:
return new LumeComputer(fullConfig as LumeComputerConfig);
case VMProviderType.LUMIER:
return new LumierComputer(fullConfig as LumierComputerConfig);
default:
throw new Error(
`Unsupported VM provider type: ${fullConfig.vmProvider}`
);
}
throw new Error(`Unsupported VM provider type`);
}
}
@@ -0,0 +1,106 @@
import type {
BaseComputerConfig,
Display,
OSType,
VMProviderType,
} from "../types";
import { computerLogger } from "../../util/logger";
/**
* Base Computer class with shared functionality
*/
export abstract class BaseComputer {
protected name: string;
protected osType: OSType;
protected vmProvider: VMProviderType;
constructor(config: BaseComputerConfig) {
this.name = config.name;
this.osType = config.osType;
this.vmProvider = config.vmProvider;
}
/**
* Get the name of the computer
*/
getName(): string {
return this.name;
}
/**
* Get the OS type of the computer
*/
getOSType(): OSType {
return this.osType;
}
/**
* Get the VM provider type
*/
getVMProviderType(): VMProviderType {
return this.vmProvider;
}
/**
* Shared method available to all computer types
*/
async disconnect(): Promise<void> {
computerLogger.info(`Disconnecting from ${this.name}`);
// Implementation would go here
}
/**
* Parse display string into Display object
* @param display Display string in format "WIDTHxHEIGHT"
* @returns Display object
*/
public static parseDisplayString(display: string): Display {
const match = display.match(/^(\d+)x(\d+)$/);
if (!match) {
throw new Error(
`Invalid display format: ${display}. Expected format: WIDTHxHEIGHT`
);
}
return {
width: parseInt(match[1], 10),
height: parseInt(match[2], 10),
};
}
/**
* Parse memory string to MB integer.
*
* Examples:
* "8GB" -> 8192
* "1024MB" -> 1024
* "512" -> 512
*
* @param memoryStr - Memory string to parse
* @returns Memory value in MB
*/
public static parseMemoryString(memoryStr: string): number {
if (!memoryStr) {
return 0;
}
// Convert to uppercase for case-insensitive matching
const upperStr = memoryStr.toUpperCase().trim();
// Extract numeric value and unit
const match = upperStr.match(/^(\d+(?:\.\d+)?)\s*(GB|MB)?$/);
if (!match) {
throw new Error(`Invalid memory format: ${memoryStr}`);
}
const value = parseFloat(match[1]);
const unit = match[2] || "MB"; // Default to MB if no unit specified
// Convert to MB
if (unit === "GB") {
return Math.round(value * 1024);
} else {
return Math.round(value);
}
}
}
@@ -0,0 +1,27 @@
import { BaseComputer } from "./base";
import type { CloudComputerConfig } from "../types";
import { computerLogger } from "../../util/logger";
/**
* Cloud-specific computer implementation
*/
export class CloudComputer extends BaseComputer {
constructor(config: CloudComputerConfig) {
super(config);
}
/**
* Cloud-specific method to deploy the computer
*/
async deploy(): Promise<void> {
computerLogger.info(`Deploying cloud computer ${this.name}`);
// Cloud-specific implementation
}
/**
* Cloud-specific method to get deployment status
*/
async getDeploymentStatus(): Promise<string> {
return "running"; // Example implementation
}
}
@@ -0,0 +1,281 @@
import type { Display, LumeComputerConfig } from "../types";
import { BaseComputer } from "./base";
import { applyDefaults } from "../defaults";
import {
lumeApiGet,
lumeApiRun,
lumeApiPull,
lumeApiStop,
lumeApiDelete,
lumeApiUpdate,
type VMInfo,
} from "../../util/lume";
import pino from "pino";
const logger = pino({ name: "lume_computer" });
/**
* Lume-specific computer implementation
*/
export class LumeComputer extends BaseComputer {
private display: string | Display;
private memory: string;
private cpu: number;
private image: string;
private port: number;
private host: string;
private ephemeral: boolean;
constructor(config: LumeComputerConfig) {
super(config);
const defaultConfig = applyDefaults(config);
this.display = defaultConfig.display;
this.memory = defaultConfig.memory;
this.cpu = defaultConfig.cpu;
this.image = defaultConfig.image;
this.port = defaultConfig.port;
this.host = defaultConfig.host;
this.ephemeral = defaultConfig.ephemeral;
}
/**
* Lume-specific method to get a VM
*/
async getVm(name: string, storage?: string): Promise<VMInfo> {
try {
const vmInfo = (await lumeApiGet(name, this.host, this.port, storage))[0];
if (!vmInfo) throw new Error("VM Not Found.");
if (vmInfo.status === "stopped") {
logger.info(
`VM ${name} is in '${vmInfo.status}' state - not waiting for IP address`
);
return {
...vmInfo,
name,
status: vmInfo.status,
};
}
if (!vmInfo.ipAddress) {
logger.info(
`VM ${name} is in '${vmInfo.status}' state but no IP address found - reporting as still starting`
);
}
return vmInfo;
} catch (e) {
logger.error(`Failed to get VM status: ${e}`);
throw e;
}
}
/**
* Lume-specific method to list availalbe VMs
*/
async listVm() {
const vms = await lumeApiGet("", this.host, this.port);
return vms;
}
/**
* Lume-specific method to run the VM
*/
async runVm(
image: string,
name: string,
runOpts: { [key: string]: any } = {},
storage?: string
): Promise<VMInfo> {
logger.info(
`Running Lume computer ${this.name} with ${this.memory} memory and ${this.cpu} CPUs`
);
logger.info(
`Using image ${this.image} with display ${
typeof this.display === "string"
? this.display
: `${this.display.width}x${this.display.height}`
}`
);
// Lume-specific implementation
try {
await this.getVm(name, storage);
} catch (e) {
logger.info(
`VM ${name} not found, attempting to pull image ${image} from registry...`
);
// Call pull_vm with the image parameter
try {
const pullRes = await this.pullVm(name, image, storage);
logger.info(pullRes);
} catch (e) {
logger.info(`Failed to pull VM image: ${e}`);
throw e;
}
}
logger.info(`Running VM ${name} with options: ${runOpts}`);
return await lumeApiRun(name, this.host, this.port, runOpts, storage);
}
/**
* Lume-specific method to stop a VM
*/
async stopVm(name: string, storage?: string): Promise<VMInfo> {
// Stop the VM first
const stopResult = await lumeApiStop(name, this.host, this.port, storage);
// If ephemeral mode is enabled, delete the VM after stopping
if (this.ephemeral && (!stopResult || !("error" in stopResult))) {
logger.info(
`Ephemeral mode enabled - deleting VM ${name} after stopping`
);
try {
const deleteResult = await this.deleteVm(name, storage);
// Return combined result
return {
...stopResult,
deleted: true,
deleteResult: deleteResult,
} as VMInfo;
} catch (e) {
logger.error(`Failed to delete ephemeral VM ${name}: ${e}`);
throw new Error(`Failed to delete ephemeral VM ${name}: ${e}`);
}
}
// Just return the stop result if not ephemeral
return stopResult;
}
/**
* Lume-specific method to pull a VM image from the registry
*/
async pullVm(
name: string,
image: string,
storage?: string,
registry: string = "ghcr.io",
organization: string = "trycua",
pullOpts?: { [key: string]: any }
): Promise<VMInfo> {
// Validate image parameter
if (!image) {
throw new Error("Image parameter is required for pullVm");
}
logger.info(`Pulling VM image '${image}' as '${name}'`);
logger.info("You can check the pull progress using: lume logs -f");
logger.debug(`Pull storage location: ${storage || "default"}`);
try {
const result = await lumeApiPull(
image,
name,
this.host,
this.port,
storage,
registry,
organization
);
logger.info(`Successfully pulled VM image '${image}' as '${name}'`);
return result;
} catch (e) {
logger.error(`Failed to pull VM image '${image}': ${e}`);
throw new Error(`Failed to pull VM: ${e}`);
}
}
/**
* Lume-specific method to delete a VM permanently
*/
async deleteVm(name: string, storage?: string): Promise<VMInfo | null> {
logger.info(`Deleting VM ${name}...`);
try {
const result = await lumeApiDelete(
name,
this.host,
this.port,
storage,
false,
false
);
logger.info(`Successfully deleted VM '${name}'`);
return result;
} catch (e) {
logger.error(`Failed to delete VM '${name}': ${e}`);
throw new Error(`Failed to delete VM: ${e}`);
}
}
/**
* Lume-specific method to update VM configuration
*/
async updateVm(
name: string,
updateOpts: { [key: string]: any },
storage?: string
): Promise<VMInfo> {
return await lumeApiUpdate(
name,
this.host,
this.port,
updateOpts,
storage,
false,
false
);
}
/**
* Lume-specific method to get the IP address of a VM, waiting indefinitely until it's available
*/
async getIp(
name: string,
storage?: string,
retryDelay: number = 2
): Promise<string> {
// Track total attempts for logging purposes
let attempts = 0;
while (true) {
attempts++;
try {
const vmInfo = await this.getVm(name, storage);
// Check if VM has an IP address
if (vmInfo.ipAddress) {
logger.info(
`Got IP address for VM ${name} after ${attempts} attempts: ${vmInfo.ipAddress}`
);
return vmInfo.ipAddress;
}
// Check if VM is in a state where it won't get an IP
if (vmInfo.status === "stopped" || vmInfo.status === "error") {
throw new Error(
`VM ${name} is in '${vmInfo.status}' state and will not get an IP address`
);
}
// Log progress every 10 attempts
if (attempts % 10 === 0) {
logger.info(
`Still waiting for IP address for VM ${name} (${attempts} attempts)...`
);
}
// Wait before retrying
await new Promise((resolve) => setTimeout(resolve, retryDelay * 1000));
} catch (e) {
logger.error(`Error getting IP for VM ${name}: ${e}`);
throw e;
}
}
}
}
@@ -0,0 +1,60 @@
import { BaseComputer } from "./base";
import { applyDefaults } from "../defaults";
import type { Display, LumierComputerConfig } from "../types";
import { computerLogger } from "../../util/logger";
/**
* Lumier-specific computer implementation
*/
export class LumierComputer extends BaseComputer {
private display: string | Display;
private memory: string;
private cpu: number;
private image: string;
private sharedDirectories?: string[];
private noVNCPort?: number;
private storage?: string;
private ephemeral: boolean;
constructor(config: LumierComputerConfig) {
super(config);
const defaultConfig = applyDefaults(config);
this.display = defaultConfig.display;
this.memory = defaultConfig.memory;
this.cpu = defaultConfig.cpu;
this.image = defaultConfig.image;
this.sharedDirectories = defaultConfig.sharedDirectories;
this.noVNCPort = defaultConfig.noVNCPort;
this.storage = defaultConfig.storage;
this.ephemeral = defaultConfig.ephemeral;
}
/**
* Lumier-specific method to start the container
*/
async startContainer(): Promise<void> {
computerLogger.info(
`Starting Lumier container ${this.name} with ${this.memory} memory and ${this.cpu} CPUs`
);
computerLogger.info(
`Using image ${this.image} with display ${
typeof this.display === "string"
? this.display
: `${this.display.width}x${this.display.height}`
}`
);
// Lumier-specific implementation
}
/**
* Lumier-specific method to execute a command in the container
*/
async execCommand(command: string): Promise<string> {
computerLogger.info(
`Executing command in Lumier container ${this.name}: ${command}`
);
return "command output"; // Example implementation
}
}
@@ -0,0 +1,152 @@
/**
* Display configuration for the computer.
*/
export interface Display {
width: number;
height: number;
scale_factor?: number;
}
/**
* Computer configuration model.
*/
export interface BaseComputerConfig {
/**
* The VM name
* @default ""
*/
name: string;
/**
* The operating system type ('macos', 'windows', or 'linux')
* @default "macos"
*/
osType: OSType;
/**
* The VM provider type to use (lume, lumier, cloud)
* @default VMProviderType.LUME
*/
vmProvider: VMProviderType;
/**
* The display configuration. Can be:
* - A Display object
* - A dict with 'width' and 'height'
* - A string in format "WIDTHxHEIGHT" (e.g. "1920x1080")
* @default "1024x768"
*/
display?: Display | string;
/**
* The VM memory allocation. (e.g. "8GB", "4GB", "1024MB")
* @default "8GB"
*/
memory?: string;
/**
* The VM CPU allocation.
* @default 4
*/
cpu?: number;
/**
* The VM image name
* @default "macos-sequoia-cua:latest"
*/
image?: string;
/**
* Optional list of directory paths to share with the VM
*/
sharedDirectories?: string[];
/**
* If True, target localhost instead of starting a VM
* @default false
*/
useHostComputerServer?: boolean;
/**
* Whether to enable telemetry tracking.
* @default true
*/
telemetryEnabled?: boolean;
/**
* Optional port to use for the VM provider server
* @default 7777
*/
port?: number;
/**
* Optional port for the noVNC web interface (Lumier provider)
* @default 8006
*/
noVNCPort?: number;
/**
* Host to use for VM provider connections (e.g. "localhost", "host.docker.internal")
* @default "localhost"
*/
host?: string;
/**
* Optional path for persistent VM storage (Lumier provider)
*/
storage?: string;
/**
* Whether to use ephemeral storage
* @default false
*/
ephemeral?: boolean;
/**
* Optional list of experimental features to enable (e.g. ["app-use"])
*/
experiments?: string[];
}
export interface CloudComputerConfig extends BaseComputerConfig {
/**
* Optional API key for cloud providers
*/
apiKey: string;
/**
* Size of the cloud VM
*/
size: "small" | "medium" | "large";
/**
* The Cloud VM provider type
*/
vmProvider: VMProviderType.CLOUD;
}
export interface LumeComputerConfig extends BaseComputerConfig {
/**
* The Lume VM provider type
*/
vmProvider: VMProviderType.LUME;
}
/**
* The Lumier VM provider type
*/
export interface LumierComputerConfig extends BaseComputerConfig {
vmProvider: VMProviderType.LUMIER;
}
export enum VMProviderType {
CLOUD = "cloud",
LUME = "lume",
LUMIER = "lumier",
}
export enum OSType {
MACOS = "macos",
WINDOWS = "windows",
LINUX = "linux",
}
-76
View File
@@ -1,76 +0,0 @@
/**
* Helper functions and decorators for the Computer module.
*/
import type { Computer } from './computer';
// Global reference to the default computer instance
let _defaultComputer: Computer | null = null;
/**
* Set the default computer instance to be used by the remote decorator.
*
* @param computer The computer instance to use as default
*/
export function setDefaultComputer(computer: Computer): void {
_defaultComputer = computer;
}
/**
* Get the default computer instance.
*
* @returns The default computer instance or null
*/
export function getDefaultComputer(): Computer | null {
return _defaultComputer;
}
/**
* Decorator that wraps a function to be executed remotely via computer.venvExec
*
* @param venvName Name of the virtual environment to execute in
* @param computer The computer instance to use, or "default" to use the globally set default
* @param maxRetries Maximum number of retries for the remote execution
*/
export function sandboxed(
venvName: string = 'default',
computer: Computer | 'default' = 'default',
maxRetries: number = 3
) {
return function <T extends (...args: any[]) => any>(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: Parameters<T>): Promise<ReturnType<T>> {
// Determine which computer instance to use
const comp = computer === 'default' ? _defaultComputer : computer;
if (!comp) {
throw new Error(
'No computer instance available. Either specify a computer instance or call setDefaultComputer() first.'
);
}
for (let i = 0; i < maxRetries; i++) {
try {
return await comp.venvExec(venvName, originalMethod, ...args);
} catch (error) {
console.error(`Attempt ${i + 1} failed:`, error);
if (i < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, 1000));
} else {
throw error;
}
}
}
// This should never be reached, but satisfies TypeScript's control flow analysis
throw new Error('Unexpected: maxRetries loop completed without returning or throwing');
};
return descriptor;
};
}
+4 -29
View File
@@ -1,30 +1,5 @@
// Core components
export { Computer } from "./computer";
export type { ComputerOptions, OSType } from "./computer";
// Export types
export * from "./computer/types";
// Models
export type { Display, ComputerConfig } from "./types";
// Provider components
export { VMProviderType, BaseVMProviderImpl } from "./providers";
export type { BaseVMProvider } from "./providers";
export { VMProviderFactory } from "./providers";
export type { VMProviderOptions } from "./providers";
// Interface components
export type { BaseComputerInterface } from "./interface";
export { InterfaceFactory } from "./interface";
export type { InterfaceOptions } from "./interface";
export { Key } from "./interface";
export type {
KeyType,
MouseButton,
NavigationKey,
SpecialKey,
ModifierKey,
FunctionKey,
AccessibilityWindow,
AccessibilityTree,
} from "./interface";
export * from "./helpers";
// Expore classes
export * from "./computer";
@@ -1,41 +0,0 @@
import type { KeyType, MouseButton, AccessibilityTree } from "./types";
/**
* Base interface for computer control implementations.
*/
export interface BaseComputerInterface {
/**
* Wait for the interface to be ready.
*/
waitForReady(): Promise<void>;
/**
* Get a screenshot of the current screen.
*/
getScreenshot(): Promise<Buffer>;
/**
* Move the mouse to the specified coordinates.
*/
moveMouse(x: number, y: number): Promise<void>;
/**
* Click the mouse at the current position.
*/
click(button?: MouseButton): Promise<void>;
/**
* Type text at the current cursor position.
*/
typeText(text: string): Promise<void>;
/**
* Press a key.
*/
pressKey(key: KeyType): Promise<void>;
/**
* Get the accessibility tree.
*/
getAccessibilityTree(): Promise<AccessibilityTree>;
}
@@ -1,56 +0,0 @@
import type { BaseComputerInterface } from './base';
import type { OSType } from '../computer';
export interface InterfaceOptions {
ipAddress: string;
apiKey?: string;
vmName?: string;
}
/**
* Factory for creating OS-specific computer interfaces.
*/
export class InterfaceFactory {
/**
* Create an interface for the specified OS.
*
* @param os The operating system type ('macos', 'linux', 'windows')
* @param ipAddress The IP address to connect to
* @param apiKey Optional API key for cloud providers
* @param vmName Optional VM name for cloud providers
* @returns An instance of the appropriate computer interface
*/
static createInterfaceForOS(
os: OSType,
ipAddress: string,
apiKey?: string,
vmName?: string
): BaseComputerInterface {
const options: InterfaceOptions = {
ipAddress,
apiKey,
vmName
};
switch (os) {
case 'macos':
// Dynamic import would be used in real implementation
// TODO: Implement macOS interface
throw new Error('macOS interface not yet implemented');
case 'linux':
// Dynamic import would be used in real implementation
// TODO: Implement Linux interface
throw new Error('Linux interface not yet implemented');
case 'windows':
// Dynamic import would be used in real implementation
// TODO: Implement Windows interface
throw new Error('Windows interface not yet implemented');
default:
// TODO: Implement interface for this OS
throw new Error(`Interface for OS ${os} not implemented`);
}
}
}
@@ -1,5 +0,0 @@
export * from "./base";
export * from "./factory";
export * from "./types";
export * from "./macos";
export * from "./linux";
@@ -1,47 +0,0 @@
import type { BaseComputerInterface } from "./base";
import type { KeyType, MouseButton, AccessibilityTree } from "./types";
/**
* Linux-specific implementation of the computer interface.
*/
export class LinuxComputerInterface implements BaseComputerInterface {
private ip_address: string;
constructor(ip_address: string) {
this.ip_address = ip_address;
}
async waitForReady(): Promise<void> {
// Implementation will go here
}
async getScreenshot(): Promise<Buffer> {
// Implementation will go here
return Buffer.from([]);
}
async moveMouse(x: number, y: number): Promise<void> {
// Implementation will go here
}
async click(button: MouseButton = "left"): Promise<void> {
// Implementation will go here
}
async typeText(text: string): Promise<void> {
// Implementation will go here
}
async pressKey(key: KeyType): Promise<void> {
// Implementation will go here
}
async getAccessibilityTree(): Promise<AccessibilityTree> {
// Implementation will go here
return {
success: false,
frontmost_application: "",
windows: [],
};
}
}
@@ -1,47 +0,0 @@
import type { BaseComputerInterface } from "./base";
import type { KeyType, MouseButton, AccessibilityTree } from "./types";
/**
* macOS-specific implementation of the computer interface.
*/
export class MacOSComputerInterface implements BaseComputerInterface {
private ip_address: string;
constructor(ip_address: string) {
this.ip_address = ip_address;
}
async waitForReady(): Promise<void> {
// Implementation will go here
}
async getScreenshot(): Promise<Buffer> {
// Implementation will go here
return Buffer.from([]);
}
async moveMouse(x: number, y: number): Promise<void> {
// Implementation will go here
}
async click(button: MouseButton = "left"): Promise<void> {
// Implementation will go here
}
async typeText(text: string): Promise<void> {
// Implementation will go here
}
async pressKey(key: KeyType): Promise<void> {
// Implementation will go here
}
async getAccessibilityTree(): Promise<AccessibilityTree> {
// Implementation will go here
return {
success: false,
frontmost_application: "",
windows: [],
};
}
}
@@ -1,96 +0,0 @@
/**
* Navigation key literals
*/
export type NavigationKey = 'pagedown' | 'pageup' | 'home' | 'end' | 'left' | 'right' | 'up' | 'down';
/**
* Special key literals
*/
export type SpecialKey = 'enter' | 'esc' | 'tab' | 'space' | 'backspace' | 'del';
/**
* Modifier key literals
*/
export type ModifierKey = 'ctrl' | 'alt' | 'shift' | 'win' | 'command' | 'option';
/**
* Function key literals
*/
export type FunctionKey = 'f1' | 'f2' | 'f3' | 'f4' | 'f5' | 'f6' | 'f7' | 'f8' | 'f9' | 'f10' | 'f11' | 'f12';
/**
* Keyboard keys that can be used with press_key.
*/
export enum Key {
// Navigation
PAGE_DOWN = 'pagedown',
PAGE_UP = 'pageup',
HOME = 'home',
END = 'end',
LEFT = 'left',
RIGHT = 'right',
UP = 'up',
DOWN = 'down',
// Special keys
RETURN = 'enter',
ENTER = 'enter',
ESCAPE = 'esc',
ESC = 'esc',
TAB = 'tab',
SPACE = 'space',
BACKSPACE = 'backspace',
DELETE = 'del',
// Modifier keys
ALT = 'alt',
CTRL = 'ctrl',
SHIFT = 'shift',
WIN = 'win',
COMMAND = 'command',
OPTION = 'option',
// Function keys
F1 = 'f1',
F2 = 'f2',
F3 = 'f3',
F4 = 'f4',
F5 = 'f5',
F6 = 'f6',
F7 = 'f7',
F8 = 'f8',
F9 = 'f9',
F10 = 'f10',
F11 = 'f11',
F12 = 'f12'
}
/**
* Combined key type
*/
export type KeyType = Key | NavigationKey | SpecialKey | ModifierKey | FunctionKey | string;
/**
* Key type for mouse actions
*/
export type MouseButton = 'left' | 'right' | 'middle';
/**
* Information about a window in the accessibility tree.
*/
export interface AccessibilityWindow {
app_name: string;
pid: number;
frontmost: boolean;
has_windows: boolean;
windows: Array<Record<string, any>>;
}
/**
* Complete accessibility tree information.
*/
export interface AccessibilityTree {
success: boolean;
frontmost_application: string;
windows: AccessibilityWindow[];
}
@@ -1,140 +0,0 @@
/**
* Types of VM providers available.
*/
export enum VMProviderType {
LUME = 'lume',
LUMIER = 'lumier',
CLOUD = 'cloud',
UNKNOWN = 'unknown'
}
/**
* Base interface for VM providers.
* All VM provider implementations must implement this interface.
*/
export interface BaseVMProvider {
/**
* Get the provider type.
*/
readonly providerType: VMProviderType;
/**
* Get VM information by name.
*
* @param name Name of the VM to get information for
* @param storage Optional storage path override
* @returns Dictionary with VM information including status, IP address, etc.
*/
getVM(name: string, storage?: string): Promise<Record<string, any>>;
/**
* List all available VMs.
*/
listVMs(): Promise<Array<Record<string, any>>>;
/**
* Run a VM by name with the given options.
*
* @param image VM image to run
* @param name Name for the VM
* @param runOpts Run options for the VM
* @param storage Optional storage path
* @returns VM run response
*/
runVM(
image: string,
name: string,
runOpts: Record<string, any>,
storage?: string
): Promise<Record<string, any>>;
/**
* Stop a VM by name.
*
* @param name Name of the VM to stop
* @param storage Optional storage path
*/
stopVM(name: string, storage?: string): Promise<void>;
/**
* Get the IP address of a VM.
*
* @param name Name of the VM
* @param storage Optional storage path
* @param retryDelay Delay between retries in seconds
* @returns IP address of the VM
*/
getIP(name: string, storage?: string, retryDelay?: number): Promise<string>;
/**
* Update VM settings.
*
* @param name Name of the VM
* @param cpu New CPU allocation
* @param memory New memory allocation
* @param storage Optional storage path
*/
updateVM(
name: string,
cpu?: number,
memory?: string,
storage?: string
): Promise<void>;
/**
* Context manager enter method
*/
__aenter__(): Promise<this>;
/**
* Context manager exit method
*/
__aexit__(
excType: any,
excVal: any,
excTb: any
): Promise<void>;
}
/**
* Abstract base class for VM providers that implements context manager
*/
export abstract class BaseVMProviderImpl implements BaseVMProvider {
abstract readonly providerType: VMProviderType;
abstract getVM(name: string, storage?: string): Promise<Record<string, any>>;
abstract listVMs(): Promise<Array<Record<string, any>>>;
abstract runVM(
image: string,
name: string,
runOpts: Record<string, any>,
storage?: string
): Promise<Record<string, any>>;
abstract stopVM(name: string, storage?: string): Promise<void>;
abstract getIP(name: string, storage?: string, retryDelay?: number): Promise<string>;
abstract updateVM(
name: string,
cpu?: number,
memory?: string,
storage?: string
): Promise<void>;
async __aenter__(): Promise<this> {
// Default implementation - can be overridden
return this;
}
/**
* Async context manager exit.
*
* This method is called when exiting an async context manager block.
* It handles proper cleanup of resources, including stopping any running containers.
*/
async __aexit__(
_excType: any,
_excVal: any,
_excTb: any
): Promise<void> {
// Default implementation - can be overridden
}
}
@@ -1,5 +0,0 @@
/**
* Cloud VM provider implementation.
*/
export { CloudProvider } from "./provider";
@@ -1,68 +0,0 @@
/**
* Cloud VM provider implementation.
*
* This provider is a placeholder for cloud-based VM provisioning.
* It will be implemented to support cloud VM services in the future.
*/
import { BaseVMProviderImpl, VMProviderType } from '../base';
export interface CloudProviderOptions {
verbose?: boolean;
apiKey?: string;
region?: string;
[key: string]: any;
}
export class CloudProvider extends BaseVMProviderImpl {
readonly providerType = VMProviderType.CLOUD;
private verbose: boolean;
private options: CloudProviderOptions;
constructor(options: CloudProviderOptions = {}) {
super();
this.verbose = options.verbose || false;
this.options = options;
}
// TODO: Implement getVM for cloud provider
async getVM(name: string, storage?: string): Promise<Record<string, any>> {
throw new Error('CloudProvider is not fully implemented yet. Please use LUME or LUMIER provider instead.');
}
// TODO: Implement listVMs for cloud provider
async listVMs(): Promise<Array<Record<string, any>>> {
throw new Error('CloudProvider is not fully implemented yet. Please use LUME or LUMIER provider instead.');
}
// TODO: Implement runVM for cloud provider
async runVM(
image: string,
name: string,
runOpts: Record<string, any>,
storage?: string
): Promise<Record<string, any>> {
throw new Error('CloudProvider is not fully implemented yet. Please use LUME or LUMIER provider instead.');
}
// TODO: Implement stopVM for cloud provider
async stopVM(name: string, storage?: string): Promise<void> {
throw new Error('CloudProvider is not fully implemented yet. Please use LUME or LUMIER provider instead.');
}
// TODO: Implement getIP for cloud provider
async getIP(name: string, storage?: string, retryDelay?: number): Promise<string> {
throw new Error('CloudProvider is not fully implemented yet. Please use LUME or LUMIER provider instead.');
}
// TODO: Implement updateVM for cloud provider
async updateVM(
name: string,
cpu?: number,
memory?: string,
storage?: string
): Promise<void> {
throw new Error('CloudProvider is not fully implemented yet. Please use LUME or LUMIER provider instead.');
}
}
@@ -1,150 +0,0 @@
/**
* Factory for creating VM providers.
*/
import type { BaseVMProvider } from './base';
import { VMProviderType } from './base';
export interface VMProviderOptions {
port?: number;
host?: string;
binPath?: string;
storage?: string;
sharedPath?: string;
image?: string;
verbose?: boolean;
ephemeral?: boolean;
noVNCPort?: number;
[key: string]: any; // Allow additional provider-specific options
}
export class VMProviderFactory {
/**
* Create a VM provider instance based on the provider type.
*
* @param providerType The type of provider to create
* @param options Provider-specific options
* @returns The created VM provider instance
* @throws Error if the provider type is not supported or dependencies are missing
*/
static async createProvider(
providerType: VMProviderType | string,
options: VMProviderOptions = {}
): Promise<BaseVMProvider> {
// Convert string to enum if needed
let type: VMProviderType;
if (typeof providerType === 'string') {
const normalizedType = providerType.toLowerCase();
if (Object.values(VMProviderType).includes(normalizedType as VMProviderType)) {
type = normalizedType as VMProviderType;
} else {
type = VMProviderType.UNKNOWN;
}
} else {
type = providerType;
}
// Extract common options with defaults
const {
port = 7777,
host = 'localhost',
binPath,
storage,
sharedPath,
image,
verbose = false,
ephemeral = false,
noVNCPort,
...additionalOptions
} = options;
switch (type) {
case VMProviderType.LUME: {
try {
// Dynamic import for Lume provider
const { LumeProvider, HAS_LUME } = await import('./lume');
if (!HAS_LUME) {
throw new Error(
'The required dependencies for LumeProvider are not available. ' +
'Please ensure curl is installed and in your PATH.'
);
}
return new LumeProvider({
port,
host,
storage,
verbose,
ephemeral
});
} catch (error) {
if (error instanceof Error && error.message.includes('Cannot find module')) {
throw new Error(
'The LumeProvider module is not available. ' +
'Please install it with: npm install @cua/computer-lume'
);
}
throw error;
}
}
case VMProviderType.LUMIER: {
try {
// Dynamic import for Lumier provider
const { LumierProvider, HAS_LUMIER } = await import('./lumier');
if (!HAS_LUMIER) {
throw new Error(
'Docker is required for LumierProvider. ' +
'Please install Docker for Apple Silicon and Lume CLI before using this provider.'
);
}
return new LumierProvider({
port,
host,
storage,
sharedPath,
image: image || 'macos-sequoia-cua:latest',
verbose,
ephemeral,
noVNCPort
});
} catch (error) {
if (error instanceof Error && error.message.includes('Cannot find module')) {
throw new Error(
'The LumierProvider module is not available. ' +
'Docker and Lume CLI are required for LumierProvider. ' +
'Please install Docker for Apple Silicon and run the Lume installer script.'
);
}
throw error;
}
}
case VMProviderType.CLOUD: {
try {
// Dynamic import for Cloud provider
const { CloudProvider } = await import('./cloud');
return new CloudProvider({
verbose,
...additionalOptions
});
} catch (error) {
if (error instanceof Error && error.message.includes('Cannot find module')) {
throw new Error(
'The CloudProvider is not fully implemented yet. ' +
'Please use LUME or LUMIER provider instead.'
);
}
throw error;
}
}
default:
throw new Error(`Unsupported provider type: ${providerType}`);
}
}
}
@@ -1,12 +0,0 @@
/**
* Export all provider-related modules.
*/
export * from './base';
export * from './factory';
export * from './lume_api';
// Export provider implementations
export * from './lume';
export * from './lumier';
export * from './cloud';
@@ -1,16 +0,0 @@
/**
* Lume VM provider implementation.
*/
export let HAS_LUME = false;
try {
// Check if curl is available
const { execSync } = require('child_process');
execSync('which curl', { stdio: 'ignore' });
HAS_LUME = true;
} catch {
HAS_LUME = false;
}
export { LumeProvider } from './provider';
@@ -1,182 +0,0 @@
/**
* Lume VM provider implementation using curl commands.
*
* This provider uses direct curl commands to interact with the Lume API,
* removing the dependency on the pylume Python package.
*/
import { BaseVMProviderImpl, VMProviderType } from '../base';
import type { LumeRunOptions } from '../lume_api';
import {
HAS_CURL,
lumeApiGet,
lumeApiRun,
lumeApiStop,
lumeApiUpdate,
lumeApiPull,
parseMemory
} from '../lume_api';
export interface LumeProviderOptions {
port?: number;
host?: string;
storage?: string;
verbose?: boolean;
ephemeral?: boolean;
}
export class LumeProvider extends BaseVMProviderImpl {
readonly providerType = VMProviderType.LUME;
private host: string;
private port: number;
private storage?: string;
private verbose: boolean;
private ephemeral: boolean;
constructor(options: LumeProviderOptions = {}) {
super();
if (!HAS_CURL) {
throw new Error(
'curl is required for LumeProvider. ' +
'Please ensure it is installed and in your PATH.'
);
}
this.host = options.host || 'localhost';
this.port = options.port || 7777;
this.storage = options.storage;
this.verbose = options.verbose || false;
this.ephemeral = options.ephemeral || false;
}
async getVM(name: string, storage?: string): Promise<Record<string, any>> {
return lumeApiGet(
name,
storage || this.storage,
this.host,
this.port,
this.verbose
);
}
async listVMs(): Promise<Array<Record<string, any>>> {
const response = await lumeApiGet(
'',
this.storage,
this.host,
this.port,
this.verbose
);
// The response should be an array of VMs
if (Array.isArray(response)) {
return response;
}
// If it's an object with a vms property
if (response.vms && Array.isArray(response.vms)) {
return response.vms;
}
// Otherwise return empty array
return [];
}
async runVM(
image: string,
name: string,
runOpts: LumeRunOptions,
storage?: string
): Promise<Record<string, any>> {
// Ensure the image is available
if (this.verbose) {
console.log(`Pulling image ${image} if needed...`);
}
try {
await lumeApiPull(image, this.host, this.port, this.verbose);
} catch (error) {
if (this.verbose) {
console.log(`Failed to pull image: ${error}`);
}
}
// Run the VM
return lumeApiRun(
image,
name,
runOpts,
storage || this.storage,
this.host,
this.port,
this.verbose
);
}
async stopVM(name: string, storage?: string): Promise<void> {
await lumeApiStop(
name,
storage || this.storage,
this.host,
this.port,
this.verbose
);
// If ephemeral, the VM should be automatically deleted after stopping
if (this.ephemeral && this.verbose) {
console.log(`VM ${name} stopped and removed (ephemeral mode)`);
}
}
async getIP(name: string, storage?: string, retryDelay: number = 1): Promise<string> {
const maxRetries = 30;
let retries = 0;
while (retries < maxRetries) {
try {
const vmInfo = await this.getVM(name, storage);
if (vmInfo.ip && vmInfo.ip !== '') {
return vmInfo.ip;
}
if (vmInfo.status === 'stopped' || vmInfo.status === 'error') {
throw new Error(`VM ${name} is in ${vmInfo.status} state`);
}
} catch (error) {
if (retries === maxRetries - 1) {
throw error;
}
}
retries++;
await new Promise(resolve => setTimeout(resolve, retryDelay * 1000));
}
throw new Error(`Failed to get IP for VM ${name} after ${maxRetries} retries`);
}
async updateVM(
name: string,
cpu?: number,
memory?: string,
storage?: string
): Promise<void> {
// Validate memory format if provided
if (memory) {
parseMemory(memory); // This will throw if invalid
}
await lumeApiUpdate(
name,
cpu,
memory,
storage || this.storage,
this.host,
this.port,
this.verbose
);
}
}
@@ -1,265 +0,0 @@
/**
* Lume API utilities for interacting with the Lume VM API.
* This module provides low-level API functions used by the Lume provider.
*/
import { exec, execSync } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
export let HAS_CURL = false;
// Check for curl availability
try {
execSync("which curl", { stdio: "ignore" });
HAS_CURL = true;
} catch {
HAS_CURL = false;
}
/**
* Parse memory string to bytes.
* Supports formats like "2GB", "512MB", "1024KB", etc.
* Defaults to 1GB
*/
export function parseMemory(memory = "1GB"): number {
const match = memory.match(/^(\d+(?:\.\d+)?)\s*([KMGT]?B?)$/i);
if (!match) {
throw new Error(`Invalid memory format: ${memory}`);
}
const value = parseFloat(match[1]!);
const unit = match[2]!.toUpperCase();
const multipliers: Record<string, number> = {
B: 1,
KB: 1024,
MB: 1024 * 1024,
GB: 1024 * 1024 * 1024,
TB: 1024 * 1024 * 1024 * 1024,
K: 1024,
M: 1024 * 1024,
G: 1024 * 1024 * 1024,
T: 1024 * 1024 * 1024 * 1024,
};
return Math.floor(value * (multipliers[unit] || 1));
}
/**
* Execute a curl command and return the result.
*/
async function executeCurl(
command: string
): Promise<{ stdout: string; stderr: string }> {
try {
const { stdout, stderr } = await execAsync(command);
return { stdout, stderr };
} catch (error: any) {
throw new Error(`Curl command failed: ${error.message}`);
}
}
/**
* Get VM information using Lume API.
*/
export async function lumeApiGet(
vmName: string = "",
storage?: string,
host: string = "localhost",
port: number = 7777,
debug: boolean = false
): Promise<Record<string, any>> {
let url = `http://${host}:${port}/vms`;
if (vmName) {
url += `/${encodeURIComponent(vmName)}`;
}
const params = new URLSearchParams();
if (storage) {
params.append("storage", storage);
}
if (params.toString()) {
url += `?${params.toString()}`;
}
const command = `curl -s -X GET "${url}"`;
if (debug) {
console.log(`Executing: ${command}`);
}
const { stdout } = await executeCurl(command);
try {
return JSON.parse(stdout);
} catch (error) {
throw new Error(`Failed to parse API response: ${stdout}`);
}
}
/**
* Options for running a VM using the Lume API.
*/
export interface LumeRunOptions {
/** CPU cores to allocate to the VM */
cpu?: number;
/** Memory to allocate to the VM (e.g., "8GB", "512MB") */
memory?: string;
/** Display configuration for the VM */
display?: {
width?: number;
height?: number;
dpi?: number;
color_depth?: number;
};
/** Environment variables to set in the VM */
env?: Record<string, string>;
/** Directories to share with the VM */
shared_directories?: Record<string, string>;
/** Network configuration */
network?: {
type?: string;
bridge?: string;
nat?: boolean;
};
/** Whether to run the VM in headless mode */
headless?: boolean;
/** Whether to enable GPU acceleration */
gpu?: boolean;
/** Storage location for the VM */
storage?: string;
/** Custom VM configuration options */
vm_options?: Record<string, any>;
/** Additional provider-specific options */
[key: string]: any;
}
/**
* Run a VM using Lume API.
*/
export async function lumeApiRun(
image: string,
name: string,
runOpts: LumeRunOptions,
storage?: string,
host: string = "localhost",
port: number = 7777,
debug: boolean = false
): Promise<Record<string, any>> {
const url = `http://${host}:${port}/vms/run`;
const body: LumeRunOptions = {
image,
name,
...runOpts,
};
if (storage) {
body.storage = storage;
}
const command = `curl -s -X POST "${url}" -H "Content-Type: application/json" -d '${JSON.stringify(
body
)}'`;
if (debug) {
console.log(`Executing: ${command}`);
}
const { stdout } = await executeCurl(command);
try {
return JSON.parse(stdout);
} catch (error) {
throw new Error(`Failed to parse API response: ${stdout}`);
}
}
/**
* Stop a VM using Lume API.
*/
export async function lumeApiStop(
vmName: string,
storage?: string,
host: string = "localhost",
port: number = 7777,
debug: boolean = false
): Promise<void> {
const url = `http://${host}:${port}/vms/${encodeURIComponent(vmName)}/stop`;
const params = new URLSearchParams();
if (storage) {
params.append("storage", storage);
}
const fullUrl = params.toString() ? `${url}?${params.toString()}` : url;
const command = `curl -s -X POST "${fullUrl}"`;
if (debug) {
console.log(`Executing: ${command}`);
}
await executeCurl(command);
}
/**
* Update VM settings using Lume API.
*/
export async function lumeApiUpdate(
vmName: string,
cpu?: number,
memory?: string,
storage?: string,
host: string = "localhost",
port: number = 7777,
debug: boolean = false
): Promise<void> {
const url = `http://${host}:${port}/vms/${encodeURIComponent(vmName)}/update`;
const body: LumeRunOptions = {};
if (cpu !== undefined) {
body.cpu = cpu;
}
if (memory !== undefined) {
body.memory = memory;
}
if (storage) {
body.storage = storage;
}
const command = `curl -s -X POST "${url}" -H "Content-Type: application/json" -d '${JSON.stringify(
body
)}'`;
if (debug) {
console.log(`Executing: ${command}`);
}
await executeCurl(command);
}
/**
* Pull a VM image using Lume API.
*/
export async function lumeApiPull(
image: string,
host: string = "localhost",
port: number = 7777,
debug: boolean = false
): Promise<void> {
const url = `http://${host}:${port}/images/pull`;
const body: LumeRunOptions = { image };
const command = `curl -s -X POST "${url}" -H "Content-Type: application/json" -d '${JSON.stringify(
body
)}'`;
if (debug) {
console.log(`Executing: ${command}`);
}
await executeCurl(command);
}
@@ -1,16 +0,0 @@
/**
* Lumier VM provider implementation.
*/
export let HAS_LUMIER = false;
try {
// Check if Docker is available
const { execSync } = require("child_process");
execSync("which docker", { stdio: "ignore" });
HAS_LUMIER = true;
} catch {
HAS_LUMIER = false;
}
export { LumierProvider } from "./provider";
@@ -1,401 +0,0 @@
/**
* Lumier VM provider implementation.
*
* This provider uses Docker containers running the Lumier image to create
* macOS and Linux VMs. It handles VM lifecycle operations through Docker
* commands and container management.
*/
import { exec } from "child_process";
import { promisify } from "util";
import { BaseVMProviderImpl, VMProviderType } from "../base";
import {
lumeApiGet,
lumeApiRun,
lumeApiStop,
lumeApiUpdate,
} from "../lume_api";
const execAsync = promisify(exec);
export interface LumierProviderOptions {
port?: number;
host?: string;
storage?: string;
sharedPath?: string;
image?: string;
verbose?: boolean;
ephemeral?: boolean;
noVNCPort?: number;
}
export class LumierProvider extends BaseVMProviderImpl {
readonly providerType = VMProviderType.LUMIER;
private host: string;
private apiPort: number;
private vncPort?: number;
private ephemeral: boolean;
private storage?: string;
private sharedPath?: string;
private image: string;
private verbose: boolean;
private containerName?: string;
private containerId?: string;
constructor(options: LumierProviderOptions = {}) {
super();
this.host = options.host || "localhost";
this.apiPort = options.port || 7777;
this.vncPort = options.noVNCPort;
this.ephemeral = options.ephemeral || false;
// Handle ephemeral storage
if (this.ephemeral) {
this.storage = "ephemeral";
} else {
this.storage = options.storage;
}
this.sharedPath = options.sharedPath;
this.image = options.image || "macos-sequoia-cua:latest";
this.verbose = options.verbose || false;
}
/**
* Parse memory string to MB integer.
*/
private parseMemory(memoryStr: string | number): number {
if (typeof memoryStr === "number") {
return memoryStr;
}
const match = memoryStr.match(/^(\d+)([A-Za-z]*)$/);
if (match) {
const value = parseInt(match[1]);
const unit = match[2].toUpperCase();
if (unit === "GB" || unit === "G") {
return value * 1024;
} else if (unit === "MB" || unit === "M" || unit === "") {
return value;
}
}
console.warn(
`Could not parse memory string '${memoryStr}', using 8GB default`
);
return 8192; // Default to 8GB
}
/**
* Check if a Docker container exists.
*/
private async containerExists(name: string): Promise<boolean> {
try {
await execAsync(`docker inspect ${name}`);
return true;
} catch {
return false;
}
}
/**
* Get container status.
*/
private async getContainerStatus(name: string): Promise<string> {
try {
const { stdout } = await execAsync(
`docker inspect -f '{{.State.Status}}' ${name}`
);
return stdout.trim();
} catch {
return "not_found";
}
}
/**
* Start the Lumier container.
*/
private async startContainer(
name: string,
cpu: number = 4,
memory: string = "8GB",
runOpts: Record<string, any> = {}
): Promise<void> {
const memoryMB = this.parseMemory(memory);
// Build Docker run command
let dockerCmd = `docker run -d --name ${name}`;
// Add resource limits
dockerCmd += ` --cpus=${cpu}`;
dockerCmd += ` --memory=${memoryMB}m`;
// Add port mappings
dockerCmd += ` -p ${this.apiPort}:7777`;
if (this.vncPort) {
dockerCmd += ` -p ${this.vncPort}:8006`;
}
// Add storage volume if not ephemeral
if (this.storage && this.storage !== "ephemeral") {
dockerCmd += ` -v ${this.storage}:/storage`;
}
// Add shared path if specified
if (this.sharedPath) {
dockerCmd += ` -v ${this.sharedPath}:/shared`;
}
// Add environment variables
if (runOpts.env) {
for (const [key, value] of Object.entries(runOpts.env)) {
dockerCmd += ` -e ${key}=${value}`;
}
}
// Add the image
dockerCmd += ` ${this.image}`;
if (this.verbose) {
console.log(`Starting container with command: ${dockerCmd}`);
}
try {
const { stdout } = await execAsync(dockerCmd);
this.containerId = stdout.trim();
this.containerName = name;
} catch (error: any) {
throw new Error(`Failed to start container: ${error.message}`);
}
}
/**
* Wait for the API to be ready.
*/
private async waitForAPI(maxRetries: number = 30): Promise<void> {
for (let i = 0; i < maxRetries; i++) {
try {
await lumeApiGet("", undefined, this.host, this.apiPort, false);
return;
} catch {
if (i === maxRetries - 1) {
throw new Error("API failed to become ready");
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
}
async getVM(name: string, storage?: string): Promise<Record<string, any>> {
// First check if container exists
const containerStatus = await this.getContainerStatus(
this.containerName || name
);
if (containerStatus === "not_found") {
throw new Error(`Container ${name} not found`);
}
// If container is not running, return basic status
if (containerStatus !== "running") {
return {
name,
status: containerStatus,
ip: "",
cpu: 0,
memory: "0MB",
};
}
// Get VM info from API
return lumeApiGet(
name,
storage || this.storage,
this.host,
this.apiPort,
this.verbose
);
}
async listVMs(): Promise<Array<Record<string, any>>> {
// Check if our container is running
if (!this.containerName) {
return [];
}
const containerStatus = await this.getContainerStatus(this.containerName);
if (containerStatus !== "running") {
return [];
}
// Get VMs from API
const response = await lumeApiGet(
"",
this.storage,
this.host,
this.apiPort,
this.verbose
);
if (Array.isArray(response)) {
return response;
}
if (response.vms && Array.isArray(response.vms)) {
return response.vms;
}
return [];
}
async runVM(
image: string,
name: string,
runOpts: Record<string, any>,
storage?: string
): Promise<Record<string, any>> {
// Check if container already exists
const exists = await this.containerExists(name);
if (exists) {
const status = await this.getContainerStatus(name);
if (status === "running") {
// Container already running, just run VM through API
return lumeApiRun(
image,
name,
runOpts,
storage || this.storage,
this.host,
this.apiPort,
this.verbose
);
} else {
// Start existing container
await execAsync(`docker start ${name}`);
}
} else {
// Create and start new container
await this.startContainer(
name,
runOpts.cpu || 4,
runOpts.memory || "8GB",
runOpts
);
}
// Wait for API to be ready
await this.waitForAPI();
// Run VM through API
return lumeApiRun(
image,
name,
runOpts,
storage || this.storage,
this.host,
this.apiPort,
this.verbose
);
}
async stopVM(name: string, storage?: string): Promise<void> {
// First stop VM through API
try {
await lumeApiStop(
name,
storage || this.storage,
this.host,
this.apiPort,
this.verbose
);
} catch (error) {
if (this.verbose) {
console.log(`Failed to stop VM through API: ${error}`);
}
}
// Stop the container
if (this.containerName) {
try {
await execAsync(`docker stop ${this.containerName}`);
// Remove container if ephemeral
if (this.ephemeral) {
await execAsync(`docker rm ${this.containerName}`);
if (this.verbose) {
console.log(
`Container ${this.containerName} stopped and removed (ephemeral mode)`
);
}
}
} catch (error: any) {
throw new Error(`Failed to stop container: ${error.message}`);
}
}
}
async getIP(
name: string,
storage?: string,
retryDelay: number = 1
): Promise<string> {
const maxRetries = 30;
for (let i = 0; i < maxRetries; i++) {
try {
const vmInfo = await this.getVM(name, storage);
if (vmInfo.ip && vmInfo.ip !== "") {
return vmInfo.ip;
}
if (vmInfo.status === "stopped" || vmInfo.status === "error") {
throw new Error(`VM ${name} is in ${vmInfo.status} state`);
}
} catch (error) {
if (i === maxRetries - 1) {
throw error;
}
}
await new Promise((resolve) => setTimeout(resolve, retryDelay * 1000));
}
throw new Error(
`Failed to get IP for VM ${name} after ${maxRetries} retries`
);
}
async updateVM(
name: string,
cpu?: number,
memory?: string,
storage?: string
): Promise<void> {
await lumeApiUpdate(
name,
cpu,
memory,
storage || this.storage,
this.host,
this.apiPort,
this.verbose
);
}
async __aexit__(excType: any, excVal: any, excTb: any): Promise<void> {
// Clean up container if ephemeral
if (this.ephemeral && this.containerName) {
try {
await execAsync(`docker stop ${this.containerName}`);
await execAsync(`docker rm ${this.containerName}`);
} catch {
// Ignore errors during cleanup
}
}
}
}
-117
View File
@@ -1,117 +0,0 @@
/**
* Telemetry tracking for Computer usage.
*/
interface TelemetryEvent {
event: string;
timestamp: Date;
properties?: Record<string, any>;
}
export class TelemetryManager {
private enabled: boolean = true;
private events: TelemetryEvent[] = [];
setEnabled(enabled: boolean): void {
this.enabled = enabled;
}
isEnabled(): boolean {
return this.enabled;
}
track(event: string, properties?: Record<string, any>): void {
if (!this.enabled) {
return;
}
const telemetryEvent: TelemetryEvent = {
event,
timestamp: new Date(),
properties,
};
this.events.push(telemetryEvent);
// In a real implementation, this would send to a telemetry service
// For now, just log to debug
if (process.env.NODE_ENV === "development") {
console.debug("[Telemetry]", event, properties);
} else {
//todo: log telemetry to posthog
}
}
getEvents(): TelemetryEvent[] {
return [...this.events];
}
clear(): void {
this.events = [];
}
}
// Singleton instance
const telemetryManager = new TelemetryManager();
/**
* Record computer initialization event
*/
export function recordComputerInitialization(): void {
telemetryManager.track("computer_initialized", {
timestamp: new Date().toISOString(),
version: process.env.npm_package_version || "unknown",
});
}
/**
* Record VM start event
*/
export function recordVMStart(vmName: string, provider: string): void {
telemetryManager.track("vm_started", {
vm_name: vmName,
provider,
timestamp: new Date().toISOString(),
});
}
/**
* Record VM stop event
*/
export function recordVMStop(vmName: string, duration: number): void {
telemetryManager.track("vm_stopped", {
vm_name: vmName,
duration_ms: duration,
timestamp: new Date().toISOString(),
});
}
/**
* Record interface action
*/
export function recordInterfaceAction(
action: string,
details?: Record<string, any>
): void {
telemetryManager.track("interface_action", {
action,
...details,
timestamp: new Date().toISOString(),
});
}
/**
* Set telemetry enabled/disabled
*/
export function setTelemetryEnabled(enabled: boolean): void {
telemetryManager.setEnabled(enabled);
}
/**
* Check if telemetry is enabled
*/
export function isTelemetryEnabled(): boolean {
return telemetryManager.isEnabled();
}
export { telemetryManager };
-21
View File
@@ -1,21 +0,0 @@
/**
* Display configuration for the computer.
*/
export interface Display {
width: number;
height: number;
scale_factor?: number;
}
/**
* Computer configuration model.
*/
export interface ComputerConfig {
image: string;
tag: string;
name: string;
display: Display;
memory: string;
cpu: string;
vm_provider?: any; // Will be properly typed when implemented
}
@@ -0,0 +1,7 @@
/**
* Shared logger module for the computer library
*/
import pino from "pino";
// Create and export default loggers for common components
export const computerLogger = pino({ name: "computer" });
+565
View File
@@ -0,0 +1,565 @@
/**
* Shared API utilities for Lume and Lumier providers.
*
* This module contains shared functions for interacting with the Lume API,
* used by both the LumeProvider and LumierProvider classes.
*/
import pino from "pino";
// Setup logging
const logger = pino({ name: "lume_api" });
// Types for API responses and options
// These are lume-specific
export interface SharedDirectory {
hostPath: string;
tag: string;
readOnly: boolean;
}
export interface VMInfo {
status?: string;
name: string;
diskSize: {
allocated: number;
total: number;
};
memorySize: number;
os: string;
display: string;
locationName: string;
cpuCount?: number;
// started state results
vncUrl?: string;
ipAddress?: string;
sharedDirectories?: SharedDirectory[];
}
export interface RunOptions {
[key: string]: any;
}
export interface UpdateOptions {
[key: string]: any;
}
/**
* Use fetch to get VM information from Lume API.
*
* @param vmName - Name of the VM to get info for
* @param host - API host
* @param port - API port
* @param storage - Storage path for the VM
* @param debug - Whether to show debug output
* @param verbose - Enable verbose logging
* @returns Dictionary with VM status information parsed from JSON response
*/
export async function lumeApiGet(
vmName: string,
host: string,
port: number,
storage?: string,
debug: boolean = false,
verbose: boolean = false
): Promise<VMInfo[]> {
// URL encode the storage parameter for the query
let storageParam = "";
if (storage) {
// First encode the storage path properly
const encodedStorage = encodeURIComponent(storage);
storageParam = `?storage=${encodedStorage}`;
}
// Construct API URL with encoded storage parameter if needed
const apiUrl = `http://${host}:${port}/lume/vms${vmName ? `/${vmName}` : ""}${storageParam}`;
// Only print the fetch URL when debug is enabled
logger.info(`Executing API request: ${apiUrl}`);
try {
// Execute the request with timeouts
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 20000); // 20 second timeout
const response = await fetch(apiUrl, {
method: "GET",
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
// Handle HTTP errors
const errorMsg = `HTTP error returned from API server (status: ${response.status})`;
throw new Error(`API request failed: ${errorMsg}`);
}
// Parse JSON response
// If vmName is provided, API returns a single object; otherwise it returns an array
const data = await response.json();
const result = vmName ? [data as VMInfo] : (data as VMInfo[]);
if (debug || verbose) {
console.log(`DEBUG: API response: ${JSON.stringify(result, null, 2)}`);
}
return result;
} catch (error: any) {
let errorMsg = "Unknown error";
if (error.name === "AbortError") {
errorMsg =
"Operation timeout - the API server is taking too long to respond";
} else if (error.code === "ECONNREFUSED") {
errorMsg =
"Failed to connect to the API server - it might still be starting up";
} else if (error.code === "ENOTFOUND") {
errorMsg = "Failed to resolve host - check the API server address";
} else if (error.message) {
errorMsg = error.message;
}
logger.error(`API request failed: ${errorMsg}`);
throw new Error(`API request failed: ${errorMsg}`);
}
}
/**
* Run a VM using fetch.
*
* @param vmName - Name of the VM to run
* @param host - API host
* @param port - API port
* @param runOpts - Dictionary of run options
* @param storage - Storage path for the VM
* @param debug - Whether to show debug output
* @param verbose - Enable verbose logging
* @returns Dictionary with API response
*/
export async function lumeApiRun(
vmName: string,
host: string,
port: number,
runOpts: RunOptions,
storage?: string,
debug: boolean = false,
verbose: boolean = false
): Promise<VMInfo> {
// URL encode the storage parameter for the query
let storageParam = "";
if (storage) {
const encodedStorage = encodeURIComponent(storage);
storageParam = `?storage=${encodedStorage}`;
}
// Construct API URL
const apiUrl = `http://${host}:${port}/lume/vms/${vmName}/run${storageParam}`;
// Convert run options to JSON
const jsonData = JSON.stringify(runOpts);
if (debug || verbose) {
console.log(`Executing fetch API call: POST ${apiUrl}`);
console.log(`Request body: ${jsonData}`);
}
logger.info(`Executing API request: POST ${apiUrl}`);
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 20000);
const response = await fetch(apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: jsonData,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorMsg = `HTTP error returned from API server (status: ${response.status})`;
throw new Error(`API request failed: ${errorMsg}`);
}
const data = (await response.json()) as VMInfo;
if (debug || verbose) {
console.log(`DEBUG: API response: ${JSON.stringify(data, null, 2)}`);
}
return data;
} catch (error: any) {
let errorMsg = "Unknown error";
if (error.name === "AbortError") {
errorMsg =
"Operation timeout - the API server is taking too long to respond";
} else if (error.code === "ECONNREFUSED") {
errorMsg = "Failed to connect to the API server";
} else if (error.message) {
errorMsg = error.message;
}
logger.error(`API request failed: ${errorMsg}`);
throw new Error(`API request failed: ${errorMsg}`);
}
}
/**
* Stop a VM using fetch.
*
* @param vmName - Name of the VM to stop
* @param host - API host
* @param port - API port
* @param storage - Storage path for the VM
* @param debug - Whether to show debug output
* @param verbose - Enable verbose logging
* @returns Dictionary with API response
*/
export async function lumeApiStop(
vmName: string,
host: string,
port: number,
storage?: string,
debug: boolean = false,
verbose: boolean = false
): Promise<VMInfo> {
// URL encode the storage parameter for the query
let storageParam = "";
if (storage) {
const encodedStorage = encodeURIComponent(storage);
storageParam = `?storage=${encodedStorage}`;
}
// Construct API URL
const apiUrl = `http://${host}:${port}/lume/vms/${vmName}/stop${storageParam}`;
if (debug || verbose) {
console.log(`DEBUG: Executing fetch API call: POST ${apiUrl}`);
}
logger.debug(`Executing API request: POST ${apiUrl}`);
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 20000);
const response = await fetch(apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: "{}",
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorMsg = `HTTP error returned from API server (status: ${response.status})`;
throw new Error(`API request failed: ${errorMsg}`);
}
const data = (await response.json()) as VMInfo;
if (debug || verbose) {
console.log(`DEBUG: API response: ${JSON.stringify(data, null, 2)}`);
}
return data;
} catch (error: any) {
let errorMsg = "Unknown error";
if (error.name === "AbortError") {
errorMsg =
"Operation timeout - the API server is taking too long to respond";
} else if (error.code === "ECONNREFUSED") {
errorMsg = "Failed to connect to the API server";
} else if (error.message) {
errorMsg = error.message;
}
logger.error(`API request failed: ${errorMsg}`);
throw new Error(`API request failed: ${errorMsg}`);
}
}
/**
* Update VM settings using fetch.
*
* @param vmName - Name of the VM to update
* @param host - API host
* @param port - API port
* @param updateOpts - Dictionary of update options
* @param storage - Storage path for the VM
* @param debug - Whether to show debug output
* @param verbose - Enable verbose logging
* @returns Dictionary with API response
*/
export async function lumeApiUpdate(
vmName: string,
host: string,
port: number,
updateOpts: UpdateOptions,
storage?: string,
debug: boolean = false,
verbose: boolean = false
): Promise<VMInfo> {
// URL encode the storage parameter for the query
let storageParam = "";
if (storage) {
const encodedStorage = encodeURIComponent(storage);
storageParam = `?storage=${encodedStorage}`;
}
// Construct API URL
const apiUrl = `http://${host}:${port}/lume/vms/${vmName}/update${storageParam}`;
// Convert update options to JSON
const jsonData = JSON.stringify(updateOpts);
if (debug || verbose) {
console.log(`DEBUG: Executing fetch API call: POST ${apiUrl}`);
console.log(`DEBUG: Request body: ${jsonData}`);
}
logger.debug(`Executing API request: POST ${apiUrl}`);
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 20000);
const response = await fetch(apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: jsonData,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorMsg = `HTTP error returned from API server (status: ${response.status})`;
throw new Error(`API request failed: ${errorMsg}`);
}
const data = (await response.json()) as VMInfo;
if (debug || verbose) {
console.log(`DEBUG: API response: ${JSON.stringify(data, null, 2)}`);
}
return data;
} catch (error: any) {
let errorMsg = "Unknown error";
if (error.name === "AbortError") {
errorMsg =
"Operation timeout - the API server is taking too long to respond";
} else if (error.code === "ECONNREFUSED") {
errorMsg = "Failed to connect to the API server";
} else if (error.message) {
errorMsg = error.message;
}
logger.error(`API request failed: ${errorMsg}`);
throw new Error(`API request failed: ${errorMsg}`);
}
}
/**
* Pull a VM image from a registry using fetch.
*
* @param image - Name/tag of the image to pull
* @param name - Name to give the VM after pulling
* @param host - API host
* @param port - API port
* @param storage - Storage path for the VM
* @param registry - Registry to pull from (default: ghcr.io)
* @param organization - Organization in registry (default: trycua)
* @param debug - Whether to show debug output
* @param verbose - Enable verbose logging
* @returns Dictionary with pull status and information
*/
export async function lumeApiPull(
image: string,
name: string,
host: string,
port: number,
storage?: string,
registry: string = "ghcr.io",
organization: string = "trycua",
debug: boolean = false,
verbose: boolean = false
): Promise<VMInfo> {
// URL encode the storage parameter for the query
let storageParam = "";
if (storage) {
const encodedStorage = encodeURIComponent(storage);
storageParam = `?storage=${encodedStorage}`;
}
// Construct API URL
const apiUrl = `http://${host}:${port}/lume/pull${storageParam}`;
// Construct pull options
const pullOpts = {
image,
name,
registry,
organization,
};
const jsonData = JSON.stringify(pullOpts);
if (debug || verbose) {
console.log(`DEBUG: Executing fetch API call: POST ${apiUrl}`);
console.log(`DEBUG: Request body: ${jsonData}`);
}
logger.debug(`Executing API request: POST ${apiUrl}`);
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 60000); // 60 second timeout for pulls
const response = await fetch(apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: jsonData,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorMsg = `HTTP error returned from API server (status: ${response.status})`;
throw new Error(`API request failed: ${errorMsg}`);
}
const data = (await response.json()) as VMInfo;
if (debug || verbose) {
console.log(`DEBUG: API response: ${JSON.stringify(data, null, 2)}`);
}
return data;
} catch (error: any) {
let errorMsg = "Unknown error";
if (error.name === "AbortError") {
errorMsg = "Operation timeout - the pull is taking too long";
} else if (error.code === "ECONNREFUSED") {
errorMsg = "Failed to connect to the API server";
} else if (error.message) {
errorMsg = error.message;
}
logger.error(`API request failed: ${errorMsg}`);
throw new Error(`API request failed: ${errorMsg}`);
}
}
/**
* Delete a VM using fetch.
*
* @param vmName - Name of the VM to delete
* @param host - API host
* @param port - API port
* @param storage - Storage path for the VM
* @param debug - Whether to show debug output
* @param verbose - Enable verbose logging
* @returns Dictionary with API response
*/
export async function lumeApiDelete(
vmName: string,
host: string,
port: number,
storage?: string,
debug: boolean = false,
verbose: boolean = false
): Promise<VMInfo | null> {
// URL encode the storage parameter for the query
let storageParam = "";
if (storage) {
const encodedStorage = encodeURIComponent(storage);
storageParam = `?storage=${encodedStorage}`;
}
// Construct API URL
const apiUrl = `http://${host}:${port}/lume/vms/${vmName}${storageParam}`;
if (debug || verbose) {
console.log(`DEBUG: Executing fetch API call: DELETE ${apiUrl}`);
}
logger.debug(`Executing API request: DELETE ${apiUrl}`);
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 20000);
const response = await fetch(apiUrl, {
method: "DELETE",
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok && response.status !== 404) {
const errorMsg = `HTTP error returned from API server (status: ${response.status})`;
throw new Error(`API request failed: ${errorMsg}`);
}
// For 404, return null (VM already deleted)
if (response.status === 404) {
if (debug || verbose) {
console.log("DEBUG: VM not found (404) - treating as already deleted");
}
return null;
}
// Try to parse JSON response, but handle empty responses
let data: VMInfo | null = null;
const contentType = response.headers.get("content-type");
if (contentType && contentType.includes("application/json")) {
try {
data = (await response.json()) as VMInfo;
} catch (e) {
// Empty response is OK for DELETE
}
} else {
// No JSON response expected
}
if (debug || verbose) {
console.log(`DEBUG: API response: ${JSON.stringify(data, null, 2)}`);
}
return data;
} catch (error: any) {
let errorMsg = "Unknown error";
if (error.name === "AbortError") {
errorMsg =
"Operation timeout - the API server is taking too long to respond";
} else if (error.code === "ECONNREFUSED") {
errorMsg = "Failed to connect to the API server";
} else if (error.message) {
errorMsg = error.message;
}
logger.error(`API request failed: ${errorMsg}`);
throw new Error(`API request failed: ${errorMsg}`);
}
}
-118
View File
@@ -1,118 +0,0 @@
/**
* Utility functions for the Computer module.
*/
/**
* Sleep for a specified number of milliseconds
* @param ms Number of milliseconds to sleep
*/
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Parse a display string in format "WIDTHxHEIGHT"
* @param display Display string to parse
* @returns Object with width and height
*/
export function parseDisplayString(display: string): {
width: number;
height: number;
} {
const match = display.match(/^(\d+)x(\d+)$/);
if (!match || !match[1] || !match[2]) {
throw new Error(
"Display string must be in format 'WIDTHxHEIGHT' (e.g. '1024x768')"
);
}
return {
width: parseInt(match[1], 10),
height: parseInt(match[2], 10),
};
}
/**
* Validate image format (should be in format "image:tag")
* @param image Image string to validate
* @returns Object with image name and tag
*/
export function parseImageString(image: string): { name: string; tag: string } {
const parts = image.split(":");
if (parts.length !== 2 || !parts[0] || !parts[1]) {
throw new Error("Image must be in the format <image_name>:<tag>");
}
return {
name: parts[0],
tag: parts[1],
};
}
/**
* Convert bytes to human-readable format
* @param bytes Number of bytes
* @returns Human-readable string
*/
export function formatBytes(bytes: number): string {
const units = ["B", "KB", "MB", "GB", "TB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
}
/**
* Parse memory string (e.g., "8GB") to bytes
* @param memory Memory string
* @returns Number of bytes
*/
export function parseMemoryString(memory: string): number {
const match = memory.match(/^(\d+)(B|KB|MB|GB|TB)?$/i);
if (!match || !match[1] || !match[2]) {
throw new Error("Invalid memory format. Use format like '8GB' or '1024MB'");
}
const value = parseInt(match[1], 10);
const unit = match[2].toUpperCase();
const multipliers: Record<string, number> = {
B: 1,
KB: 1024,
MB: 1024 * 1024,
GB: 1024 * 1024 * 1024,
TB: 1024 * 1024 * 1024 * 1024,
};
return value * (multipliers[unit] || 1);
}
/**
* Create a timeout promise that rejects after specified milliseconds
* @param ms Timeout in milliseconds
* @param message Optional error message
*/
export function timeout<T>(ms: number, message?: string): Promise<T> {
return new Promise((_, reject) => {
setTimeout(() => {
reject(new Error(message || `Timeout after ${ms}ms`));
}, ms);
});
}
/**
* Race a promise against a timeout
* @param promise The promise to race
* @param ms Timeout in milliseconds
* @param message Optional timeout error message
*/
export async function withTimeout<T>(
promise: Promise<T>,
ms: number,
message?: string
): Promise<T> {
return Promise.race([promise, timeout<T>(ms, message)]);
}
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { Computer, OSType, VMProviderType } from "../src/index";
describe("Create Computer Instances", () => {
it("should create a cloud computer", () => {
const computer = Computer.create({
vmProvider: VMProviderType.CLOUD,
name: "computer-name",
size: "small",
osType: OSType.LINUX,
apiKey: "asdf",
});
});
it("should create a lume computer", () => {
const computer = Computer.create({
vmProvider: VMProviderType.LUME,
display: { width: 1000, height: 1000, scale_factor: 1 },
image: "computer-image",
memory: "5GB",
cpu: 2,
name: "computer-name",
osType: OSType.MACOS,
});
});
it("should create a lumier computer", () => {
const computer = Computer.create({
vmProvider: VMProviderType.LUMIER,
display: { width: 1000, height: 1000, scale_factor: 1 },
image: "computer-image",
memory: "5GB",
cpu: 2,
name: "computer-name",
osType: OSType.MACOS,
});
});
});
@@ -0,0 +1,625 @@
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
lumeApiGet,
lumeApiRun,
lumeApiStop,
lumeApiUpdate,
lumeApiPull,
lumeApiDelete,
type VMInfo,
type RunOptions,
type UpdateOptions,
} from "../src/util/lume";
const PORT = 1213;
const HOST = "localhost";
describe("Lume API", () => {
let lumeServer: any;
beforeAll(() => {
// Spawn the lume serve process before tests
const { spawn } = require("child_process");
lumeServer = spawn("lume", ["serve", "--port", PORT], {
stdio: "pipe",
detached: true,
});
// Clean up the server when tests are done
afterAll(() => {
if (lumeServer && !lumeServer.killed) {
process.kill(-lumeServer.pid);
}
});
});
describe("lumeApiGet", () => {
it("should fetch VM information successfully", async () => {
// Mock fetch for this test - API returns a single VMDetails object
const mockVMInfo: VMInfo = {
name: "test-vm",
status: "stopped",
diskSize: { allocated: 1024, total: 10240 },
memorySize: 2048,
os: "ubuntu",
display: "1920x1080",
locationName: "local",
cpuCount: 2,
sharedDirectories: [
{
hostPath: "/home/user/shared",
tag: "shared",
readOnly: false,
},
],
};
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => mockVMInfo,
headers: new Headers({ "content-type": "application/json" }),
} as Response);
const result = await lumeApiGet("test-vm", HOST, PORT);
expect(result).toEqual([mockVMInfo]);
expect(fetch).toHaveBeenCalledWith(
`http://${HOST}:${PORT}/lume/vms/test-vm`,
expect.objectContaining({
method: "GET",
signal: expect.any(AbortSignal),
})
);
});
it("should list all VMs when name is empty", async () => {
// Mock fetch for list VMs - API returns an array
const mockVMList: VMInfo[] = [
{
name: "vm1",
status: "running",
diskSize: { allocated: 1024, total: 10240 },
memorySize: 2048,
os: "ubuntu",
display: "1920x1080",
locationName: "local",
cpuCount: 2,
},
{
name: "vm2",
status: "stopped",
diskSize: { allocated: 2048, total: 10240 },
memorySize: 4096,
os: "debian",
display: "1920x1080",
locationName: "local",
cpuCount: 4,
},
];
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => mockVMList,
headers: new Headers({ "content-type": "application/json" }),
} as Response);
const result = await lumeApiGet("", HOST, PORT);
expect(result).toEqual(mockVMList);
expect(fetch).toHaveBeenCalledWith(
`http://${HOST}:${PORT}/lume/vms`,
expect.objectContaining({
method: "GET",
signal: expect.any(AbortSignal),
})
);
});
it("should handle storage parameter encoding correctly", async () => {
const mockVMInfo: VMInfo = {
name: "test-vm",
status: "stopped",
diskSize: { allocated: 1024, total: 10240 },
memorySize: 2048,
os: "ubuntu",
display: "1920x1080",
locationName: "local",
cpuCount: 2,
};
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => mockVMInfo,
headers: new Headers({ "content-type": "application/json" }),
} as Response);
const storage = "/path/with spaces/and#special?chars";
await lumeApiGet("test-vm", HOST, PORT, storage);
const expectedUrl = `http://${HOST}:${PORT}/lume/vms/test-vm?storage=${encodeURIComponent(
storage
)}`;
expect(fetch).toHaveBeenCalledWith(
expectedUrl,
expect.objectContaining({
method: "GET",
})
);
});
it("should handle HTTP errors", async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: false,
status: 404,
headers: new Headers(),
} as Response);
await expect(lumeApiGet("test-vm", HOST, PORT)).rejects.toThrow(
"API request failed: HTTP error returned from API server (status: 404)"
);
});
it("should handle connection refused errors", async () => {
const error = new Error("Connection refused");
(error as any).code = "ECONNREFUSED";
global.fetch = vi.fn().mockRejectedValueOnce(error);
await expect(lumeApiGet("test-vm", HOST, PORT)).rejects.toThrow(
"API request failed: Failed to connect to the API server - it might still be starting up"
);
});
it("should handle timeout errors", async () => {
const error = new Error("Request aborted");
error.name = "AbortError";
global.fetch = vi.fn().mockRejectedValueOnce(error);
await expect(lumeApiGet("test-vm", HOST, PORT)).rejects.toThrow(
"API request failed: Operation timeout - the API server is taking too long to respond"
);
});
it("should handle host not found errors", async () => {
const error = new Error("Host not found");
(error as any).code = "ENOTFOUND";
global.fetch = vi.fn().mockRejectedValueOnce(error);
await expect(lumeApiGet("test-vm", HOST, PORT)).rejects.toThrow(
"API request failed: Failed to resolve host - check the API server address"
);
});
});
describe("lumeApiRun", () => {
it("should run a VM successfully", async () => {
const mockVMInfo: VMInfo = {
name: "test-vm",
status: "running",
diskSize: { allocated: 1024, total: 10240 },
memorySize: 2048,
os: "ubuntu",
display: "1920x1080",
locationName: "local",
cpuCount: 2,
vncUrl: "vnc://localhost:5900",
ipAddress: "192.168.1.100",
};
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => mockVMInfo,
headers: new Headers({ "content-type": "application/json" }),
} as Response);
const runOpts: RunOptions = {
memory: "2G",
cpus: 2,
display: "1920x1080",
};
const result = await lumeApiRun("test-vm", HOST, PORT, runOpts);
expect(result).toEqual(mockVMInfo);
expect(fetch).toHaveBeenCalledWith(
`http://${HOST}:${PORT}/lume/vms/test-vm/run`,
expect.objectContaining({
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(runOpts),
signal: expect.any(AbortSignal),
})
);
});
it("should handle storage parameter in run request", async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({ name: "test-vm" }),
headers: new Headers({ "content-type": "application/json" }),
} as Response);
const storage = "/custom/storage/path";
const runOpts: RunOptions = { memory: "1G" };
await lumeApiRun("test-vm", HOST, PORT, runOpts, storage);
const expectedUrl = `http://${HOST}:${PORT}/lume/vms/test-vm/run?storage=${encodeURIComponent(
storage
)}`;
expect(fetch).toHaveBeenCalledWith(
expectedUrl,
expect.objectContaining({
method: "POST",
})
);
});
it("should handle run errors", async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: false,
status: 500,
headers: new Headers(),
} as Response);
await expect(
lumeApiRun("test-vm", HOST, PORT, {})
).rejects.toThrow(
"API request failed: HTTP error returned from API server (status: 500)"
);
});
});
describe("lumeApiStop", () => {
it("should stop a VM successfully", async () => {
const mockVMInfo: VMInfo = {
name: "test-vm",
status: "stopped",
diskSize: { allocated: 1024, total: 10240 },
memorySize: 2048,
os: "ubuntu",
display: "1920x1080",
locationName: "local",
cpuCount: 2,
};
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => mockVMInfo,
headers: new Headers({ "content-type": "application/json" }),
} as Response);
const result = await lumeApiStop("test-vm", HOST, PORT);
expect(result).toEqual(mockVMInfo);
expect(fetch).toHaveBeenCalledWith(
`http://${HOST}:${PORT}/lume/vms/test-vm/stop`,
expect.objectContaining({
method: "POST",
signal: expect.any(AbortSignal),
headers: {
"Content-Type": "application/json",
},
body: "{}",
})
);
});
it("should handle storage parameter in stop request", async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({ name: "test-vm" }),
headers: new Headers({ "content-type": "application/json" }),
} as Response);
const storage = "/storage/path";
await lumeApiStop("test-vm", HOST, PORT, storage);
const expectedUrl = `http://${HOST}:${PORT}/lume/vms/test-vm/stop?storage=${encodeURIComponent(
storage
)}`;
expect(fetch).toHaveBeenCalledWith(
expectedUrl,
expect.objectContaining({
method: "POST",
})
);
});
});
describe("lumeApiUpdate", () => {
it("should update VM settings successfully", async () => {
const mockVMInfo: VMInfo = {
name: "test-vm",
status: "stopped",
diskSize: { allocated: 1024, total: 10240 },
memorySize: 4096,
os: "ubuntu",
display: "2560x1440",
locationName: "local",
cpuCount: 2,
};
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => mockVMInfo,
headers: new Headers({ "content-type": "application/json" }),
} as Response);
const updateOpts: UpdateOptions = {
memory: "4G",
display: "2560x1440",
};
const result = await lumeApiUpdate("test-vm", HOST, PORT, updateOpts);
expect(result).toEqual(mockVMInfo);
expect(fetch).toHaveBeenCalledWith(
`http://${HOST}:${PORT}/lume/vms/test-vm/update`,
expect.objectContaining({
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(updateOpts),
signal: expect.any(AbortSignal),
})
);
});
it("should handle empty update options", async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({ name: "test-vm" }),
headers: new Headers({ "content-type": "application/json" }),
} as Response);
await lumeApiUpdate("test-vm", HOST, PORT, {});
expect(fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
body: "{}",
})
);
});
});
describe("lumeApiPull", () => {
it("should pull a VM image successfully", async () => {
const mockVMInfo: VMInfo = {
name: "pulled-vm",
status: "stopped",
diskSize: { allocated: 2048, total: 10240 },
memorySize: 2048,
os: "ubuntu",
display: "1920x1080",
locationName: "local",
cpuCount: 2,
};
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => mockVMInfo,
headers: new Headers({ "content-type": "application/json" }),
} as Response);
const result = await lumeApiPull(
"ubuntu:latest",
"pulled-vm",
HOST,
PORT
);
expect(result).toEqual(mockVMInfo);
expect(fetch).toHaveBeenCalledWith(
`http://${HOST}:${PORT}/lume/pull`,
expect.objectContaining({
method: "POST",
signal: expect.any(AbortSignal),
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
image: "ubuntu:latest",
name: "pulled-vm",
registry: "ghcr.io",
organization: "trycua",
}),
})
);
});
it("should use custom registry and organization", async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({ name: "custom-vm" }),
headers: new Headers({ "content-type": "application/json" }),
} as Response);
await lumeApiPull(
"custom:tag",
"custom-vm",
HOST,
PORT,
undefined,
"docker.io",
"myorg"
);
expect(fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
body: JSON.stringify({
image: "custom:tag",
name: "custom-vm",
registry: "docker.io",
organization: "myorg",
}),
})
);
});
it("should handle storage parameter in pull request", async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({ name: "test-vm" }),
headers: new Headers({ "content-type": "application/json" }),
} as Response);
const storage = "/custom/storage";
await lumeApiPull("image:tag", "test-vm", HOST, PORT, storage);
const expectedUrl = `http://${HOST}:${PORT}/lume/pull?storage=${encodeURIComponent(
storage
)}`;
expect(fetch).toHaveBeenCalledWith(
expectedUrl,
expect.objectContaining({
method: "POST",
})
);
});
});
describe("lumeApiDelete", () => {
it("should delete a VM successfully", async () => {
const mockVMInfo: VMInfo = {
name: "test-vm",
status: "deleted",
diskSize: { allocated: 0, total: 0 },
memorySize: 0,
os: "ubuntu",
display: "",
locationName: "local",
cpuCount: 0,
};
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => mockVMInfo,
headers: new Headers({ "content-type": "application/json" }),
} as Response);
const result = await lumeApiDelete("test-vm", HOST, PORT);
expect(result).toEqual(mockVMInfo);
expect(fetch).toHaveBeenCalledWith(
`http://${HOST}:${PORT}/lume/vms/test-vm`,
expect.objectContaining({
method: "DELETE",
signal: expect.any(AbortSignal),
})
);
});
it("should handle storage parameter in delete request", async () => {
const storage = "/custom/storage";
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => null,
headers: new Headers(),
} as Response);
await lumeApiDelete("test-vm", HOST, PORT, storage);
const expectedUrl = `http://${HOST}:${PORT}/lume/vms/test-vm?storage=${encodeURIComponent(
storage
)}`;
expect(fetch).toHaveBeenCalledWith(
expectedUrl,
expect.objectContaining({
method: "DELETE",
})
);
});
it("should handle 404 as successful deletion", async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: false,
status: 404,
headers: new Headers(),
} as Response);
const result = await lumeApiDelete("non-existent-vm", HOST, PORT);
expect(result).toBeNull();
});
it("should throw error for non-404 HTTP errors", async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: false,
status: 500,
headers: new Headers(),
} as Response);
await expect(lumeApiDelete("test-vm", HOST, PORT)).rejects.toThrow(
"API request failed: HTTP error returned from API server (status: 500)"
);
});
});
describe("Debug and Verbose Logging", () => {
it("should log debug information when debug is true", async () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => [{ name: "test-vm", cpuCount: 2 }],
headers: new Headers({ "content-type": "application/json" }),
} as Response);
await lumeApiGet("", HOST, PORT, undefined, true); // Empty name for list
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("DEBUG: API response:")
);
consoleSpy.mockRestore();
});
it("should log verbose information when verbose is true", async () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({ name: "test-vm", cpuCount: 2 }),
headers: new Headers({ "content-type": "application/json" }),
} as Response);
await lumeApiRun(
"test-vm",
HOST,
PORT,
{ memory: "1G" },
undefined,
false,
true
);
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe("Error Message Handling", () => {
it("should handle generic errors with message", async () => {
const error = new Error("Custom error message");
global.fetch = vi.fn().mockRejectedValueOnce(error);
await expect(lumeApiGet("test-vm", HOST, PORT)).rejects.toThrow(
"API request failed: Custom error message"
);
});
it("should handle unknown errors", async () => {
global.fetch = vi.fn().mockRejectedValueOnce({});
await expect(lumeApiGet("test-vm", HOST, PORT)).rejects.toThrow(
"API request failed: Unknown error"
);
});
});
});