David Prince
production: clean source snapshot — no history bloat
71b4454
Raw
History Blame Contribute Delete
19.9 kB
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()