""" DSS API Server REST API for design system operations. Endpoints: - Project management (CRUD) - Figma integration (token extraction, component sync) - Health checks - Activity tracking - Configuration management - Service discovery Modes: - Server: Remote deployment, team distribution - Local: Development companion """ # Load environment variables from .env file FIRST (before any other imports) import os from pathlib import Path from dotenv import load_dotenv # Get project root - tools/api/server.py -> tools/api -> tools -> project_root _server_file = Path(__file__).resolve() _project_root = _server_file.parent.parent.parent # /home/.../dss # Try loading from multiple possible .env locations env_paths = [ _project_root / "dss-mvp1" / ".env", # dss-mvp1/.env (primary) _project_root / ".env", # root .env _server_file.parent / ".env", # tools/api/.env ] for env_path in env_paths: if env_path.exists(): load_dotenv(env_path, override=True) break import asyncio import subprocess import json from typing import Optional, List, Dict, Any from datetime import datetime from fastapi import FastAPI, HTTPException, Query, BackgroundTasks, Depends, Header from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from typing import Optional import sys # Add tools directory to path (legacy imports) sys.path.insert(0, str(Path(__file__).parent.parent)) # Add dss-mvp1 directory to path (consolidated dss package) sys.path.insert(0, str(Path(__file__).parent.parent.parent / "dss-mvp1")) # Import browser logger router from browser_logger import router as browser_log_router # Legacy imports (will gradually migrate these) from config import config from storage.json_store import ( Projects, Components, SyncHistory, ActivityLog, Teams, Cache, get_stats, FigmaFiles, CodeMetrics, TestResults, TokenDrift, Tokens, Styles, Integrations, IntegrationHealth ) from figma.figma_tools import FigmaToolSuite # New consolidated dss imports - now available! # from dss import DesignToken, TokenSource, ProjectScanner, etc. # from dss.ingest import CSSTokenSource, SCSSTokenSource, TailwindTokenSource # from dss.analyze import ReactAnalyzer, StyleAnalyzer, QuickWinFinder # from dss.storybook import StorybookScanner, StoryGenerator # MVP1 Configuration Architecture - Services from services.project_manager import ProjectManager from services.config_service import ConfigService, DSSConfig from services.sandboxed_fs import SandboxedFS # === Runtime Configuration === class RuntimeConfig: """ ⚙️ ENDOCRINE HORMONE STORAGE - Runtime configuration system The endocrine system regulates behavior through hormones. This configuration manager stores the component's behavioral preferences and adaptation state. Persists to .dss/runtime-config.json so the component remembers its preferences even after sleep (shutdown). """ def __init__(self): self.config_path = Path(__file__).parent.parent.parent / ".dss" / "runtime-config.json" self.config_path.parent.mkdir(parents=True, exist_ok=True) self._data = self._load() def _load(self) -> dict: if self.config_path.exists(): try: return json.loads(self.config_path.read_text()) except (json.JSONDecodeError, IOError) as e: # Config file corrupted or unreadable, use defaults pass return { "mode": "local", # "local" or "server" "figma": {"token": "", "configured": False}, "services": { "storybook": {"enabled": False, "port": 6006, "url": ""}, "chromatic": {"enabled": False, "project_token": ""}, "github": {"enabled": False, "repo": ""}, }, "features": { "visual_qa": True, "token_sync": True, "code_gen": True, "ai_advisor": False, } } def _save(self): self.config_path.write_text(json.dumps(self._data, indent=2)) def get(self, key: str = None): if key is None: # Return safe copy without secrets safe = self._data.copy() if safe.get("figma", {}).get("token"): safe["figma"]["token"] = "***configured***" return safe return self._data.get(key) def set(self, key: str, value: Any): self._data[key] = value self._save() return self._data[key] def update(self, updates: dict): for key, value in updates.items(): if isinstance(value, dict) and isinstance(self._data.get(key), dict): self._data[key].update(value) else: self._data[key] = value self._save() return self.get() def set_figma_token(self, token: str): self._data["figma"]["token"] = token self._data["figma"]["configured"] = bool(token) self._save() # Also update the global config os.environ["FIGMA_TOKEN"] = token return {"configured": bool(token)} runtime_config = RuntimeConfig() # === MVP1 Services Initialization === # Initialize services for project configuration architecture config_service = ConfigService() project_manager = ProjectManager(Projects, config_service) # Ensure database schema is up to date (adds root_path column if missing) ProjectManager.ensure_schema() # === Service Discovery === class ServiceDiscovery: """ Service discovery for companion services (Storybook, Chromatic, dev servers). Checks known ports to discover running services. """ KNOWN_SERVICES = { "storybook": {"ports": [6006, 6007], "health": "/"}, "chromatic": {"ports": [], "health": None}, "vite": {"ports": [5173, 5174, 3000], "health": "/"}, "webpack": {"ports": [8080, 8081], "health": "/"}, "nextjs": {"ports": [3000, 3001], "health": "/"}, } @classmethod async def discover(cls) -> dict: """Discover running services by checking known ports.""" import socket discovered = {} for service, info in cls.KNOWN_SERVICES.items(): for port in info["ports"]: try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(0.5) result = sock.connect_ex(('127.0.0.1', port)) sock.close() if result == 0: discovered[service] = { "running": True, "port": port, "url": f"http://localhost:{port}" } break except (OSError, socket.error): # Service not running on this port pass if service not in discovered: discovered[service] = {"running": False, "port": None, "url": None} return discovered @classmethod async def check_storybook(cls) -> dict: """Check Storybook status specifically.""" import httpx configured = runtime_config.get("services").get("storybook", {}) port = configured.get("port", 6006) url = configured.get("url") or f"http://localhost:{port}" try: async with httpx.AsyncClient(timeout=2.0) as client: resp = await client.get(url) return { "running": resp.status_code == 200, "url": url, "port": port } except (httpx.ConnectError, httpx.TimeoutException, httpx.HTTPError): # Storybook not running or unreachable return {"running": False, "url": url, "port": port} # === App Setup === app = FastAPI( title="Design System Server (DSS)", description="API for design system management and Figma integration", version="1.0.0" ) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Include browser logger router for console log forwarding app.include_router(browser_log_router) # Mount Admin UI static files UI_DIR = Path(__file__).parent.parent.parent / "admin-ui" if UI_DIR.exists(): app.mount("/admin-ui", StaticFiles(directory=str(UI_DIR), html=True), name="admin-ui") # Initialize Figma tools with token from runtime config figma_config = runtime_config.get("figma") figma_token_at_startup = figma_config.get("token") if figma_config else None figma_suite = FigmaToolSuite( token=figma_token_at_startup, output_dir=str(Path(__file__).parent.parent.parent / ".dss" / "output") ) # === Request/Response Models === class ProjectCreate(BaseModel): name: str description: str = "" figma_file_key: str = "" root_path: str = "" # MVP1: Project root directory path class ProjectUpdate(BaseModel): name: Optional[str] = None description: Optional[str] = None figma_file_key: Optional[str] = None status: Optional[str] = None root_path: Optional[str] = None # MVP1: Update project root path class FigmaExtractRequest(BaseModel): file_key: str format: str = "css" class FigmaSyncRequest(BaseModel): file_key: str target_path: str format: str = "css" class TeamCreate(BaseModel): name: str description: str = "" class FigmaFileCreate(BaseModel): figma_url: str file_name: str file_key: str class ESRECreate(BaseModel): name: str definition_text: str expected_value: Optional[str] = None component_name: Optional[str] = None class TokenDriftCreate(BaseModel): component_id: str property_name: str hardcoded_value: str file_path: str line_number: int severity: str = "warning" suggested_token: Optional[str] = None # === Authentication === from auth.atlassian_auth import get_auth async def get_current_user(authorization: Optional[str] = Header(None)) -> Dict[str, Any]: """ Dependency to get current authenticated user from JWT token. Usage: user = Depends(get_current_user) """ if not authorization or not authorization.startswith("Bearer "): raise HTTPException(status_code=401, detail="Not authenticated") token = authorization.replace("Bearer ", "") auth = get_auth() user_data = auth.verify_token(token) if not user_data: raise HTTPException(status_code=401, detail="Invalid or expired token") return user_data class LoginRequest(BaseModel): url: str # Atlassian URL email: str api_token: str service: str = "jira" # "jira" or "confluence" @app.post("/api/auth/login") async def login(request: LoginRequest): """ Authenticate with Atlassian credentials. Validates credentials against Jira or Confluence API, creates/updates user in database, returns JWT token. """ try: auth = get_auth() result = await auth.login( url=request.url, email=request.email, api_token=request.api_token, service=request.service ) return result except ValueError as e: raise HTTPException(status_code=401, detail=str(e)) except Exception as e: raise HTTPException(status_code=500, detail=f"Login failed: {str(e)}") @app.get("/api/auth/me") async def get_me(user: Dict[str, Any] = Depends(get_current_user)): """Get current authenticated user info""" auth = get_auth() user_data = await auth.get_user_by_id(user["user_id"]) if not user_data: raise HTTPException(status_code=404, detail="User not found") return user_data # === Root & Health === @app.get("/") async def root(): """Redirect to Admin UI dashboard.""" from fastapi.responses import RedirectResponse return RedirectResponse(url="/admin-ui/index.html") @app.get("/health") async def health(): """ 🏥 ORGANISM VITAL SIGNS CHECK Performs a complete health diagnostic on the DSS component. Returns 200 OK with vital signs if component is healthy. Vital Signs Checked: - ❤️ Heart (Database) - Is the source of truth responsive? - 🧠 Brain (MCP Handler) - Is the decision-making system online? - 👁️ Sensory (Figma) - Are the external perception organs configured? """ import os import psutil from pathlib import Path # ❤️ Check Heart (storage) connectivity db_ok = False try: from storage.json_store import DATA_DIR db_ok = DATA_DIR.exists() except Exception as e: import traceback error_trace = traceback.format_exc() print(f"🏥 VITAL SIGN: Heart (storage) error: {type(e).__name__}: {e}", flush=True) print(f" Traceback:\n{error_trace}", flush=True) pass # 🧠 Check Brain (MCP handler) functionality mcp_ok = False try: import sys from pathlib import Path # Add project root to path (two levels up from tools/api) project_root = Path(__file__).parent.parent.parent if str(project_root) not in sys.path: sys.path.insert(0, str(project_root)) from tools.dss_mcp.handler import get_mcp_handler handler = get_mcp_handler() mcp_ok = handler is not None except Exception as e: import traceback error_trace = ''.join(traceback.format_exception(type(e), e, e.__traceback__)) print(f"🧠 BRAIN CHECK: MCP handler error: {type(e).__name__}: {e}", flush=True) print(f" Traceback:\n{error_trace}", flush=True) # Get uptime (how long component has been conscious) try: process = psutil.Process(os.getpid()) uptime_seconds = int((datetime.now() - datetime.fromtimestamp(process.create_time())).total_seconds()) except: uptime_seconds = 0 # Overall vitality assessment status = "healthy" if (db_ok and mcp_ok) else "degraded" return { "status": status, "vital_signs": { "overall": "🟢 All systems nominal" if status == "healthy" else "🟡 System degradation detected", "consciousness_duration_seconds": uptime_seconds }, "version": "0.8.0", "timestamp": datetime.utcnow().isoformat() + "Z", "services": { "storage": "ok" if db_ok else "error", "mcp": "ok" if mcp_ok else "error", "figma": "connected" if config.figma.is_configured else "not configured" } } # === DEBUG ENDPOINTS === @app.post("/api/browser-logs") async def receive_browser_logs(logs: dict): """ 📋 BROWSER LOG COLLECTION ENDPOINT Receives browser logs from the dashboard and stores them for debugging. Browser logger (browser-logger.js) POSTs logs here automatically or on demand. Expected payload: { "sessionId": "session-timestamp-random", "exportedAt": "ISO timestamp", "logs": [...], "diagnostic": {...} } """ from pathlib import Path import time # Create browser logs directory if doesn't exist browser_logs_dir = Path(__file__).parent.parent.parent / ".dss" / "browser-logs" browser_logs_dir.mkdir(parents=True, exist_ok=True) # Get or generate session ID session_id = logs.get("sessionId", f"session-{int(time.time())}") # Store logs as JSON file log_file = browser_logs_dir / f"{session_id}.json" log_file.write_text(json.dumps(logs, indent=2)) # Log to activity (skip if ActivityLog not available) try: with get_connection() as conn: conn.execute(""" INSERT INTO activity_log (category, action, details, metadata, created_at) VALUES (?, ?, ?, ?, ?) """, ("debug", "browser_logs_received", f"Received browser logs for session {session_id}", json.dumps({"session_id": session_id, "log_count": len(logs.get("logs", []))}), datetime.utcnow().isoformat())) conn.commit() except: pass # Activity logging is optional # Check for errors and create notification task error_count = logs.get("diagnostic", {}).get("errorCount", 0) warn_count = logs.get("diagnostic", {}).get("warnCount", 0) if error_count > 0 or warn_count > 0: # Create task for Claude to investigate try: import httpx task_data = { "title": f"Browser errors detected in session {session_id[:20]}...", "description": f"Detected {error_count} errors and {warn_count} warnings in browser session. Use dss_get_browser_errors('{session_id}') to investigate.", "priority": 3 if error_count > 0 else 5, "project": "dss-debug", "visibility": "public" } # Create task via task-queue MCP HTTP endpoint (if available) # This runs async - don't block browser log storage import asyncio async def create_task(): try: async with httpx.AsyncClient() as client: # Task queue typically runs on same server await client.post( "http://localhost:8765/tasks", json=task_data, timeout=2.0 ) except: pass # Task creation is best-effort # Run in background asyncio.create_task(create_task()) except: pass # Task creation is optional return { "status": "stored", "sessionId": session_id, "logCount": len(logs.get("logs", [])), "storedAt": datetime.utcnow().isoformat() + "Z", "errorsDetected": error_count > 0 or warn_count > 0 } @app.get("/api/browser-logs/{session_id}") async def get_browser_logs(session_id: str): """ 📋 RETRIEVE BROWSER LOGS Retrieves stored browser logs by session ID. """ from pathlib import Path browser_logs_dir = Path(__file__).parent.parent.parent / ".dss" / "browser-logs" log_file = browser_logs_dir / f"{session_id}.json" if not log_file.exists(): raise HTTPException(status_code=404, detail=f"Session not found: {session_id}") logs = json.loads(log_file.read_text()) return logs @app.get("/api/debug/diagnostic") async def get_debug_diagnostic(): """ 🔍 COMPREHENSIVE SYSTEM DIAGNOSTIC Returns detailed system diagnostic including: - Health status (from /health endpoint) - Browser log session count - API uptime - Database size and stats - Memory usage - Recent errors """ import os import psutil from pathlib import Path # Get health status health_status = await health() # Get browser log sessions browser_logs_dir = Path(__file__).parent.parent.parent / ".dss" / "browser-logs" browser_logs_dir.mkdir(parents=True, exist_ok=True) browser_sessions = len(list(browser_logs_dir.glob("*.json"))) # Get database size db_path = Path(__file__).parent.parent.parent / ".dss" / "dss.db" db_size_bytes = db_path.stat().st_size if db_path.exists() else 0 # Get process stats process = psutil.Process(os.getpid()) memory_info = process.memory_info() # Get recent errors from activity log try: with get_connection() as conn: recent_errors = conn.execute(""" SELECT category, action, details, created_at FROM activity_log WHERE category = 'error' OR action LIKE '%error%' OR action LIKE '%fail%' ORDER BY created_at DESC LIMIT 10 """).fetchall() recent_errors = [ { "category": row[0], "action": row[1], "details": row[2], "timestamp": row[3] } for row in recent_errors ] except: recent_errors = [] return { "status": health_status["status"], "timestamp": datetime.utcnow().isoformat() + "Z", "health": health_status, "browser": { "session_count": browser_sessions, "logs_directory": str(browser_logs_dir) }, "database": { "size_bytes": db_size_bytes, "size_mb": round(db_size_bytes / 1024 / 1024, 2), "path": str(db_path) }, "process": { "pid": os.getpid(), "memory_rss_mb": round(memory_info.rss / 1024 / 1024, 2), "memory_vms_mb": round(memory_info.vms / 1024 / 1024, 2), "threads": process.num_threads() }, "recent_errors": recent_errors } @app.get("/api/debug/workflows") async def list_workflows(): """ 📋 LIST AVAILABLE DEBUG WORKFLOWS Returns list of available workflows from .dss/WORKFLOWS/ directory. Each workflow is a markdown file with step-by-step debugging procedures. """ from pathlib import Path workflows_dir = Path(__file__).parent.parent.parent / ".dss" / "WORKFLOWS" if not workflows_dir.exists(): return {"workflows": [], "count": 0} workflows = [] for workflow_file in sorted(workflows_dir.glob("*.md")): if workflow_file.name == "README.md": continue # Read first few lines for metadata content = workflow_file.read_text() lines = content.split("\n") # Extract title (first # heading) title = workflow_file.stem for line in lines[:10]: if line.startswith("# "): title = line[2:].strip() break # Extract purpose purpose = "" for i, line in enumerate(lines[:20]): if line.startswith("**Purpose**:"): purpose = line.replace("**Purpose**:", "").strip() break workflows.append({ "id": workflow_file.stem, "title": title, "purpose": purpose, "file": workflow_file.name, "path": str(workflow_file) }) return { "workflows": workflows, "count": len(workflows), "directory": str(workflows_dir) } @app.get("/api/config") async def get_config(): """ Public configuration endpoint. Returns ONLY safe, non-sensitive configuration values that are safe to expose to the client browser. SECURITY: This endpoint is the ONLY place where configuration is exposed. All other config values (secrets, API keys, etc.) must be server-only. """ # Import here to avoid circular imports try: from config import get_public_config return get_public_config() except ImportError: # Fallback for legacy deployments return { "dssHost": os.environ.get("DSS_HOST", "localhost"), "dssPort": os.environ.get("DSS_PORT", "3456"), "storybookPort": 6006, } @app.get("/api/stats") async def get_statistics(): """Get database and system statistics.""" db_stats = get_stats() return { "database": db_stats, "figma": { "mode": figma_suite.mode, "configured": config.figma.is_configured } } # === Projects === @app.get("/api/projects") async def list_projects(status: Optional[str] = None): """List all projects.""" projects = Projects.list(status=status) return projects @app.get("/api/projects/{project_id}") async def get_project(project_id: str): """Get a specific project.""" project = Projects.get(project_id) if not project: raise HTTPException(status_code=404, detail="Project not found") return project @app.post("/api/projects") async def create_project(project: ProjectCreate): """Create a new project.""" project_id = f"proj-{int(datetime.utcnow().timestamp() * 1000)}" created = Projects.create( id=project_id, name=project.name, description=project.description, figma_file_key=project.figma_file_key ) ActivityLog.log( action="project_created", entity_type="project", entity_id=project_id, project_id=project_id, details={"name": project.name} ) return created @app.put("/api/projects/{project_id}") async def update_project(project_id: str, update: ProjectUpdate): """Update a project.""" existing = Projects.get(project_id) if not existing: raise HTTPException(status_code=404, detail="Project not found") update_data = {k: v for k, v in update.dict().items() if v is not None} if not update_data: return existing updated = Projects.update(project_id, **update_data) ActivityLog.log( action="project_updated", entity_type="project", entity_id=project_id, project_id=project_id, details=update_data ) return updated @app.delete("/api/projects/{project_id}") async def delete_project(project_id: str): """Delete a project.""" if not Projects.delete(project_id): raise HTTPException(status_code=404, detail="Project not found") ActivityLog.log( action="project_deleted", entity_type="project", entity_id=project_id ) return {"success": True} # === Components === @app.get("/api/projects/{project_id}/components") async def list_components(project_id: str): """List components for a project.""" if not Projects.get(project_id): raise HTTPException(status_code=404, detail="Project not found") return Components.list(project_id) # === Figma Integration === @app.post("/api/figma/extract-variables") async def extract_variables(request: FigmaExtractRequest, background_tasks: BackgroundTasks): """Extract design tokens from Figma variables.""" try: result = await figma_suite.extract_variables(request.file_key, request.format) ActivityLog.log( action="figma_extract_variables", entity_type="figma", details={"file_key": request.file_key, "format": request.format, "tokens_count": result.get("tokens_count")} ) return result except Exception as e: raise HTTPException(status_code=500, detail=f"Token extraction failed: {str(e)}") @app.post("/api/figma/extract-components") async def extract_components(request: FigmaExtractRequest): """Extract component definitions from Figma.""" try: result = await figma_suite.extract_components(request.file_key) ActivityLog.log( action="figma_extract_components", entity_type="figma", details={"file_key": request.file_key, "count": result.get("components_count")} ) return result except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.post("/api/figma/extract-styles") async def extract_styles(request: FigmaExtractRequest): """Extract style definitions from Figma.""" try: result = await figma_suite.extract_styles(request.file_key) return result except Exception as e: raise HTTPException(status_code=500, detail=f"Style extraction failed: {str(e)}") @app.post("/api/figma/sync-tokens") async def sync_tokens(request: FigmaSyncRequest): """Sync tokens from Figma to target file.""" try: result = await figma_suite.sync_tokens(request.file_key, request.target_path, request.format) ActivityLog.log( action="figma_sync_tokens", entity_type="figma", details={"file_key": request.file_key, "target": request.target_path, "tokens_synced": result.get("tokens_synced")} ) return result except Exception as e: raise HTTPException(status_code=500, detail=f"Token sync failed: {str(e)}") @app.post("/api/figma/validate") async def validate_components(request: FigmaExtractRequest): """Validate component definitions against design system rules.""" try: result = await figma_suite.validate_components(request.file_key) return result except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.post("/api/figma/generate-code") async def generate_code(file_key: str, component_name: str, framework: str = "webcomponent"): """Generate component code from Figma.""" try: result = await figma_suite.generate_code(file_key, component_name, framework) return result except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.get("/api/figma/health") async def figma_health(): """Check Figma connection status.""" is_live = figma_suite.mode == 'live' return { "status": "ok" if is_live else "degraded", "mode": figma_suite.mode, "message": "Figma connected" if is_live else "Running in mock mode. Configure FIGMA_TOKEN for live API." } # === Discovery === @app.get("/api/discovery") async def run_discovery(path: str = "."): """Run project discovery.""" script_path = Path(__file__).parent.parent / "discovery" / "discover.sh" try: result = subprocess.run( [str(script_path), path], capture_output=True, text=True, timeout=30 ) if result.returncode == 0: return json.loads(result.stdout) else: return {"error": result.stderr} except subprocess.TimeoutExpired: raise HTTPException(status_code=504, detail="Discovery timed out") except json.JSONDecodeError: return {"raw_output": result.stdout} class DiscoveryScanRequest(BaseModel): path: str = "." full_scan: bool = False @app.post("/api/discovery/scan") async def scan_project(request: DiscoveryScanRequest): """Run project discovery scan.""" script_path = Path(__file__).parent.parent / "discovery" / "discover.sh" try: result = subprocess.run( [str(script_path), request.path], capture_output=True, text=True, timeout=30 ) if result.returncode == 0: data = json.loads(result.stdout) ActivityLog.log( action="discovery_scan", entity_type="project", details={"path": request.path, "full_scan": request.full_scan} ) return data else: return {"error": result.stderr} except subprocess.TimeoutExpired: raise HTTPException(status_code=504, detail="Discovery timed out") except json.JSONDecodeError: return {"raw_output": result.stdout} @app.get("/api/discovery/stats") async def get_discovery_stats(): """Get project statistics.""" db_stats = get_stats() return { "projects": db_stats.get("projects", {}), "tokens": db_stats.get("tokens", {"total": 0}), "components": db_stats.get("components", {"total": 0}), "syncs": { "today": 0, "this_week": 0, "total": db_stats.get("syncs", {}).get("total", 0), "last_sync": None }, "stories": { "total": 0 } } @app.get("/api/discovery/activity") async def get_discovery_activity(limit: int = Query(default=10, le=50)): """Get recent discovery activity.""" return ActivityLog.recent(limit=limit) @app.get("/api/discovery/ports") async def discover_ports(): """Discover listening ports and services.""" script_path = Path(__file__).parent.parent / "discovery" / "discover-ports.sh" try: result = subprocess.run( [str(script_path)], capture_output=True, text=True, timeout=10 ) return json.loads(result.stdout) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.get("/api/discovery/env") async def discover_env(path: str = "."): """Analyze environment configuration.""" script_path = Path(__file__).parent.parent / "discovery" / "discover-env.sh" try: result = subprocess.run( [str(script_path), path], capture_output=True, text=True, timeout=10 ) return json.loads(result.stdout) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) # === Activity & Sync History === @app.get("/api/activity") async def get_activity(limit: int = Query(default=50, le=100)): """Get recent activity log.""" return ActivityLog.recent(limit=limit) @app.get("/api/sync-history") async def get_sync_history(project_id: Optional[str] = None, limit: int = Query(default=20, le=100)): """Get sync history.""" return SyncHistory.recent(project_id=project_id, limit=limit) # === Audit Log (Enhanced) === @app.get("/api/audit") async def get_audit_log( project_id: Optional[str] = None, user_id: Optional[str] = None, action: Optional[str] = None, category: Optional[str] = None, entity_type: Optional[str] = None, severity: Optional[str] = None, start_date: Optional[str] = None, end_date: Optional[str] = None, limit: int = Query(default=50, le=200), offset: int = Query(default=0, ge=0) ): """ Get audit log with advanced filtering. Query parameters: - project_id: Filter by project - user_id: Filter by user - action: Filter by specific action - category: Filter by category (design_system, code, configuration, etc.) - entity_type: Filter by entity type (project, component, token, etc.) - severity: Filter by severity (info, warning, critical) - start_date: Filter from date (ISO format) - end_date: Filter to date (ISO format) - limit: Number of results (max 200) - offset: Pagination offset """ activities = ActivityLog.search( project_id=project_id, user_id=user_id, action=action, category=category, entity_type=entity_type, severity=severity, start_date=start_date, end_date=end_date, limit=limit, offset=offset ) total = ActivityLog.count( project_id=project_id, user_id=user_id, action=action, category=category ) return { "activities": activities, "total": total, "limit": limit, "offset": offset, "has_more": (offset + limit) < total } @app.get("/api/audit/stats") async def get_audit_stats(): """Get audit log statistics.""" return { "by_category": ActivityLog.get_stats_by_category(), "by_user": ActivityLog.get_stats_by_user(), "total_count": ActivityLog.count() } @app.get("/api/audit/categories") async def get_audit_categories(): """Get list of all activity categories.""" return ActivityLog.get_categories() @app.get("/api/audit/actions") async def get_audit_actions(): """Get list of all activity actions.""" return ActivityLog.get_actions() class AuditLogRequest(BaseModel): action: str entity_type: Optional[str] = None entity_id: Optional[str] = None entity_name: Optional[str] = None project_id: Optional[str] = None user_id: Optional[str] = None user_name: Optional[str] = None team_context: Optional[str] = None description: Optional[str] = None category: Optional[str] = None severity: str = 'info' details: Optional[Dict[str, Any]] = None @app.post("/api/audit") async def create_audit_entry(entry: AuditLogRequest, request: Any): """ Create a new audit log entry. Automatically captures IP and user agent from request. """ # Extract IP and user agent from request ip_address = request.client.host if hasattr(request, 'client') else None user_agent = request.headers.get('user-agent') if hasattr(request, 'headers') else None ActivityLog.log( action=entry.action, entity_type=entry.entity_type, entity_id=entry.entity_id, entity_name=entry.entity_name, project_id=entry.project_id, user_id=entry.user_id, user_name=entry.user_name, team_context=entry.team_context, description=entry.description, category=entry.category, severity=entry.severity, details=entry.details, ip_address=ip_address, user_agent=user_agent ) return {"success": True, "message": "Audit entry created"} @app.get("/api/audit/export") async def export_audit_log( project_id: Optional[str] = None, category: Optional[str] = None, start_date: Optional[str] = None, end_date: Optional[str] = None, format: str = Query(default="json", regex="^(json|csv)$") ): """ Export audit log in JSON or CSV format. """ activities = ActivityLog.search( project_id=project_id, category=category, start_date=start_date, end_date=end_date, limit=10000 # Max export limit ) if format == "csv": import csv import io from fastapi.responses import StreamingResponse output = io.StringIO() if activities: fieldnames = ['created_at', 'user_name', 'action', 'category', 'description', 'project_id', 'entity_type', 'entity_name', 'severity'] writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction='ignore') writer.writeheader() writer.writerows(activities) output.seek(0) return StreamingResponse( iter([output.getvalue()]), media_type="text/csv", headers={"Content-Disposition": f"attachment; filename=audit_log_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.csv"} ) else: # JSON format return { "activities": activities, "total": len(activities), "exported_at": datetime.utcnow().isoformat() + "Z" } # === Teams === @app.get("/api/teams") async def list_teams(): """List all teams.""" return Teams.list() @app.post("/api/teams") async def create_team(team: TeamCreate): """Create a new team.""" team_id = f"team-{int(datetime.utcnow().timestamp() * 1000)}" created = Teams.create(team_id, team.name, team.description) return created @app.get("/api/teams/{team_id}") async def get_team(team_id: str): """Get a specific team.""" team = Teams.get(team_id) if not team: raise HTTPException(status_code=404, detail="Team not found") return team # === Cache Management === @app.post("/api/cache/clear") async def clear_cache(): """Clear expired cache entries.""" count = Cache.clear_expired() return {"cleared": count} @app.delete("/api/cache") async def purge_cache(): """Purge all cache entries.""" Cache.clear_all() return {"success": True} # === Configuration Management === class ConfigUpdate(BaseModel): mode: Optional[str] = None figma_token: Optional[str] = None services: Optional[Dict[str, Any]] = None features: Optional[Dict[str, bool]] = None @app.get("/api/config") async def get_config(): """Get current runtime configuration (secrets masked).""" return { "config": runtime_config.get(), "env": config.summary(), "mode": runtime_config.get("mode") } @app.put("/api/config") async def update_config(update: ConfigUpdate): """Update runtime configuration.""" updates = {} if update.mode: updates["mode"] = update.mode if update.figma_token is not None: runtime_config.set_figma_token(update.figma_token) # Reinitialize Figma tools with new token global figma_suite figma_suite = FigmaToolSuite( token=update.figma_token, output_dir=str(Path(__file__).parent.parent.parent / ".dss" / "output") ) ActivityLog.log( action="figma_token_updated", entity_type="config", details={"configured": bool(update.figma_token)} ) if update.services: updates["services"] = update.services if update.features: updates["features"] = update.features if updates: runtime_config.update(updates) ActivityLog.log( action="config_updated", entity_type="config", details={"keys": list(updates.keys())} ) return runtime_config.get() @app.get("/api/config/figma") async def get_figma_config(): """Get Figma configuration status.""" figma_cfg = runtime_config.get("figma") return { "configured": figma_cfg.get("configured", False), "mode": figma_suite.mode, "features": { "extract_variables": True, "extract_components": True, "extract_styles": True, "sync_tokens": True, "validate": True, "generate_code": True, } } @app.post("/api/config/figma/test") async def test_figma_connection(): """Test Figma API connection.""" try: # Try to make a simple API call if not runtime_config.get("figma").get("configured"): return {"success": False, "error": "Figma token not configured"} # Test with a minimal API call import httpx token = runtime_config._data["figma"]["token"] async with httpx.AsyncClient() as client: resp = await client.get( "https://api.figma.com/v1/me", headers={"X-Figma-Token": token} ) if resp.status_code == 200: user = resp.json() return { "success": True, "user": user.get("email", "connected"), "handle": user.get("handle") } else: return {"success": False, "error": f"API returned {resp.status_code}"} except Exception as e: return {"success": False, "error": str(e)} # === Service Discovery === @app.get("/api/services") async def list_services(): """List configured and discovered services.""" configured = runtime_config.get("services") discovered = await ServiceDiscovery.discover() return { "configured": configured, "discovered": discovered, "storybook": await ServiceDiscovery.check_storybook() } @app.put("/api/services/{service_name}") async def configure_service(service_name: str, config_data: Dict[str, Any]): """Configure a service.""" services = runtime_config.get("services") or {} services[service_name] = {**services.get(service_name, {}), **config_data} runtime_config.set("services", services) ActivityLog.log( action="service_configured", entity_type="service", entity_id=service_name, details={"keys": list(config_data.keys())} ) return services[service_name] @app.get("/api/services/storybook") async def get_storybook_status(): """Get Storybook service status.""" return await ServiceDiscovery.check_storybook() @app.post("/api/storybook/init") async def init_storybook(request_data: Dict[str, Any] = None): """ Initialize Storybook with design system components. Clears existing generated stories and generates new ones from the specified component source path. Request body (optional): source_path: Path to components directory (defaults to configured path) Returns: JSON with generation status and count """ import shutil import sys try: # Get paths dss_mvp1_path = Path(__file__).parent.parent.parent / "dss-mvp1" generated_dir = dss_mvp1_path / "stories" / "generated" # Default source path - can be overridden in request source_path = dss_mvp1_path / "dss" / "components" if request_data and request_data.get("source_path"): # Validate path is within allowed directories requested_path = Path(request_data["source_path"]).resolve() if not str(requested_path).startswith(str(dss_mvp1_path.resolve())): raise HTTPException(status_code=400, detail="Source path must be within dss-mvp1") source_path = requested_path # Step 1: Clear existing generated stories if generated_dir.exists(): for item in generated_dir.iterdir(): if item.name != ".gitkeep": if item.is_dir(): shutil.rmtree(item) else: item.unlink() else: generated_dir.mkdir(parents=True, exist_ok=True) # Step 2: Generate stories using StoryGenerator stories_generated = 0 errors = [] # Add dss-mvp1 to path for imports sys.path.insert(0, str(dss_mvp1_path)) try: from dss.storybook.generator import StoryGenerator, StoryTemplate generator = StoryGenerator(str(dss_mvp1_path)) # Check if source path exists and has components if source_path.exists(): results = await generator.generate_stories_for_directory( str(source_path.relative_to(dss_mvp1_path)), template=StoryTemplate.CSF3, dry_run=False ) # Move generated stories to stories/generated/ for result in results: if "story" in result and "error" not in result: story_filename = Path(result["component"]).stem + ".stories.js" output_path = generated_dir / story_filename output_path.write_text(result["story"]) stories_generated += 1 elif "error" in result: errors.append(result) else: # No components yet - that's okay, Storybook will show welcome pass except ImportError as e: # StoryGenerator not available - log but don't fail errors.append({"error": f"StoryGenerator import failed: {str(e)}"}) finally: # Clean up path if str(dss_mvp1_path) in sys.path: sys.path.remove(str(dss_mvp1_path)) ActivityLog.log( action="storybook_initialized", entity_type="storybook", details={ "stories_generated": stories_generated, "errors_count": len(errors) } ) return { "success": True, "stories_generated": stories_generated, "message": f"Generated {stories_generated} stories" if stories_generated > 0 else "Storybook initialized (no components found)", "errors": errors if errors else None } except HTTPException: raise except Exception as e: ActivityLog.log( action="storybook_init_failed", entity_type="storybook", details={"error": str(e)} ) raise HTTPException(status_code=500, detail=f"Storybook initialization failed: {str(e)}") @app.delete("/api/storybook/stories") async def clear_storybook_stories(): """ Clear all generated stories from Storybook. Returns Storybook to blank state (only Welcome page). """ import shutil try: dss_mvp1_path = Path(__file__).parent.parent.parent / "dss-mvp1" generated_dir = dss_mvp1_path / "stories" / "generated" cleared_count = 0 if generated_dir.exists(): for item in generated_dir.iterdir(): if item.name != ".gitkeep": if item.is_dir(): shutil.rmtree(item) else: item.unlink() cleared_count += 1 ActivityLog.log( action="storybook_cleared", entity_type="storybook", details={"cleared_count": cleared_count} ) return { "success": True, "cleared_count": cleared_count, "message": "Storybook stories cleared" } except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to clear stories: {str(e)}") # === Design System Ingestion === class IngestionRequest(BaseModel): """Request for design system ingestion via natural language.""" prompt: str project_id: Optional[str] = None class IngestionConfirmRequest(BaseModel): """Confirm ingestion of a specific design system.""" system_id: str method: str = "npm" # npm, figma, css, manual source_url: Optional[str] = None options: Optional[Dict[str, Any]] = {} @app.post("/api/ingest/parse") async def parse_ingestion_prompt(request: IngestionRequest): """ Parse a natural language ingestion prompt. Understands prompts like: - "add heroui" - "ingest material ui" - "import from figma.com/file/abc123" - "use shadcn for our design system" Returns parsed intent, detected design systems, and next steps. """ try: from ingestion_parser import parse_and_suggest result = parse_and_suggest(request.prompt) ActivityLog.log( action="ingestion_prompt_parsed", entity_type="ingestion", project_id=request.project_id, details={ "prompt": request.prompt[:100], "intent": result.get("intent"), "sources_found": len(result.get("sources", [])) } ) return result except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to parse prompt: {str(e)}") @app.get("/api/ingest/systems") async def list_known_systems( category: Optional[str] = None, framework: Optional[str] = None, search: Optional[str] = None ): """ List known design systems from the registry. Query params: - category: Filter by category (component-library, css-framework, design-system, css-tokens) - framework: Filter by framework (react, vue, angular, html) - search: Search by name or alias """ try: from design_system_registry import ( get_all_systems, get_systems_by_category, get_systems_by_framework, search_design_systems ) if search: systems = search_design_systems(search, limit=20) elif category: systems = get_systems_by_category(category) elif framework: systems = get_systems_by_framework(framework) else: systems = get_all_systems() return { "systems": [s.to_dict() for s in systems], "count": len(systems), "filters": { "category": category, "framework": framework, "search": search } } except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.get("/api/ingest/systems/{system_id}") async def get_system_info(system_id: str): """ Get detailed information about a specific design system. """ try: from design_system_registry import find_design_system, get_alternative_ingestion_options system = find_design_system(system_id) if not system: raise HTTPException(status_code=404, detail=f"Design system not found: {system_id}") alternatives = get_alternative_ingestion_options(system) return { "system": system.to_dict(), "alternatives": alternatives } except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.get("/api/ingest/npm/search") async def search_npm_packages( query: str, limit: int = Query(default=10, le=50), design_systems_only: bool = True ): """ Search npm registry for design system packages. Filters results to likely design system packages by default. """ try: from npm_search import search_npm results = await search_npm(query, limit=limit, design_systems_only=design_systems_only) return { "packages": [r.to_dict() for r in results], "count": len(results), "query": query, "design_systems_only": design_systems_only } except Exception as e: raise HTTPException(status_code=500, detail=f"npm search failed: {str(e)}") @app.get("/api/ingest/npm/package/{package_name:path}") async def get_npm_package_info(package_name: str): """ Get detailed information about an npm package. Package name can include scope (e.g., @heroui/react). """ try: from npm_search import get_package_info info = await get_package_info(package_name) if not info: raise HTTPException(status_code=404, detail=f"Package not found: {package_name}") return info.to_dict() except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.post("/api/ingest/confirm") async def confirm_ingestion(request: IngestionConfirmRequest): """ Confirm and execute design system ingestion. After parsing a prompt and getting user confirmation, this endpoint performs the actual ingestion. Supports multiple methods: - npm: Install npm packages and extract tokens - figma: Extract tokens from Figma URL - css: Fetch and parse CSS file - manual: Process manual token definitions """ try: from design_system_registry import find_design_system system = find_design_system(request.system_id) if not system: # Try to find via npm from npm_search import get_package_info npm_info = await get_package_info(request.system_id) if not npm_info: raise HTTPException( status_code=404, detail=f"Design system not found: {request.system_id}" ) # Execute ingestion based on method result = { "success": True, "system_id": request.system_id, "method": request.method, "status": "queued" } if request.method == "npm": # Queue npm package installation and token extraction packages = system.npm_packages if system else [request.system_id] result["packages"] = packages result["message"] = f"Will install: {', '.join(packages)}" result["next_steps"] = [ "Install npm packages", "Extract design tokens", "Generate Storybook stories", "Update token configuration" ] elif request.method == "figma": if not request.source_url: raise HTTPException( status_code=400, detail="Figma URL required for figma method" ) result["figma_url"] = request.source_url result["message"] = "Will extract tokens from Figma" result["next_steps"] = [ "Authenticate with Figma", "Extract design tokens", "Map to CSS variables", "Generate component stories" ] elif request.method == "css": if not request.source_url: # Use CDN URL if available if system and system.css_cdn_url: request.source_url = system.css_cdn_url else: raise HTTPException( status_code=400, detail="CSS URL required for css method" ) result["css_url"] = request.source_url result["message"] = "Will parse CSS for design tokens" result["next_steps"] = [ "Fetch CSS file", "Parse CSS variables", "Extract color/spacing/typography tokens", "Create token collection" ] elif request.method == "manual": result["message"] = "Manual token entry mode" result["next_steps"] = [ "Enter color tokens", "Enter typography tokens", "Enter spacing tokens", "Review and confirm" ] ActivityLog.log( action="ingestion_confirmed", entity_type="ingestion", entity_id=request.system_id, details={ "method": request.method, "status": "queued" } ) return result except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.post("/api/ingest/execute") async def execute_ingestion( system_id: str, method: str = "npm", source_url: Optional[str] = None, project_id: Optional[str] = None ): """ Execute the actual ingestion process. This performs the heavy lifting: - For npm: Extracts tokens from installed packages - For figma: Calls Figma API to get design tokens - For css: Fetches and parses CSS variables """ try: from design_system_registry import find_design_system system = find_design_system(system_id) tokens_extracted = 0 if method == "npm" and system: # Import existing token ingestion tools sys.path.insert(0, str(Path(__file__).parent.parent.parent / "dss-mvp1")) try: from dss.ingest import TokenCollection # Create a token collection for this design system collection = TokenCollection(name=system.name) # Based on primary ingestion method, use appropriate source if system.primary_ingestion.value == "css_variables": if system.css_cdn_url: # Fetch CSS from CDN and parse import httpx async with httpx.AsyncClient() as client: resp = await client.get(system.css_cdn_url) if resp.status_code == 200: from dss.ingest.css import CSSTokenSource # Write temp file and parse temp_css = Path("/tmp") / f"{system.id}_tokens.css" temp_css.write_text(resp.text) source = CSSTokenSource(str(temp_css)) source.parse() collection.merge(source.tokens) tokens_extracted = len(collection.tokens) elif system.primary_ingestion.value == "tailwind_config": # For Tailwind-based systems, we'll need their config tokens_extracted = 0 # Placeholder for Tailwind parsing except ImportError as e: # Token ingestion module not available pass finally: if str(Path(__file__).parent.parent.parent / "dss-mvp1") in sys.path: sys.path.remove(str(Path(__file__).parent.parent.parent / "dss-mvp1")) elif method == "figma" and source_url: # Use existing Figma extraction result = await figma_suite.extract_variables(source_url.split("/")[-1], "css") tokens_extracted = result.get("tokens_count", 0) elif method == "css" and source_url: # Fetch and parse CSS import httpx sys.path.insert(0, str(Path(__file__).parent.parent.parent / "dss-mvp1")) try: async with httpx.AsyncClient() as client: resp = await client.get(source_url) if resp.status_code == 200: from dss.ingest.css import CSSTokenSource temp_css = Path("/tmp") / "ingested_tokens.css" temp_css.write_text(resp.text) source = CSSTokenSource(str(temp_css)) source.parse() tokens_extracted = len(source.tokens.tokens) finally: if str(Path(__file__).parent.parent.parent / "dss-mvp1") in sys.path: sys.path.remove(str(Path(__file__).parent.parent.parent / "dss-mvp1")) ActivityLog.log( action="ingestion_executed", entity_type="ingestion", entity_id=system_id, project_id=project_id, details={ "method": method, "tokens_extracted": tokens_extracted } ) return { "success": True, "system_id": system_id, "method": method, "tokens_extracted": tokens_extracted, "message": f"Extracted {tokens_extracted} tokens from {system.name if system else system_id}" } except Exception as e: ActivityLog.log( action="ingestion_failed", entity_type="ingestion", entity_id=system_id, details={"error": str(e)} ) raise HTTPException(status_code=500, detail=str(e)) @app.get("/api/ingest/alternatives") async def get_ingestion_alternatives(system_id: Optional[str] = None): """ Get alternative ingestion methods. When the primary method fails or isn't available, suggests other ways to ingest the design system. """ try: from design_system_registry import find_design_system, get_alternative_ingestion_options system = None if system_id: system = find_design_system(system_id) return get_alternative_ingestion_options(system) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) # === DSS Mode === @app.get("/api/mode") async def get_mode(): """Get current DSS mode.""" mode = runtime_config.get("mode") return { "mode": mode, "description": "Local dev companion" if mode == "local" else "Remote design system server", "features": runtime_config.get("features") } @app.put("/api/mode") async def set_mode(request_data: Dict[str, Any]): """Set DSS mode (local or server).""" mode = request_data.get("mode") if not mode or mode not in ["local", "server"]: raise HTTPException(status_code=400, detail="Mode must be 'local' or 'server'") runtime_config.set("mode", mode) ActivityLog.log( action="mode_changed", entity_type="config", details={"mode": mode} ) return {"mode": mode, "success": True} # === Run Server === # === Static Files (Admin UI) === # Mount at the end so API routes take precedence # This enables portable mode: ./dss start serves everything on one port # === System Administration === @app.post("/api/system/reset") async def reset_dss(request_data: Dict[str, Any]): """ Reset DSS to fresh state by calling the reset command in dss-mvp1. Requires confirmation. """ confirm = request_data.get("confirm", "") if confirm != "RESET": raise HTTPException(status_code=400, detail="Must confirm with 'RESET'") try: # Path to dss-mvp1 directory dss_mvp1_path = Path(__file__).parent.parent.parent / "dss-mvp1" # Run the reset command result = subprocess.run( ["python3", "-m", "dss.settings", "reset", "--no-confirm"], cwd=str(dss_mvp1_path), capture_output=True, text=True, timeout=60 ) if result.returncode != 0: raise Exception(f"Reset failed: {result.stderr}") ActivityLog.log( action="dss_reset", entity_type="system", details={"status": "success"} ) return { "success": True, "message": "DSS has been reset to fresh state", "output": result.stdout } except subprocess.TimeoutExpired: raise HTTPException(status_code=504, detail="Reset operation timed out") except Exception as e: ActivityLog.log( action="dss_reset_failed", entity_type="system", details={"error": str(e)} ) raise HTTPException(status_code=500, detail=str(e)) # === Team Dashboards === @app.get("/api/projects/{project_id}/dashboard/summary") async def get_dashboard_summary(project_id: str): """ Get dashboard summary for all teams (thin slice). Provides overview of UX, UI, and QA metrics. """ if not Projects.get(project_id): raise HTTPException(status_code=404, detail="Project not found") # UX Dashboard data figma_files = FigmaFiles.list(project_id) # UI Dashboard data drift_stats = TokenDriftDetector.get_stats(project_id) code_summary = CodeMetrics.get_project_summary(project_id) # QA Dashboard data esre_list = ESREDefinitions.list(project_id) test_summary = TestResults.get_project_summary(project_id) return { "project_id": project_id, "ux": { "figma_files_count": len(figma_files), "figma_files": figma_files[:5] # Show first 5 }, "ui": { "token_drift": drift_stats, "code_metrics": code_summary }, "qa": { "esre_count": len(esre_list), "test_summary": test_summary } } # === UX Dashboard: Figma File Management === @app.get("/api/projects/{project_id}/figma-files") async def list_figma_files(project_id: str): """List all Figma files for a project (UX Dashboard).""" if not Projects.get(project_id): raise HTTPException(status_code=404, detail="Project not found") return FigmaFiles.list(project_id) @app.post("/api/projects/{project_id}/figma-files") async def create_figma_file(project_id: str, figma_file: FigmaFileCreate): """Add a Figma file to a project (UX Dashboard).""" if not Projects.get(project_id): raise HTTPException(status_code=404, detail="Project not found") created = FigmaFiles.create( project_id=project_id, figma_url=figma_file.figma_url, file_name=figma_file.file_name, file_key=figma_file.file_key ) ActivityLog.log( action="figma_file_added", entity_type="figma_file", entity_id=str(created['id']), entity_name=figma_file.file_name, project_id=project_id, team_context="ux", details={"file_key": figma_file.file_key} ) return created @app.put("/api/projects/{project_id}/figma-files/{file_id}/sync") async def update_figma_file_sync(project_id: str, file_id: int, status: str = "synced"): """Update Figma file sync status (UX Dashboard).""" if not Projects.get(project_id): raise HTTPException(status_code=404, detail="Project not found") updated = FigmaFiles.update_sync_status( file_id=file_id, status=status, last_synced=datetime.utcnow().isoformat() ) if not updated: raise HTTPException(status_code=404, detail="Figma file not found") ActivityLog.log( action="figma_file_synced", entity_type="figma_file", entity_id=str(file_id), project_id=project_id, team_context="ux" ) return updated @app.delete("/api/projects/{project_id}/figma-files/{file_id}") async def delete_figma_file(project_id: str, file_id: int): """Delete a Figma file (UX Dashboard).""" if not Projects.get(project_id): raise HTTPException(status_code=404, detail="Project not found") if not FigmaFiles.delete(file_id): raise HTTPException(status_code=404, detail="Figma file not found") ActivityLog.log( action="figma_file_deleted", entity_type="figma_file", entity_id=str(file_id), project_id=project_id, team_context="ux" ) return {"success": True} # === UI Dashboard: Token Drift Detection === @app.get("/api/projects/{project_id}/token-drift") async def list_token_drift(project_id: str, severity: Optional[str] = None): """List token drift issues for a project (UI Dashboard).""" if not Projects.get(project_id): raise HTTPException(status_code=404, detail="Project not found") drifts = TokenDriftDetector.list_by_project(project_id, severity) stats = TokenDriftDetector.get_stats(project_id) return { "drifts": drifts, "stats": stats } @app.post("/api/projects/{project_id}/token-drift") async def record_token_drift(project_id: str, drift: TokenDriftCreate): """Record a token drift issue (UI Dashboard).""" if not Projects.get(project_id): raise HTTPException(status_code=404, detail="Project not found") created = TokenDriftDetector.record_drift( component_id=drift.component_id, property_name=drift.property_name, hardcoded_value=drift.hardcoded_value, file_path=drift.file_path, line_number=drift.line_number, severity=drift.severity, suggested_token=drift.suggested_token ) ActivityLog.log( action="token_drift_detected", entity_type="token_drift", entity_id=str(created['id']), project_id=project_id, team_context="ui", details={ "severity": drift.severity, "component_id": drift.component_id } ) return created @app.put("/api/projects/{project_id}/token-drift/{drift_id}/status") async def update_drift_status(project_id: str, drift_id: int, status: str): """Update token drift status: pending, fixed, ignored (UI Dashboard).""" if not Projects.get(project_id): raise HTTPException(status_code=404, detail="Project not found") if status not in ["pending", "fixed", "ignored"]: raise HTTPException(status_code=400, detail="Invalid status") updated = TokenDriftDetector.update_status(drift_id, status) if not updated: raise HTTPException(status_code=404, detail="Drift issue not found") ActivityLog.log( action="token_drift_status_updated", entity_type="token_drift", entity_id=str(drift_id), project_id=project_id, team_context="ui", details={"status": status} ) return updated # === QA Dashboard: ESRE Definitions === @app.get("/api/projects/{project_id}/esre") async def list_esre_definitions(project_id: str): """List all ESRE definitions for a project (QA Dashboard).""" if not Projects.get(project_id): raise HTTPException(status_code=404, detail="Project not found") return ESREDefinitions.list(project_id) @app.post("/api/projects/{project_id}/esre") async def create_esre_definition(project_id: str, esre: ESRECreate): """Create a new ESRE definition (QA Dashboard).""" if not Projects.get(project_id): raise HTTPException(status_code=404, detail="Project not found") created = ESREDefinitions.create( project_id=project_id, name=esre.name, definition_text=esre.definition_text, expected_value=esre.expected_value, component_name=esre.component_name ) ActivityLog.log( action="esre_created", entity_type="esre", entity_id=str(created['id']), entity_name=esre.name, project_id=project_id, team_context="qa" ) return created @app.put("/api/projects/{project_id}/esre/{esre_id}") async def update_esre_definition(project_id: str, esre_id: int, updates: ESRECreate): """Update an ESRE definition (QA Dashboard).""" if not Projects.get(project_id): raise HTTPException(status_code=404, detail="Project not found") updated = ESREDefinitions.update( esre_id=esre_id, name=updates.name, definition_text=updates.definition_text, expected_value=updates.expected_value, component_name=updates.component_name ) if not updated: raise HTTPException(status_code=404, detail="ESRE definition not found") ActivityLog.log( action="esre_updated", entity_type="esre", entity_id=str(esre_id), entity_name=updates.name, project_id=project_id, team_context="qa" ) return updated @app.delete("/api/projects/{project_id}/esre/{esre_id}") async def delete_esre_definition(project_id: str, esre_id: int): """Delete an ESRE definition (QA Dashboard).""" if not Projects.get(project_id): raise HTTPException(status_code=404, detail="Project not found") if not ESREDefinitions.delete(esre_id): raise HTTPException(status_code=404, detail="ESRE definition not found") ActivityLog.log( action="esre_deleted", entity_type="esre", entity_id=str(esre_id), project_id=project_id, team_context="qa" ) return {"success": True} # === Claude Chat API with MCP Tool Integration === class ClaudeChatRequest(BaseModel): """AI chat request model (supports Claude and Gemini)""" message: str context: Optional[Dict[str, Any]] = {} history: Optional[List[Dict[str, Any]]] = [] project_id: Optional[str] = None user_id: Optional[int] = 1 enable_tools: Optional[bool] = True model: Optional[str] = "claude" # "claude" or "gemini" @app.post("/api/claude/chat") async def claude_chat(request_data: ClaudeChatRequest): """ Chat with AI (Claude or Gemini) via their APIs with MCP tool integration. AI can execute DSS tools to: - Get project information - List/search components - Get design tokens - Interact with Figma, Jira, Confluence Requires ANTHROPIC_API_KEY (for Claude) or GOOGLE_API_KEY/GEMINI_API_KEY (for Gemini). """ message = request_data.message context = request_data.context or {} history = request_data.history or [] project_id = request_data.project_id or context.get("projectId") user_id = request_data.user_id or 1 enable_tools = request_data.enable_tools model_name = request_data.model or "claude" # Log the chat request ActivityLog.log( action="ai_chat", entity_type="chat", entity_id=model_name, details={"message_length": len(message), "tools_enabled": enable_tools, "model": model_name} ) try: # Import AI provider from ai_providers import get_ai_provider # Get the appropriate provider provider = get_ai_provider(model_name) if not provider.is_available(): return { "success": False, "response": f"{model_name.title()} is not available. Check API keys and SDK installation.", "model": "error" } # Import MCP handler from dss_mcp.handler import get_mcp_handler, MCPContext mcp_handler = get_mcp_handler() # Build system prompt with design system context system_prompt = """You are a design system assistant with access to DSS (Design System Server) tools. You can use tools to: - Get project summaries, health scores, and statistics - List and search components in the design system - Get design tokens (colors, typography, spacing) - Interact with Figma to extract designs - Create/search Jira issues for tracking - Access Confluence documentation RULES: - Use tools when the user asks about project data, components, or tokens - Be concise: 2-3 sentences for simple questions - When showing tool results, summarize key information - If a tool fails, explain what went wrong - Always provide actionable insights from tool data""" # Add project context if available if project_id: try: project_context = await mcp_handler.get_project_context(project_id, user_id) if project_context: system_prompt += f""" CURRENT PROJECT CONTEXT: - Project: {project_context.name} (ID: {project_id}) - Components: {project_context.component_count} - Health Score: {project_context.health.get('score', 'N/A')}/100 (Grade: {project_context.health.get('grade', 'N/A')}) - Integrations: {', '.join(project_context.integrations.keys()) if project_context.integrations else 'None configured'}""" except: system_prompt += f"\n\nProject ID: {project_id} (context not loaded)" # Add user context if context: context_parts = [] if "project" in context: context_parts.append(f"Project: {context['project']}") if "file" in context: context_parts.append(f"Current file: {context['file']}") if "component" in context: context_parts.append(f"Component: {context['component']}") if context_parts: system_prompt += f"\n\nUser context:\n" + "\n".join(context_parts) # Get tools if enabled tools = None if enable_tools and project_id: tools = mcp_handler.get_tools_for_claude() # Create MCP context mcp_context = MCPContext( project_id=project_id, user_id=user_id ) # Call AI provider with all context result = await provider.chat( message=message, system_prompt=system_prompt, history=history, tools=tools, temperature=0.7, mcp_handler=mcp_handler, mcp_context=mcp_context ) # Log tool usage if result.get("tools_used"): ActivityLog.log( action="ai_tools_used", entity_type="chat", entity_id=model_name, project_id=project_id, details={"tools": result["tools_used"], "model": model_name} ) return result except Exception as e: error_msg = str(e) return { "success": False, "response": f"Error connecting to {model_name.title()}: {error_msg}\n\nMake sure your API key is valid and you have API access.", "model": "error" } # === MCP Tools Proxy === @app.post("/api/mcp/{tool_name}") async def execute_mcp_tool(tool_name: str, params: Dict[str, Any] = {}): """ Proxy MCP tool execution. Calls the MCP server running on port 3457. """ try: # Import MCP server functions from mcp_server import ( get_status, list_projects, create_project, get_project, extract_tokens, extract_components, generate_component_code, sync_tokens_to_file, get_sync_history, get_activity, ingest_css_tokens, ingest_scss_tokens, ingest_tailwind_tokens, ingest_json_tokens, merge_tokens, export_tokens, validate_tokens, discover_project, analyze_react_components, find_inline_styles, find_style_patterns, analyze_style_values, find_unused_styles, build_source_graph, get_quick_wins, get_quick_wins_report, check_naming_consistency, scan_storybook, generate_story, generate_stories_batch, generate_storybook_theme, get_story_coverage ) # Map tool names to functions tool_map = { 'get_status': get_status, 'list_projects': list_projects, 'create_project': create_project, 'get_project': get_project, 'extract_tokens': extract_tokens, 'extract_components': extract_components, 'generate_component_code': generate_component_code, 'sync_tokens_to_file': sync_tokens_to_file, 'get_sync_history': get_sync_history, 'get_activity': get_activity, 'ingest_css_tokens': ingest_css_tokens, 'ingest_scss_tokens': ingest_scss_tokens, 'ingest_tailwind_tokens': ingest_tailwind_tokens, 'ingest_json_tokens': ingest_json_tokens, 'merge_tokens': merge_tokens, 'export_tokens': export_tokens, 'validate_tokens': validate_tokens, 'discover_project': discover_project, 'analyze_react_components': analyze_react_components, 'find_inline_styles': find_inline_styles, 'find_style_patterns': find_style_patterns, 'analyze_style_values': analyze_style_values, 'find_unused_styles': find_unused_styles, 'build_source_graph': build_source_graph, 'get_quick_wins': get_quick_wins, 'get_quick_wins_report': get_quick_wins_report, 'check_naming_consistency': check_naming_consistency, 'scan_storybook': scan_storybook, 'generate_story': generate_story, 'generate_stories_batch': generate_stories_batch, 'generate_storybook_theme': generate_storybook_theme, 'get_story_coverage': get_story_coverage, } # Get the tool function tool_func = tool_map.get(tool_name) if not tool_func: raise HTTPException(status_code=404, detail=f"Tool '{tool_name}' not found") # Execute tool result = await tool_func(**params) # Log execution ActivityLog.log( action="mcp_tool_executed", entity_type="tool", entity_id=tool_name, details={"params": list(params.keys())} ) return JSONResponse(content={"success": True, "result": result}) except Exception as e: ActivityLog.log( action="mcp_tool_failed", entity_type="tool", entity_id=tool_name, details={"error": str(e)} ) raise HTTPException(status_code=500, detail=str(e)) # === MCP Integration Endpoints === class IntegrationCreate(BaseModel): """Create/Update integration configuration""" integration_type: str # figma, jira, confluence, sequential-thinking config: Dict[str, Any] # Encrypted in database enabled: bool = True class IntegrationUpdate(BaseModel): """Update integration""" config: Optional[Dict[str, Any]] = None enabled: Optional[bool] = None @app.get("/api/mcp/integrations") async def list_all_integrations(): """List all available integration types and their health status.""" health_list = IntegrationHealth.list_all() if not health_list: # Return defaults if no health data exists return { "integrations": [ {"integration_type": "figma", "is_healthy": True, "failure_count": 0}, {"integration_type": "jira", "is_healthy": True, "failure_count": 0}, {"integration_type": "confluence", "is_healthy": True, "failure_count": 0}, {"integration_type": "sequential-thinking", "is_healthy": True, "failure_count": 0} ] } return {"integrations": health_list} @app.get("/api/projects/{project_id}/integrations") async def list_project_integrations( project_id: str, user_id: Optional[int] = Query(None, description="Filter by user ID") ): """List integrations configured for a project.""" if not Projects.get(project_id): raise HTTPException(status_code=404, detail="Project not found") integrations = Integrations.list(project_id, user_id) return {"integrations": integrations} @app.post("/api/projects/{project_id}/integrations") async def create_integration( project_id: str, integration: IntegrationCreate, user_id: int = Query(..., description="User ID for user-scoped integration") ): """Create or update integration for a project (user-scoped).""" if not Projects.get(project_id): raise HTTPException(status_code=404, detail="Project not found") from dss_mcp.config import mcp_config # Encrypt config config_json = json.dumps(integration.config) cipher = mcp_config.get_cipher() if cipher: encrypted_config = cipher.encrypt(config_json.encode()).decode() else: encrypted_config = config_json # Store unencrypted if no key try: Integrations.upsert( project_id=project_id, user_id=user_id, integration_type=integration.integration_type, config=encrypted_config, enabled=integration.enabled ) ActivityLog.log( action="integration_configured", entity_type="integration", entity_id=integration.integration_type, project_id=project_id, details={"user_id": user_id, "enabled": integration.enabled} ) return { "success": True, "integration_type": integration.integration_type, "enabled": integration.enabled } except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.put("/api/projects/{project_id}/integrations/{integration_type}") async def update_integration( project_id: str, integration_type: str, update: IntegrationUpdate, user_id: int = Query(..., description="User ID") ): """Update an existing integration.""" if not Projects.get(project_id): raise HTTPException(status_code=404, detail="Project not found") from dss_mcp.config import mcp_config try: encrypted_config = None if update.config is not None: config_json = json.dumps(update.config) cipher = mcp_config.get_cipher() if cipher: encrypted_config = cipher.encrypt(config_json.encode()).decode() else: encrypted_config = config_json if update.config is None and update.enabled is None: return {"success": False, "message": "No updates provided"} result = Integrations.update( project_id=project_id, user_id=user_id, integration_type=integration_type, config=encrypted_config, enabled=update.enabled ) if not result: raise HTTPException(status_code=404, detail="Integration not found") return {"success": True, "integration_type": integration_type} except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.delete("/api/projects/{project_id}/integrations/{integration_type}") async def delete_integration( project_id: str, integration_type: str, user_id: int = Query(..., description="User ID") ): """Delete an integration configuration.""" if not Projects.get(project_id): raise HTTPException(status_code=404, detail="Project not found") try: deleted = Integrations.delete(project_id, user_id, integration_type) if not deleted: raise HTTPException(status_code=404, detail="Integration not found") ActivityLog.log( action="integration_deleted", entity_type="integration", entity_id=integration_type, project_id=project_id, details={"user_id": user_id} ) return {"success": True} except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.get("/api/mcp/tools") async def list_mcp_tools(include_details: bool = Query(False, description="Include full tool schemas")): """List all available MCP tools via unified handler.""" from dss_mcp.handler import get_mcp_handler handler = get_mcp_handler() return handler.list_tools(include_details=include_details) @app.get("/api/mcp/tools/{tool_name}") async def get_mcp_tool_info(tool_name: str): """Get detailed information about a specific MCP tool.""" from dss_mcp.handler import get_mcp_handler handler = get_mcp_handler() info = handler.get_tool_info(tool_name) if not info: raise HTTPException(status_code=404, detail=f"Tool not found: {tool_name}") return info class MCPToolExecuteRequest(BaseModel): """Request to execute an MCP tool""" arguments: Dict[str, Any] project_id: str user_id: Optional[int] = 1 @app.post("/api/mcp/tools/{tool_name}/execute") async def execute_mcp_tool(tool_name: str, request: MCPToolExecuteRequest): """ Execute an MCP tool via unified handler. All tool executions go through the central MCPHandler which: - Validates tool existence - Checks integration configurations - Applies circuit breaker protection - Logs execution metrics """ from dss_mcp.handler import get_mcp_handler, MCPContext handler = get_mcp_handler() # Create execution context context = MCPContext( project_id=request.project_id, user_id=request.user_id ) # Execute tool result = await handler.execute_tool( tool_name=tool_name, arguments=request.arguments, context=context ) # Log to activity ActivityLog.log( action="mcp_tool_executed", entity_type="tool", entity_id=tool_name, project_id=request.project_id, details={ "success": result.success, "duration_ms": result.duration_ms, "error": result.error } ) return result.to_dict() @app.get("/api/mcp/status") async def get_mcp_status(): """Get MCP server status and configuration.""" from dss_mcp.config import mcp_config, integration_config, validate_config warnings = validate_config() return { "server": { "host": mcp_config.HOST, "port": mcp_config.PORT, "encryption_enabled": bool(mcp_config.ENCRYPTION_KEY), "context_cache_ttl": mcp_config.CONTEXT_CACHE_TTL }, "integrations": { "figma": bool(integration_config.FIGMA_TOKEN), "anthropic": bool(integration_config.ANTHROPIC_API_KEY), "jira_default": bool(integration_config.JIRA_URL), "confluence_default": bool(integration_config.CONFLUENCE_URL) }, "circuit_breaker": { "failure_threshold": mcp_config.CIRCUIT_BREAKER_FAILURE_THRESHOLD, "timeout_seconds": mcp_config.CIRCUIT_BREAKER_TIMEOUT_SECONDS }, "warnings": warnings } # === MVP1: Project Configuration & Sandboxed File System === @app.get("/api/projects/{project_id}/config") async def get_project_config(project_id: str): """Get project configuration from .dss/config.json.""" project = project_manager.get_project(project_id) if not project: raise HTTPException(status_code=404, detail="Project not found") root_path = project.get('root_path') if not root_path: raise HTTPException(status_code=400, detail="Project has no root_path configured") config = config_service.get_config(root_path) return config.dict() @app.put("/api/projects/{project_id}/config") async def update_project_config(project_id: str, updates: Dict[str, Any]): """Update project configuration.""" project = project_manager.get_project(project_id) if not project: raise HTTPException(status_code=404, detail="Project not found") root_path = project.get('root_path') if not root_path: raise HTTPException(status_code=400, detail="Project has no root_path configured") config = config_service.update_config(root_path, updates) return config.dict() @app.get("/api/projects/{project_id}/context") async def get_project_context(project_id: str): """ Get full project context for AI injection. Returns project info, config, file tree, and context file contents. """ project = project_manager.get_project(project_id) if not project: raise HTTPException(status_code=404, detail="Project not found") root_path = project.get('root_path') if not root_path: raise HTTPException(status_code=400, detail="Project has no root_path configured") # Get config and sandboxed FS config = config_service.get_config(root_path) fs = SandboxedFS(root_path) # Load context files specified in config context_files = {} for file_path in config.ai.context_files: try: if fs.file_exists(file_path): content = fs.read_file(file_path, max_size_kb=config.ai.max_file_size_kb) context_files[file_path] = content[:2000] # Truncate for context except Exception: pass return { "project": { "id": project['id'], "name": project['name'], "root_path": root_path }, "config": config.dict(), "file_tree": fs.get_file_tree(max_depth=2), "context_files": context_files } @app.get("/api/projects/{project_id}/files") async def list_project_files(project_id: str, path: str = "."): """List files in project directory (sandboxed).""" project = project_manager.get_project(project_id) if not project: raise HTTPException(status_code=404, detail="Project not found") root_path = project.get('root_path') if not root_path: raise HTTPException(status_code=400, detail="Project has no root_path configured") try: fs = SandboxedFS(root_path) return fs.list_directory(path) except PermissionError as e: raise HTTPException(status_code=403, detail=str(e)) except NotADirectoryError as e: raise HTTPException(status_code=400, detail=str(e)) @app.get("/api/projects/{project_id}/files/tree") async def get_project_file_tree(project_id: str, max_depth: int = 3): """Get project file tree (sandboxed).""" project = project_manager.get_project(project_id) if not project: raise HTTPException(status_code=404, detail="Project not found") root_path = project.get('root_path') if not root_path: raise HTTPException(status_code=400, detail="Project has no root_path configured") fs = SandboxedFS(root_path) return fs.get_file_tree(max_depth=min(max_depth, 5)) # Cap at 5 levels @app.get("/api/projects/{project_id}/files/read") async def read_project_file(project_id: str, path: str): """Read file content from project (sandboxed).""" project = project_manager.get_project(project_id) if not project: raise HTTPException(status_code=404, detail="Project not found") root_path = project.get('root_path') if not root_path: raise HTTPException(status_code=400, detail="Project has no root_path configured") try: fs = SandboxedFS(root_path) content = fs.read_file(path) return {"path": path, "content": content} except PermissionError as e: raise HTTPException(status_code=403, detail=str(e)) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) class FileWriteRequest(BaseModel): path: str content: str @app.post("/api/projects/{project_id}/files/write") async def write_project_file(project_id: str, request: FileWriteRequest): """Write file content to project (sandboxed).""" project = project_manager.get_project(project_id) if not project: raise HTTPException(status_code=404, detail="Project not found") root_path = project.get('root_path') if not root_path: raise HTTPException(status_code=400, detail="Project has no root_path configured") # Check if AI write operations are allowed config = config_service.get_config(root_path) if "write" not in config.ai.allowed_operations: raise HTTPException(status_code=403, detail="Write operations not allowed for this project") try: fs = SandboxedFS(root_path) fs.write_file(request.path, request.content) ActivityLog.log( action="file_written", entity_type="file", entity_id=request.path, project_id=project_id, details={"path": request.path, "size": len(request.content)} ) return {"status": "ok", "path": request.path} except PermissionError as e: raise HTTPException(status_code=403, detail=str(e)) UI_DIR = Path(__file__).parent.parent.parent / "admin-ui" if UI_DIR.exists(): app.mount("/", StaticFiles(directory=str(UI_DIR), html=True), name="ui") def kill_port(port: int, wait: float = 0.5) -> None: """Kill any process using the specified port.""" import subprocess import time try: # Get PIDs using the port result = subprocess.run( ["lsof", "-ti", f":{port}"], capture_output=True, text=True ) pids = result.stdout.strip().split('\n') killed = False for pid in pids: if pid: subprocess.run(["kill", "-9", pid], capture_output=True) print(f"[DSS] Killed process {pid} on port {port}") killed = True if killed and wait: time.sleep(wait) # Wait for port to be released except Exception: pass # Port was free if __name__ == "__main__": import uvicorn port = int(os.getenv("PORT", "3456")) host = os.getenv("HOST", "0.0.0.0") # Kill any existing process on the port (twice to handle respawning) kill_port(port, wait=1.0) kill_port(port, wait=0.5) url = f"http://{host}:{port}" print(f""" ╔═══════════════════════════════════════════════════════════════╗ ║ Design System Server (DSS) - Portable Server ║ ╠═══════════════════════════════════════════════════════════════╣ ║ Dashboard: {url + '/':^47}║ ║ API: {url + '/api':^47}║ ║ Docs: {url + '/docs':^47}║ ║ Environment: {config.server.env:^47}║ ║ Figma Mode: {figma_suite.mode:^47}║ ╚═══════════════════════════════════════════════════════════════╝ """) uvicorn.run( "server:app", host=host, port=port, reload=config.server.env == "development" )