| 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) |
|
|
| |
| |
| |
|
|
| 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 |
|
|
| |
| |
| |
|
|
| 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 |
|
|
| |
| |
| |
|
|
| 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) |
|
|
| |
| |
| |
|
|
| 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(), |
| } |
|
|
| |
| |
| |
|
|
| 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, |
| } |
|
|
| |
| |
| |
|
|
| 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 |
|
|
| |
| |
| |
|
|
| 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() |
|
|
| |
| |
| |
|
|
| 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() |
|
|