UNPKG

vybcel

Version:

Vybcel Development Agent and Vite Plugin for seamless code sync and auto-deploy

872 lines (868 loc) 28.9 kB
// src/vscode-utils.ts import { exec } from "child_process"; import { promisify } from "util"; import http from "http"; var execAsync = promisify(exec); function detectVSCode() { return !!(process.env.VSCODE_PID || process.env.TERM_PROGRAM === "vscode" || process.env.VSCODE_INJECTION === "1" || process.env.TERM_PROGRAM === "cursor" || process.env.CURSOR_PID || process.env.CURSOR_TRACE_ID); } function isCursor() { return !!(process.env.CURSOR_TRACE_ID || process.env.VSCODE_GIT_ASKPASS_NODE?.includes("Cursor.app")); } async function isCodeCommandAvailable() { const useCursor = isCursor(); const command = useCursor ? "cursor" : "code"; try { await execAsync(`${command} --version`); return true; } catch { try { const altCommand = useCursor ? "code" : "cursor"; await execAsync(`${altCommand} --version`); return true; } catch { return false; } } } function checkDevServerReady(port) { return new Promise((resolve) => { const req = http.get(`http://localhost:${port}`, (res) => { resolve(res.statusCode === 200); }); req.on("error", () => { resolve(false); }); req.setTimeout(1e3, () => { req.destroy(); resolve(false); }); }); } async function findVitePort() { const commonPorts = [5177, 5176, 5175, 5174, 5173, 3001, 3e3]; for (const port of commonPorts) { if (await checkDevServerReady(port)) { return port; } } return null; } async function openInVSCodeSimpleBrowser(url) { const useCursor = isCursor(); const command = useCursor ? "cursor" : "code"; try { if (useCursor) { await execAsync(`${command} --command "vscode.open" --command-args "${url}"`); return true; } else { await execAsync(`${command} --command "simpleBrowser.show" --command-args "${url}"`); return true; } } catch (error) { console.warn(`[${useCursor ? "Cursor" : "VS Code"}] Method 1 failed:`, error.message); try { await execAsync(`${command} --command "simpleBrowser.show" --command-args "${url}"`); return true; } catch (fallbackError) { console.warn(`[${useCursor ? "Cursor" : "VS Code"}] Method 2 failed:`, fallbackError.message); try { const encodedUrl = encodeURIComponent(url); await execAsync(`${command} --open-url "vscode://ms-vscode.vscode-simple-browser/show?url=${encodedUrl}"`); return true; } catch (uriError) { console.warn(`[${useCursor ? "Cursor" : "VS Code"}] Method 3 failed:`, uriError.message); try { await execAsync(`${command} --open-url "${url}"`); return true; } catch (finalError) { console.warn(`[${useCursor ? "Cursor" : "VS Code"}] All methods failed:`, finalError.message); return false; } } } } } async function openInSystemBrowser(url) { try { const platform = process.platform; let command; switch (platform) { case "darwin": command = `open "${url}"`; break; case "win32": command = `start "" "${url}"`; break; default: command = `xdg-open "${url}"`; break; } await execAsync(command); return true; } catch (error) { console.warn("[System Browser] Failed to open:", error.message); return false; } } async function openPreview(config, debug = false) { if (!config.autoOpen) { if (debug) { console.log("\u{1F527} Auto-open disabled in config"); } return; } let port = config.port; let url = config.url; if (!url) { const foundPort = await findVitePort(); if (foundPort) { port = foundPort; url = `http://localhost:${port}`; if (debug) { console.log(`\u{1F50D} Found Vite dev server on port ${port}`); } } else { url = `http://localhost:${port}`; if (debug) { console.log(`\u26A0\uFE0F No active dev server found, using default port ${port}`); } } } if (debug) { console.log(`\u{1F310} Opening preview at ${url}...`); } const isReady = await checkDevServerReady(port); if (!isReady) { console.warn(`\u26A0\uFE0F Dev server not ready at ${url}`); return; } if (debug) { console.log("\u2705 Dev server is ready"); } const isVSCode = detectVSCode(); const hasCodeCommand = await isCodeCommandAvailable(); const useCursor = isCursor(); if (debug) { console.log(`\u{1F50D} ${useCursor ? "Cursor" : "VS Code"} detected: ${isVSCode}`); console.log(`\u{1F50D} Code command available: ${hasCodeCommand}`); console.log(`\u{1F50D} Environment check:`); console.log(` - CURSOR_TRACE_ID: ${process.env.CURSOR_TRACE_ID ? "present" : "missing"}`); console.log(` - VSCODE_GIT_ASKPASS_NODE: ${process.env.VSCODE_GIT_ASKPASS_NODE?.includes("Cursor.app") ? "Cursor.app" : "other"}`); } console.log(""); console.log("\u{1F310} \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550"); console.log("\u{1F680} Your development server is ready!"); console.log(`\u{1F517} Local preview: ${url}`); if (useCursor) { console.log("\u{1F4A1} To open in Cursor Simple Browser:"); console.log(" 1. Press Cmd+Shift+P"); console.log(' 2. Type "Simple Browser: Show"'); console.log(` 3. Enter: ${url}`); } console.log("\u{1F310} \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550"); console.log(""); if (isVSCode && hasCodeCommand) { if (debug) { console.log(`\u{1F680} Trying ${useCursor ? "Cursor" : "VS Code"} Simple Browser...`); } const success = await openInVSCodeSimpleBrowser(url); if (success) { if (debug) { console.log(`\u2705 Successfully opened in ${useCursor ? "Cursor" : "VS Code"} Simple Browser`); } return; } } if (debug) { console.log(`\u{1F310} Opening ${url} in system browser as fallback...`); } const systemSuccess = await openInSystemBrowser(url); if (systemSuccess) { console.log("\u2705 Preview opened in system browser"); return; } console.log("\u{1F4A1} Please manually open the URL above in your browser"); } // src/agent.ts import { io } from "socket.io-client"; import chokidar from "chokidar"; import fs from "fs"; import path from "path"; import crypto from "crypto"; import { exec as exec2 } from "child_process"; import { promisify as promisify2 } from "util"; import http2 from "http"; var execAsync2 = promisify2(exec2); var VybcelAgent = class { socket = null; watcher = null; httpServer = null; isConnected = false; isGitRepo = false; config; constructor(config) { this.config = config; } async start() { console.log("\u{1F680} Vybcel Agent"); if (this.config.debug) { console.log(`\u{1F4E1} Connecting to: ${this.config.wsUrl}`); console.log(`\u{1F194} Project ID: ${this.config.projectId}`); } await this.startLocalServer(); await this.checkGitRepository(); await this.connectWebSocket(); this.startFileWatcher(); console.log("\u2705 Agent running - edit files to sync changes"); console.log("\u{1F310} Local command server: http://localhost:3333"); console.log("Press Ctrl+C to stop"); } async startLocalServer() { return new Promise((resolve, reject) => { this.httpServer = http2.createServer((req, res) => { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type"); if (req.method === "OPTIONS") { res.writeHead(200); res.end(); return; } if (req.method === "POST" && req.url === "/open-url") { let body = ""; req.on("data", (chunk) => { body += chunk.toString(); }); req.on("end", async () => { try { const { url } = JSON.parse(body); if (this.config.debug) { console.log(`\u{1F310} Local server: Opening URL ${url}`); } const success = await this.openUrlInSystem(url); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ success, message: success ? "URL opened successfully" : "Failed to open URL" })); } catch (error) { if (this.config.debug) { console.error("\u274C Local server: Error opening URL:", error); } res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ success: false, error: error.message })); } }); return; } if (req.method === "GET" && req.url === "/status") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ status: "running", projectId: this.config.projectId, connected: this.isConnected })); return; } res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Not found" })); }); this.httpServer.listen(3333, "localhost", () => { if (this.config.debug) { console.log("\u{1F310} Local command server started on http://localhost:3333"); } resolve(); }); this.httpServer.on("error", (error) => { if (error.code === "EADDRINUSE") { console.log("\u26A0\uFE0F Port 3333 already in use, trying 3334..."); this.httpServer?.listen(3334, "localhost", () => { if (this.config.debug) { console.log("\u{1F310} Local command server started on http://localhost:3334"); } resolve(); }); } else { console.error("\u274C Failed to start local server:", error); reject(error); } }); }); } async openUrlInSystem(url) { try { const platform = process.platform; let command; switch (platform) { case "darwin": try { const cursorAppleScript = ` tell application "System Events" if exists (processes where name is "Cursor") then tell application "Cursor" to activate delay 0.5 keystroke "l" using {command down} delay 0.2 keystroke "${url}" delay 0.2 keystroke return return true else return false end if end tell `; if (this.config.debug) { console.log(`\u{1F34E} Trying AppleScript for Cursor: ${url}`); } await execAsync2(`osascript -e '${cursorAppleScript}'`); if (this.config.debug) { console.log(`\u2705 Opened in Cursor via AppleScript: ${url}`); } return true; } catch (cursorError) { if (this.config.debug) { console.log(`\u26A0\uFE0F Cursor AppleScript failed, falling back to system browser`); } command = `open "${url}"`; } break; case "win32": command = `start "" "${url}"`; break; default: command = `xdg-open "${url}"`; break; } if (command) { await execAsync2(command); if (this.config.debug) { console.log(`\u2705 Opened URL in system browser: ${url}`); } } return true; } catch (error) { console.error("\u274C Failed to open URL:", error.message); if (this.config.debug) { console.error("\u274C Full error details:", error); } return false; } } async checkGitRepository() { try { await execAsync2("git rev-parse --git-dir"); this.isGitRepo = true; if (this.config.debug) { console.log("\u2705 Git repository detected"); try { const { stdout } = await execAsync2("git remote get-url origin"); console.log(`\u{1F517} Remote origin: ${stdout.trim()}`); } catch (error) { console.log("\u26A0\uFE0F No remote origin configured"); } } } catch (error) { if (this.config.debug) { console.log("\u26A0\uFE0F Not a git repository - initializing..."); } await this.initializeGitRepository(); } } async initializeGitRepository() { try { if (this.config.debug) { console.log("\u{1F527} Initializing git repository..."); } await execAsync2("git init"); const repoUrl = `https://github.com/vybcel/vybcel-project-${this.config.projectId}.git`; await execAsync2(`git remote add origin ${repoUrl}`); try { await execAsync2("git add ."); await execAsync2('git commit -m "Initial commit from Vybcel template"'); } catch (commitError) { } this.isGitRepo = true; if (this.config.debug) { console.log("\u2705 Git repository setup completed"); } } catch (error) { console.error("\u274C Failed to initialize git repository:", error.message); this.isGitRepo = false; if (this.config.debug) { console.log("\u26A0\uFE0F Git commands will be disabled"); } } } async connectWebSocket() { return new Promise((resolve) => { this.socket = io(this.config.wsUrl, { transports: ["websocket", "polling"], // Support both transports for reliability timeout: 3e4, // 30 seconds - increased from 10 seconds for production reconnection: true, reconnectionAttempts: 5, reconnectionDelay: 2e3, reconnectionDelayMax: 1e4, // Max 10 seconds between attempts randomizationFactor: 0.5, // Add jitter to reconnection attempts // 🚀 PRODUCTION SETTINGS - match server configuration upgrade: true, rememberUpgrade: true, // Enable connection state recovery auth: { projectId: this.config.projectId // Include projectId in handshake } }); this.socket.on("connect", () => { if (this.config.debug) { console.log("\u2705 Connected to Vybcel"); } this.socket.emit("authenticate", { projectId: this.config.projectId, clientType: "agent" }); }); this.socket.on("authenticated", (data) => { if (this.config.debug) { console.log(`\u{1F510} Authenticated for project: ${data.projectId}`); } this.isConnected = true; this.openPreviewIfEnabled(); resolve(); }); setTimeout(() => { if (!this.isConnected) { if (this.config.debug) { console.log("\u23F0 Connection timeout, but continuing anyway..."); } resolve(); } }, 15e3); this.setupEventHandlers(); }); } setupEventHandlers() { if (!this.socket) return; this.socket.onAny((eventName, ...args) => { if (this.config.debug) { console.log(`\u{1F4E1} [Agent] Received event: ${eventName}`, args.length > 0 ? args[0] : ""); } }); this.socket.on("file_saved", (data) => { if (this.config.debug) { console.log(`\u{1F4BE} File saved: ${data.filePath}`); } }); this.socket.on("file_updated", (data) => { if (this.config.debug) { console.log(`\u{1F504} File updated by another client: ${data.filePath}`); } }); this.socket.on("discard_local_changes", async (data) => { console.log("\u{1F504} [AGENT] Received discard_local_changes command from server"); console.log("\u{1F504} Discarding local changes..."); await this.discardLocalChanges(); }); this.socket.on("git_pull_with_token", async (data) => { console.log("\u{1F4E5} [AGENT] Received git_pull_with_token command from server"); console.log("\u{1F4E5} Syncing with latest changes..."); await this.gitPullWithToken(data.token, data.repoUrl); }); this.socket.on("update_local_files", async (data) => { if (this.config.debug) { console.log("\u{1F4C4} [AGENT] Received update_local_files command from server"); console.log("\u{1F4C4} Updating local files..."); } await this.updateLocalFiles(data.files); }); this.socket.on("save_success", (data) => { console.log("\u{1F4BE} [AGENT] Save operation completed successfully"); }); this.socket.on("discard_success", (data) => { console.log("\u{1F504} [AGENT] Discard operation completed successfully"); }); this.socket.on("error", (error) => { console.error("\u274C WebSocket error:", error.message); }); this.socket.on("disconnect", (reason) => { console.log(`\u274C Disconnected: ${reason}`); this.isConnected = false; const serverInitiated = ["io server disconnect", "server namespace disconnect"]; const networkIssues = ["ping timeout", "transport close", "transport error"]; const clientInitiated = ["io client disconnect", "client namespace disconnect"]; if (serverInitiated.includes(reason)) { console.log("\u{1F504} Server-initiated disconnect, will attempt reconnection"); } else if (networkIssues.includes(reason)) { console.log("\u26A0\uFE0F Network issue detected, checking connection quality"); } else if (clientInitiated.includes(reason)) { console.log("\u{1F44B} Client-initiated disconnect, normal closure"); return; } if (!clientInitiated.includes(reason)) { setTimeout(() => { console.log("\u{1F504} Attempting to reconnect..."); this.socket?.connect(); }, 5e3); } }); this.socket.on("connect_error", (error) => { console.error("\u274C Connection failed:", error.message); console.log("\u{1F504} Will retry connection..."); }); } async discardLocalChanges() { if (!this.isGitRepo) { if (this.config.debug) { console.log("\u26A0\uFE0F Not a git repository - cannot discard changes"); } this.socket?.emit("git_command_result", { projectId: this.config.projectId, command: "discard", success: false, error: "Not a git repository" }); return; } try { if (this.watcher) { this.watcher.close(); this.watcher = null; } await execAsync2("git reset --hard HEAD"); await execAsync2("git clean -fd"); this.socket?.emit("git_command_result", { projectId: this.config.projectId, command: "discard", success: true }); this.startFileWatcher(); console.log("\u2705 Changes discarded"); } catch (error) { console.error("\u274C Error discarding changes:", error.message); this.socket?.emit("git_command_result", { projectId: this.config.projectId, command: "discard", success: false, error: error.message }); this.startFileWatcher(); } } async gitPullWithToken(token, repoUrl) { if (!this.isGitRepo) { if (this.config.debug) { console.log("\u26A0\uFE0F Not a git repository - cannot pull"); } this.socket?.emit("git_command_result", { projectId: this.config.projectId, command: "pull", success: false, error: "Not a git repository" }); return; } try { if (this.watcher) { this.watcher.close(); this.watcher = null; } const authenticatedUrl = repoUrl.replace( "https://github.com/", `https://x-access-token:${token}@github.com/` ); await execAsync2(`git fetch ${authenticatedUrl} main`); await execAsync2(`git reset --hard FETCH_HEAD`); console.log("\u2705 Synced with latest changes"); this.socket?.emit("git_command_result", { projectId: this.config.projectId, command: "pull", success: true }); this.startFileWatcher(); } catch (error) { console.error("\u274C Error syncing:", error.message); this.socket?.emit("git_command_result", { projectId: this.config.projectId, command: "pull", success: false, error: error.message }); this.startFileWatcher(); } } async updateLocalFiles(files) { try { if (this.watcher) { this.watcher.close(); this.watcher = null; } for (const file of files) { try { const dir = path.dirname(file.path); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(file.path, file.content, "utf8"); if (this.config.debug) { console.log(`\u2705 Updated: ${file.path}`); } } catch (fileError) { console.error(`\u274C Error updating file ${file.path}:`, fileError.message); } } this.socket?.emit("git_command_result", { projectId: this.config.projectId, command: "update_files", success: true }); this.startFileWatcher(); console.log("\u2705 Files updated"); } catch (error) { console.error("\u274C Error updating files:", error.message); this.socket?.emit("git_command_result", { projectId: this.config.projectId, command: "update_files", success: false, error: error.message }); this.startFileWatcher(); } } startFileWatcher() { if (this.watcher) { return; } if (this.config.debug) { console.log("\u{1F440} Watching for file changes..."); } this.watcher = chokidar.watch(".", { ignored: [ "node_modules/**", ".git/**", "dist/**", "build/**", ".next/**", ".turbo/**", "*.log", "vybcel.js", ".env*", "*.timestamp-*.mjs", "vite.config.*.timestamp-*.mjs", "**/*timestamp-*", "**/*.timestamp-*.*", "*.tmp", "*.temp", ".vite/**" ], ignoreInitial: true, persistent: true }); this.watcher.on("change", (filePath) => { this.handleFileChange(filePath); }); this.watcher.on("add", (filePath) => { this.handleFileChange(filePath); }); this.watcher.on("unlink", (filePath) => { this.handleFileDelete(filePath); }); this.watcher.on("error", (error) => { console.error("\u274C File watcher error:", error); }); } async handleFileChange(filePath) { if (!this.isConnected) { if (this.config.debug) { console.log(`\u23F3 Not connected, skipping: ${filePath}`); } return; } if (filePath.includes(".timestamp-") || filePath.includes("timestamp-")) { if (this.config.debug) { console.log(`\u23ED\uFE0F Ignoring timestamp file: ${filePath}`); } return; } try { const content = fs.readFileSync(filePath, "utf-8"); const hash = crypto.createHash("sha256").update(content).digest("hex"); if (this.config.debug) { console.log(`\u{1F4DD} Syncing: ${filePath}`); } const fileChange = { projectId: this.config.projectId, filePath: filePath.replace(/\\/g, "/"), content, hash, timestamp: Date.now() }; this.socket?.emit("file_change", fileChange); } catch (error) { console.error(`\u274C Error reading file ${filePath}:`, error.message); } } handleFileDelete(filePath) { if (!this.isConnected) { if (this.config.debug) { console.log(`\u23F3 Queuing deletion: ${filePath} (not connected)`); } return; } if (this.config.debug) { console.log(`\u{1F5D1}\uFE0F Deleted: ${filePath}`); } const fileDelete = { projectId: this.config.projectId, filePath: filePath.replace(/\\/g, "/"), timestamp: Date.now() }; this.socket?.emit("file_delete", fileDelete); } async openPreviewIfEnabled() { if (this.config.debug) { console.log("\u{1F50D} Checking if preview should be opened..."); console.log("\u{1F4CB} Toolbar config:", JSON.stringify(this.config.toolbar, null, 2)); } const toolbarConfig = this.config.toolbar; if (!toolbarConfig?.enabled) { if (this.config.debug) { console.log("\u23ED\uFE0F Toolbar disabled, skipping preview"); } return; } if (!toolbarConfig?.autoOpen) { if (this.config.debug) { console.log("\u23ED\uFE0F Auto-open disabled, skipping preview"); } return; } if (this.config.debug) { console.log("\u2705 Preview will be opened in 2 seconds..."); } const vsCodeConfig = { autoOpen: true, port: 5173 // Vite default port }; setTimeout(async () => { if (this.config.debug) { console.log("\u{1F680} Opening preview now..."); } await openPreview(vsCodeConfig, this.config.debug); }, 2e3); } stop() { console.log("\u{1F6D1} Stopping agent..."); if (this.watcher) { this.watcher.close(); this.watcher = null; } if (this.socket) { this.socket.disconnect(); } if (this.httpServer) { this.httpServer.close(); this.httpServer = null; } console.log("\u2705 Stopped"); } }; // src/version-checker.ts import fs2 from "fs"; import path2 from "path"; import { fileURLToPath } from "url"; var __filename = fileURLToPath(import.meta.url); var __dirname = path2.dirname(__filename); function getCurrentVersion() { try { const possiblePaths = [ path2.join(__dirname, "..", "package.json"), // Из dist/ в корень пакета path2.join(__dirname, "..", "..", "package.json"), // Если dist в подпапке path2.join(process.cwd(), "node_modules", "vybcel", "package.json") // В node_modules ]; for (const packageJsonPath of possiblePaths) { try { if (fs2.existsSync(packageJsonPath)) { const packageJson = JSON.parse(fs2.readFileSync(packageJsonPath, "utf8")); if (packageJson.name === "vybcel" && packageJson.version) { return packageJson.version; } } } catch (pathError) { continue; } } return "1.1.27"; } catch (error) { return "1.1.27"; } } async function checkLatestVersion(wsUrl) { try { const httpUrl = wsUrl.replace("ws://", "http://").replace("wss://", "https://"); const versionUrl = `${httpUrl}/api/version`; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5e3); const response = await fetch(versionUrl, { method: "GET", signal: controller.signal }); clearTimeout(timeoutId); if (response.ok) { const data = await response.json(); return data.latestAgentVersion || null; } } catch (error) { if (process.env.DEBUG) { console.debug("Failed to check latest version:", error); } } return null; } function isNewerVersion(latest, current) { try { const latestParts = latest.split(".").map(Number); const currentParts = current.split(".").map(Number); for (let i = 0; i < Math.max(latestParts.length, currentParts.length); i++) { const latestPart = latestParts[i] || 0; const currentPart = currentParts[i] || 0; if (latestPart > currentPart) return true; if (latestPart < currentPart) return false; } return false; } catch (error) { return false; } } async function checkForUpdates(wsUrl) { const current = getCurrentVersion(); const latest = await checkLatestVersion(wsUrl); const hasUpdate = latest ? isNewerVersion(latest, current) : false; return { current, latest: latest || void 0, hasUpdate }; } export { detectVSCode, openPreview, VybcelAgent, getCurrentVersion, isNewerVersion, checkForUpdates };