mirror of
https://github.com/trycua/computer.git
synced 2026-04-26 00:19:42 -05:00
Remove bad claude code, start from scratch with lume provider
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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" });
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user