Files
dss/cli/python/api/server.py
Digital Production Factory 276ed71f31 Initial commit: Clean DSS implementation
Migrated from design-system-swarm with fresh git history.
Old project history preserved in /home/overbits/apps/design-system-swarm

Core components:
- MCP Server (Python FastAPI with mcp 1.23.1)
- Claude Plugin (agents, commands, skills, strategies, hooks, core)
- DSS Backend (dss-mvp1 - token translation, Figma sync)
- Admin UI (Node.js/React)
- Server (Node.js/Express)
- Storybook integration (dss-mvp1/.storybook)

Self-contained configuration:
- All paths relative or use DSS_BASE_PATH=/home/overbits/dss
- PYTHONPATH configured for dss-mvp1 and dss-claude-plugin
- .env file with all configuration
- Claude plugin uses ${CLAUDE_PLUGIN_ROOT} for portability

Migration completed: $(date)
🤖 Clean migration with full functionality preserved
2025-12-09 18:45:48 -03:00

725 lines
22 KiB
Python

"""
Design System Server (DSS) - FastAPI Server
Portable API server providing:
- Project management (CRUD)
- Figma integration endpoints
- Discovery & health endpoints
- Activity tracking
- Runtime configuration management
- Service discovery (Storybook, etc.)
Modes:
- Server: Deployed remotely, serves design systems to teams
- Local: Dev companion, UI advisor, local services
Uses SQLite for persistence, integrates with Figma tools.
"""
import asyncio
import subprocess
import json
import os
from pathlib import Path
from typing import Optional, List, Dict, Any
from datetime import datetime
from fastapi import FastAPI, HTTPException, Query, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
import sys
sys.path.insert(0, str(Path(__file__).parent.parent))
from config import config
from storage.database import (
Projects, Components, SyncHistory, ActivityLog, Teams, Cache, get_stats
)
from figma.figma_tools import FigmaToolSuite
# === Runtime Configuration ===
class RuntimeConfig:
"""
Runtime configuration that can be modified from the dashboard.
Persists to .dss/runtime-config.json for portability.
"""
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:
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()
# === Service Discovery ===
class ServiceDiscovery:
"""Discovers and manages companion 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:
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:
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=["*"],
)
# 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
figma_suite = FigmaToolSuite(output_dir=str(Path(__file__).parent.parent.parent / ".dss" / "output"))
# === Request/Response Models ===
class ProjectCreate(BaseModel):
name: str
description: str = ""
figma_file_key: str = ""
class ProjectUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
figma_file_key: Optional[str] = None
status: Optional[str] = None
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 = ""
# === 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():
"""Health check endpoint."""
return {
"status": "ok",
"name": "dss-api",
"version": "1.0.0",
"timestamp": datetime.utcnow().isoformat() + "Z",
"figma_mode": figma_suite.mode,
"config": config.summary()
}
@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 file."""
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, "count": result.get("tokens_count")}
)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/figma/extract-components")
async def extract_components(request: FigmaExtractRequest):
"""Extract components from Figma file."""
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 styles from Figma file."""
try:
result = await figma_suite.extract_styles(request.file_key)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/figma/sync-tokens")
async def sync_tokens(request: FigmaSyncRequest):
"""Sync tokens from Figma to target path."""
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, "synced": result.get("tokens_synced")}
)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/figma/validate")
async def validate_components(request: FigmaExtractRequest):
"""Validate components 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))
# === 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}
@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)
# === 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(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()
# === 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(mode: str):
"""Set DSS mode (local or server)."""
if 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
UI_DIR = Path(__file__).parent.parent.parent / "admin-ui"
if UI_DIR.exists():
app.mount("/", StaticFiles(directory=str(UI_DIR), html=True), name="ui")
if __name__ == "__main__":
import uvicorn
port = int(os.getenv("PORT", "3456"))
host = os.getenv("HOST", "0.0.0.0")
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"
)