UNPKG

phion

Version:

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

943 lines (939 loc) 29 kB
// src/vscode-utils.ts import { exec } from "child_process"; import fs from "fs"; import http from "http"; import os from "os"; import path from "path"; import { promisify } from "util"; var execAsync = promisify(exec); var globalBrowserOpened = false; var BROWSER_OPENED_FLAG_FILE = path.join(os.tmpdir(), "phion-browser-opened.flag"); 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 = [3e3, 3001, 5173, 5174, 5175, 5176, 5177]; 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 { try { await execAsync(`${command} --command "simpleBrowser.show" --command-args "${url}"`); return true; } catch { try { const encodedUrl = encodeURIComponent(url); await execAsync( `${command} --open-url "vscode://ms-vscode.vscode-simple-browser/show?url=${encodedUrl}"` ); return true; } catch { try { await execAsync(`${command} --open-url "${url}"`); return true; } catch { 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 { return false; } } function isBrowserAlreadyOpened() { return globalBrowserOpened || fs.existsSync(BROWSER_OPENED_FLAG_FILE); } function markBrowserAsOpened() { globalBrowserOpened = true; try { fs.writeFileSync(BROWSER_OPENED_FLAG_FILE, Date.now().toString()); } catch (error) { } } async function openPreview(config, debug = false) { if (!config.autoOpen) { return; } if (isBrowserAlreadyOpened()) { if (debug) { console.log("\u{1F6AB} Browser already opened, skipping..."); } return; } let port = config.port; let url = config.url; if (!url) { const foundPort = await findVitePort(); if (foundPort) { port = foundPort; url = `http://localhost:${port}`; } else { url = `http://localhost:${port}`; } } const isReady = await checkDevServerReady(port); if (!isReady) { if (debug) { console.warn(`Dev server not ready at ${url}`); } return; } if (debug) { console.log(`\u{1F680} Development server ready: ${url}`); } const isVSCode = detectVSCode(); const hasCodeCommand = await isCodeCommandAvailable(); if (isVSCode && hasCodeCommand) { const success = await openInVSCodeSimpleBrowser(url); if (success) { markBrowserAsOpened(); if (debug) { console.log("\u2705 Preview opened in browser"); } return; } } const systemSuccess = await openInSystemBrowser(url); if (systemSuccess) { markBrowserAsOpened(); if (debug) { console.log("\u2705 Preview opened in browser"); } return; } console.log(`\u{1F4A1} Open manually: ${url}`); } // src/agent.ts import { exec as exec2 } from "child_process"; import chokidar from "chokidar"; import crypto from "crypto"; import fs2 from "fs"; import http2 from "http"; import path2 from "path"; import { io } from "socket.io-client"; import { promisify as promisify2 } from "util"; var execAsync2 = promisify2(exec2); var PhionAgent = class { socket = null; watcher = null; envWatcher = null; // Отдельный watcher для .env файлов httpServer = null; isConnected = false; isGitRepo = false; config; gitOperationCooldown = false; // Новое поле для предотвращения ложных событий constructor(config) { this.config = config; } async start() { console.log("\u{1F680} Phion 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"); if (this.config.debug) { 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); const success = await openInSystemBrowser(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") { if (this.config.debug) { 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 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/phion-dev/phion-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 Phion 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 Phion"); } 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) => { if (this.config.debug) { 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) => { if (this.config.debug) { 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) => { if (this.config.debug) { console.log("\u{1F4BE} [AGENT] Save operation completed successfully"); } }); this.socket.on("discard_success", (data) => { if (this.config.debug) { 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) => { if (this.config.debug) { 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)) { if (this.config.debug) { console.log("\u{1F504} Server-initiated disconnect, will attempt reconnection"); } } else if (networkIssues.includes(reason)) { if (this.config.debug) { console.log("\u26A0\uFE0F Network issue detected, checking connection quality"); } } else if (clientInitiated.includes(reason)) { if (this.config.debug) { console.log("\u{1F44B} Client-initiated disconnect, normal closure"); } return; } if (!clientInitiated.includes(reason)) { setTimeout(() => { if (this.config.debug) { console.log("\u{1F504} Attempting to reconnect..."); } this.socket?.connect(); }, 5e3); } }); this.socket.on("connect_error", (error) => { if (this.config.debug) { 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.gitOperationCooldown = true; if (this.config.debug) { console.log("\u2705 Changes discarded"); console.log("\u{1F504} Git operation cooldown started (5s)"); } this.startFileWatcher(); setTimeout(() => { this.gitOperationCooldown = false; if (this.config.debug) { console.log("\u{1F504} Git operation cooldown ended"); } }, 5e3); } 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.gitOperationCooldown = false; 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`); if (this.config.debug) { console.log("\u2705 Synced with latest changes"); } this.socket?.emit("git_command_result", { projectId: this.config.projectId, command: "pull", success: true }); this.gitOperationCooldown = true; if (this.config.debug) { console.log("\u{1F504} Git operation cooldown started (5s)"); } this.startFileWatcher(); setTimeout(() => { this.gitOperationCooldown = false; if (this.config.debug) { console.log("\u{1F504} Git operation cooldown ended"); } }, 5e3); } 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.gitOperationCooldown = false; this.startFileWatcher(); } } async updateLocalFiles(files) { try { if (this.watcher) { this.watcher.close(); this.watcher = null; } for (const file of files) { try { const dir = path2.dirname(file.path); if (!fs2.existsSync(dir)) { fs2.mkdirSync(dir, { recursive: true }); } fs2.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(); if (this.config.debug) { 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", "phion.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); }); this.startEnvWatcher(); } startEnvWatcher() { if (this.envWatcher) { return; } if (this.config.debug) { console.log("\u{1F510} Watching for .env file changes..."); } this.envWatcher = chokidar.watch( [ ".env", ".env.local", ".env.development", ".env.production", ".env.development.local", ".env.production.local", ".env.test", ".env.test.local" ], { ignoreInitial: true, persistent: true } ); this.envWatcher.on("change", (filePath) => { this.handleEnvFileChange(filePath); }); this.envWatcher.on("add", (filePath) => { this.handleEnvFileChange(filePath); }); this.envWatcher.on("unlink", (filePath) => { this.handleEnvFileDelete(filePath); }); this.envWatcher.on("error", (error) => { console.error("\u274C Env watcher error:", error); }); } async handleFileChange(filePath) { if (!this.isConnected) { if (this.config.debug) { console.log(`\u23F3 Not connected, skipping: ${filePath}`); } return; } if (this.gitOperationCooldown) { if (this.config.debug) { console.log(`\u{1F504} Git operation in progress, skipping file change: ${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 = fs2.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 handleEnvFileChange(filePath) { if (!this.isConnected) { if (this.config.debug) { console.log(`\u23F3 Not connected, skipping env file: ${filePath}`); } return; } try { const content = fs2.readFileSync(filePath, "utf-8"); if (this.config.debug) { console.log(`\u{1F510} Syncing env file: ${filePath}`); } const envFileChange = { projectId: this.config.projectId, filePath: filePath.replace(/\\/g, "/"), content, timestamp: Date.now() }; this.socket?.emit("env_file_change", envFileChange); } catch (error) { console.error(`\u274C Error reading env file ${filePath}:`, error.message); } } handleEnvFileDelete(filePath) { if (!this.isConnected) { if (this.config.debug) { console.log(`\u23F3 Not connected, skipping env file deletion: ${filePath}`); } return; } if (this.config.debug) { console.log(`\u{1F5D1}\uFE0F Env file deleted: ${filePath}`); } this.socket?.emit("env_file_delete", { projectId: this.config.projectId, filePath: filePath.replace(/\\/g, "/"), timestamp: Date.now() }); } 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 3 seconds..."); } const vsCodeConfig = { autoOpen: true, port: 5173 // Vite default port }; setTimeout(async () => { if (this.config.debug) { console.log("\u{1F680} Opening preview now..."); } try { await openPreview(vsCodeConfig, this.config.debug); } catch (error) { if (this.config.debug) { console.log("\u26A0\uFE0F Failed to open preview from agent:", error.message); } } }, 3e3); } stop() { console.log("\u{1F6D1} Stopping agent..."); if (this.watcher) { this.watcher.close(); this.watcher = null; } if (this.envWatcher) { this.envWatcher.close(); this.envWatcher = 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 fs3 from "fs"; import path3 from "path"; import { fileURLToPath } from "url"; var __filename = fileURLToPath(import.meta.url); var __dirname = path3.dirname(__filename); function getCurrentVersion() { try { const possiblePaths = [ path3.join(__dirname, "..", "package.json"), // Из dist/ в корень пакета path3.join(__dirname, "..", "..", "package.json"), // Если dist в подпапке path3.join(process.cwd(), "node_modules", "phion", "package.json") // В node_modules ]; for (const packageJsonPath of possiblePaths) { try { if (fs3.existsSync(packageJsonPath)) { const packageJson = JSON.parse(fs3.readFileSync(packageJsonPath, "utf8")); if (packageJson.name === "phion" && packageJson.version) { return packageJson.version; } } } catch (pathError) { continue; } } return "0.0.1"; } catch (error) { return "0.0.1"; } } 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, PhionAgent, getCurrentVersion, checkForUpdates };