UNPKG

@fly/sprites

Version:

JavaScript/TypeScript SDK for Sprites - remote command execution

239 lines 7.61 kB
/** * Command execution implementation - mirrors Node.js child_process API */ import { EventEmitter } from 'node:events'; import { PassThrough } from 'node:stream'; import { WSCommand } from './websocket.js'; import { ExecError } from './types.js'; /** * Represents a command running on a sprite * Mirrors the Node.js ChildProcess API */ export class SpriteCommand extends EventEmitter { sprite; stdin; stdout; stderr; wsCmd; exitPromise; exitResolver; started = false; constructor(sprite, command, args = [], options = {}) { super(); this.sprite = sprite; // Create passthrough streams for stdin/stdout/stderr this.stdin = new PassThrough(); this.stdout = new PassThrough(); this.stderr = new PassThrough(); // Build WebSocket URL const url = this.buildWebSocketURL(command, args, options); // Create WebSocket command this.wsCmd = new WSCommand(url, { 'Authorization': `Bearer ${this.sprite.client.token}`, }, options.tty || false); // Set up exit promise this.exitPromise = new Promise((resolve) => { this.exitResolver = resolve; }); // Wire up the streams and events this.setupStreams(); } /** * Start the command execution */ async start() { if (this.started) { throw new Error('Command already started'); } this.started = true; await this.wsCmd.start(); } /** * Set up stream connections */ setupStreams() { // Stdin: user writes -> WebSocket this.stdin.on('data', (chunk) => { try { this.wsCmd.writeStdin(chunk); } catch (error) { this.emit('error', error); } }); this.stdin.on('end', () => { this.wsCmd.sendStdinEOF(); }); // Stdout: WebSocket -> user reads this.wsCmd.on('stdout', (data) => { this.stdout.push(data); }); // Stderr: WebSocket -> user reads this.wsCmd.on('stderr', (data) => { this.stderr.push(data); }); // Exit handling this.wsCmd.on('exit', (code) => { this.stdout.push(null); // Signal EOF this.stderr.push(null); // Signal EOF this.exitResolver(code); this.emit('exit', code); }); // Error handling this.wsCmd.on('error', (error) => { this.emit('error', error); }); // Text messages (port notifications, etc.) this.wsCmd.on('message', (msg) => { this.emit('message', msg); }); } /** * Build WebSocket URL with query parameters */ buildWebSocketURL(command, args, options) { let baseURL = this.sprite.client.baseURL; // Convert HTTP(S) to WS(S) if (baseURL.startsWith('http')) { baseURL = 'ws' + baseURL.substring(4); } const url = new URL(`${baseURL}/v1/sprites/${this.sprite.name}/exec`); // Add command and arguments const allArgs = [command, ...args]; allArgs.forEach((arg) => { url.searchParams.append('cmd', arg); }); url.searchParams.set('path', command); // Add environment variables if (options.env) { for (const [key, value] of Object.entries(options.env)) { url.searchParams.append('env', `${key}=${value}`); } } // Add working directory if (options.cwd) { url.searchParams.set('dir', options.cwd); } // Add TTY settings if (options.tty) { url.searchParams.set('tty', 'true'); if (options.rows) { url.searchParams.set('rows', options.rows.toString()); } if (options.cols) { url.searchParams.set('cols', options.cols.toString()); } } // Add session ID if specified if (options.sessionId) { url.searchParams.set('id', options.sessionId); } // Add detachable flag if (options.detachable) { url.searchParams.set('detachable', 'true'); } // Add control mode flag if (options.controlMode) { url.searchParams.set('cc', 'true'); } return url.toString(); } /** * Wait for the command to complete and return the exit code */ async wait() { return this.exitPromise; } /** * Kill the command */ kill(_signal = 'SIGTERM') { this.wsCmd.close(); } /** * Resize the terminal (TTY mode only) */ resize(cols, rows) { this.wsCmd.resize(cols, rows); } /** * Get the exit code (returns -1 if not exited) */ exitCode() { return this.wsCmd.getExitCode(); } } /** * Spawn a command - event-based API (most Node.js-like) */ export function spawn(sprite, command, args = [], options = {}) { const cmd = new SpriteCommand(sprite, command, args, options); // Start asynchronously and emit 'spawn' when ready cmd.start().then(() => { cmd.emit('spawn'); }).catch((error) => { cmd.emit('error', error); }); return cmd; } /** * Execute a command and return a promise with the output */ export async function exec(sprite, command, options = {}) { // Parse command into parts const parts = command.trim().split(/\s+/); const cmd = parts[0]; const args = parts.slice(1); return execFile(sprite, cmd, args, options); } /** * Execute a file with arguments and return a promise with the output */ export async function execFile(sprite, file, args = [], options = {}) { const encoding = options.encoding || 'utf8'; const maxBuffer = options.maxBuffer || 10 * 1024 * 1024; // 10MB default return new Promise((resolve, reject) => { const cmd = new SpriteCommand(sprite, file, args, options); const stdoutChunks = []; const stderrChunks = []; let stdoutLength = 0; let stderrLength = 0; cmd.stdout.on('data', (chunk) => { stdoutChunks.push(chunk); stdoutLength += chunk.length; if (stdoutLength > maxBuffer) { cmd.kill(); reject(new Error(`stdout maxBuffer exceeded`)); } }); cmd.stderr.on('data', (chunk) => { stderrChunks.push(chunk); stderrLength += chunk.length; if (stderrLength > maxBuffer) { cmd.kill(); reject(new Error(`stderr maxBuffer exceeded`)); } }); cmd.on('exit', (code) => { const stdoutBuffer = Buffer.concat(stdoutChunks); const stderrBuffer = Buffer.concat(stderrChunks); const result = { stdout: encoding === 'buffer' ? stdoutBuffer : stdoutBuffer.toString(encoding), stderr: encoding === 'buffer' ? stderrBuffer : stderrBuffer.toString(encoding), exitCode: code, }; if (code !== 0) { const error = new ExecError(`Command failed with exit code ${code}`, result); reject(error); } else { resolve(result); } }); cmd.on('error', (error) => { reject(error); }); cmd.start().catch(reject); }); } //# sourceMappingURL=exec.js.map