from __future__ import annotations import asyncio import json import logging import os import re import shutil import subprocess import tempfile import zipfile from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional logger = logging.getLogger("preview_workspace") _PROJECT_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") class PreviewWorkspace: """ Persistent project-preview workspace. Responsibilities: - isolate projects by project_id - safely create and inspect project directories - save source files - report workspace state - rebuild JavaScript/Next/static projects - package source and project artifacts - record workspace activity The implementation is intentionally filesystem-backed so it works both locally and inside the production container without requiring another service. """ def __init__(self, root_dir: Optional[str] = None) -> None: configured_root = ( root_dir or os.getenv("PREVIEW_WORKSPACE_ROOT") or os.getenv("DOLOR3V_PREVIEW_ROOT") ) if configured_root: self.root_dir = Path(configured_root).expanduser().resolve() else: self.root_dir = ( Path(__file__).resolve().parents[2] / "storage" / "preview-workspaces" ).resolve() self.root_dir.mkdir(parents=True, exist_ok=True) self.activity_dir = self.root_dir / ".activity" self.activity_dir.mkdir(parents=True, exist_ok=True) self.artifact_dir = self.root_dir / ".artifacts" self.artifact_dir.mkdir(parents=True, exist_ok=True) # --------------------------------------------------------- # Security / paths # --------------------------------------------------------- def _validate_project_id(self, project_id: str) -> str: if not isinstance(project_id, str): raise ValueError("project_id must be a string") project_id = project_id.strip() if not project_id: raise ValueError("project_id cannot be empty") if not _PROJECT_ID_RE.fullmatch(project_id): raise ValueError( "Invalid project_id. Only letters, numbers, '.', '_' and '-' " "are allowed." ) return project_id def _project_dir(self, project_id: str) -> Path: project_id = self._validate_project_id(project_id) path = (self.root_dir / project_id).resolve() try: path.relative_to(self.root_dir) except ValueError as exc: raise ValueError("Project path escapes preview workspace root") from exc return path def _resolve_project_file( self, project_id: str, relative_path: str, ) -> Path: project_dir = self._project_dir(project_id) if not isinstance(relative_path, str): raise ValueError("relative_path must be a string") relative_path = relative_path.strip().replace("\\", "/") if not relative_path: raise ValueError("relative_path cannot be empty") candidate = (project_dir / relative_path).resolve() try: candidate.relative_to(project_dir) except ValueError as exc: raise ValueError("File path escapes project workspace") from exc return candidate # --------------------------------------------------------- # Project lifecycle # --------------------------------------------------------- async def ensure_project_dir(self, project_id: str) -> Path: project_dir = self._project_dir(project_id) project_dir.mkdir(parents=True, exist_ok=True) await self._log_activity( project_id, "Project preview workspace initialized.", ) return project_dir async def create_project( self, project_id: str, files: Optional[Dict[str, str]] = None, ) -> Dict[str, Any]: project_dir = await self.ensure_project_dir(project_id) if files: for relative_path, content in files.items(): await self.save_file( project_id, relative_path, content, ) return await self.get_status(project_id) async def delete_project(self, project_id: str) -> bool: project_dir = self._project_dir(project_id) if not project_dir.exists(): return False shutil.rmtree(project_dir) await self._log_activity( project_id, "Project preview workspace deleted.", ) return True # --------------------------------------------------------- # File operations # --------------------------------------------------------- async def save_file( self, project_id: str, relative_path: str, content: Any, ) -> Path: target = self._resolve_project_file( project_id, relative_path, ) target.parent.mkdir(parents=True, exist_ok=True) if isinstance(content, bytes): target.write_bytes(content) elif isinstance(content, str): target.write_text( content, encoding="utf-8", ) else: raise TypeError( "File content must be str or bytes" ) await self._log_activity( project_id, f"File saved: {relative_path}", ) return target async def read_file( self, project_id: str, relative_path: str, ) -> str: target = self._resolve_project_file( project_id, relative_path, ) if not target.is_file(): raise FileNotFoundError( f"Project file not found: {relative_path}" ) return target.read_text(encoding="utf-8") async def delete_file( self, project_id: str, relative_path: str, ) -> bool: target = self._resolve_project_file( project_id, relative_path, ) if not target.exists(): return False if target.is_dir(): shutil.rmtree(target) else: target.unlink() await self._log_activity( project_id, f"File deleted: {relative_path}", ) return True async def list_files( self, project_id: str, ) -> List[str]: project_dir = await self.ensure_project_dir(project_id) files: List[str] = [] for path in project_dir.rglob("*"): if not path.is_file(): continue relative = path.relative_to(project_dir) if any(part.startswith(".") for part in relative.parts): continue files.append(relative.as_posix()) return sorted(files) # --------------------------------------------------------- # Workspace status # --------------------------------------------------------- async def get_status( self, project_id: str, ) -> Dict[str, Any]: project_dir = await self.ensure_project_dir(project_id) files = await self.list_files(project_id) package_json = project_dir / "package.json" framework = "unknown" if package_json.is_file(): try: package = json.loads( package_json.read_text(encoding="utf-8") ) dependencies = { **package.get("dependencies", {}), **package.get("devDependencies", {}), } if "next" in dependencies: framework = "next" elif "vite" in dependencies: framework = "vite" elif "react" in dependencies: framework = "react" elif "vue" in dependencies: framework = "vue" elif "svelte" in dependencies: framework = "svelte" else: framework = "node" except Exception: framework = "node" return { "project_id": project_id, "project_root": str(project_dir), "exists": project_dir.exists(), "source_files": len(files), "files": files, "framework": framework, "package_json": package_json.exists(), "node_modules": (project_dir / "node_modules").exists(), "dist": (project_dir / "dist").exists(), ".next": (project_dir / ".next").exists(), "timestamp": datetime.now(timezone.utc).isoformat(), } # --------------------------------------------------------- # Build / rebuild # --------------------------------------------------------- def _run_command( self, command: List[str], cwd: Path, timeout: int = 900, ) -> Dict[str, Any]: logger.info( "Running preview command: %s", " ".join(command), ) completed = subprocess.run( command, cwd=str(cwd), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=timeout, check=False, env=os.environ.copy(), ) return { "returncode": completed.returncode, "output": completed.stdout, "command": command, } def _package_manager(self, project_dir: Path) -> Optional[str]: if (project_dir / "pnpm-lock.yaml").is_file(): return "pnpm" if (project_dir / "yarn.lock").is_file(): return "yarn" if (project_dir / "package-lock.json").is_file(): return "npm" if (project_dir / "package.json").is_file(): return "npm" return None async def rebuild_project( self, project_id: str, ) -> Dict[str, Any]: project_dir = await self.ensure_project_dir(project_id) package_json = project_dir / "package.json" if not package_json.is_file(): return { "status": "success", "project_id": project_id, "framework": "static", "log": "No package.json found. Static workspace requires no Node build.", } try: package = json.loads( package_json.read_text(encoding="utf-8") ) except Exception as exc: raise RuntimeError( f"Invalid package.json: {exc}" ) from exc scripts = package.get("scripts") or {} if "build" not in scripts: return { "status": "success", "project_id": project_id, "framework": "node", "log": "package.json contains no build script. Workspace accepted without compilation.", } manager = self._package_manager(project_dir) if manager == "pnpm": executable = shutil.which("pnpm") if not executable: raise RuntimeError( "pnpm-lock.yaml exists but pnpm executable is unavailable." ) install = ["pnpm", "install", "--frozen-lockfile"] build = ["pnpm", "run", "build"] elif manager == "yarn": executable = shutil.which("yarn") if not executable: raise RuntimeError( "yarn.lock exists but yarn executable is unavailable." ) install = ["yarn", "install", "--frozen-lockfile"] build = ["yarn", "build"] else: executable = shutil.which("npm") if not executable: raise RuntimeError( "npm executable is unavailable." ) if (project_dir / "package-lock.json").is_file(): install = [ "npm", "ci", "--legacy-peer-deps", ] else: install = [ "npm", "install", "--legacy-peer-deps", ] build = [ "npm", "run", "build", ] install_result = await asyncio.to_thread( self._run_command, install, project_dir, ) if install_result["returncode"] != 0: raise RuntimeError( "Dependency installation failed:\n" + install_result["output"] ) build_result = await asyncio.to_thread( self._run_command, build, project_dir, ) if build_result["returncode"] != 0: raise RuntimeError( "Project build failed:\n" + build_result["output"] ) combined = ( "Dependency installation:\n" + install_result["output"] + "\n\nBuild:\n" + build_result["output"] ) await self._log_activity( project_id, "Project rebuild completed successfully.", ) return { "status": "success", "project_id": project_id, "package_manager": manager, "log": combined, } # --------------------------------------------------------- # Artifact generation # --------------------------------------------------------- async def _zip_project( self, project_id: str, include_hidden: bool = False, ) -> Path: project_dir = await self.ensure_project_dir(project_id) timestamp = datetime.now(timezone.utc).strftime( "%Y%m%d-%H%M%S" ) artifact_dir = ( self.artifact_dir / project_id ) artifact_dir.mkdir( parents=True, exist_ok=True, ) output = ( artifact_dir / f"{project_id}-{timestamp}.zip" ) def write_zip() -> None: with zipfile.ZipFile( output, "w", compression=zipfile.ZIP_DEFLATED, ) as archive: for path in project_dir.rglob("*"): if not path.is_file(): continue relative = path.relative_to(project_dir) if not include_hidden and any( part.startswith(".") for part in relative.parts ): continue archive.write( path, arcname=relative.as_posix(), ) await asyncio.to_thread(write_zip) return output async def generate_source_zip( self, project_id: str, ) -> Path: project_dir = await self.ensure_project_dir(project_id) timestamp = datetime.now(timezone.utc).strftime( "%Y%m%d-%H%M%S" ) artifact_dir = ( self.artifact_dir / project_id ) artifact_dir.mkdir( parents=True, exist_ok=True, ) output = ( artifact_dir / f"{project_id}-source-{timestamp}.zip" ) def write_source_zip() -> None: excluded = { "node_modules", ".next", "dist", "build", } with zipfile.ZipFile( output, "w", compression=zipfile.ZIP_DEFLATED, ) as archive: for path in project_dir.rglob("*"): if not path.is_file(): continue relative = path.relative_to(project_dir) if any( part in excluded for part in relative.parts ): continue if any( part.startswith(".") for part in relative.parts ): continue archive.write( path, arcname=relative.as_posix(), ) await asyncio.to_thread(write_source_zip) await self._log_activity( project_id, f"Source artifact generated: {output.name}", ) return output async def generate_project_zip( self, project_id: str, ) -> Path: output = await self._zip_project( project_id, include_hidden=False, ) await self._log_activity( project_id, f"Project artifact generated: {output.name}", ) return output # --------------------------------------------------------- # Compatibility helpers # --------------------------------------------------------- async def preview_project( self, project_id: str, ) -> Dict[str, Any]: return await self.get_status(project_id) async def initialize_project( self, project_id: str, ) -> Dict[str, Any]: return await self.create_project(project_id) async def project_exists( self, project_id: str, ) -> bool: return self._project_dir(project_id).is_dir() # --------------------------------------------------------- # Activity logging # --------------------------------------------------------- async def _log_activity( self, project_id: str, message: str, ) -> None: project_id = self._validate_project_id(project_id) activity_file = ( self.activity_dir / f"{project_id}.log" ) timestamp = datetime.now(timezone.utc).isoformat() line = f"{timestamp} {message}\n" activity_file.parent.mkdir( parents=True, exist_ok=True, ) def write_activity() -> None: with activity_file.open( "a", encoding="utf-8", ) as handle: handle.write(line) await asyncio.to_thread(write_activity) async def generate_apk(self, project_id: str) -> Path: root = await self.ensure_project_dir(project_id) apk_dir = self.artifact_dir / project_id apk_dir.mkdir(parents=True, exist_ok=True) apk = apk_dir / f"{project_id}.apk" native_builder = root / "android" if not native_builder.exists(): raise RuntimeError( f"Android project not found for project '{project_id}'" ) from native_apk_builder import build_native_apk result = await build_native_apk( project_root=native_builder, output_apk=apk, ) if isinstance(result, (str, Path)): apk = Path(result) if not apk.is_file() or apk.stat().st_size == 0: raise RuntimeError("APK build completed without a valid APK artifact") return apk preview_workspace = PreviewWorkspace()