Files
dss/dss-claude-plugin/core/config.py
DSS 9dbd56271e
Some checks failed
DSS Project Analysis / dss-context-update (push) Has been cancelled
feat: Enterprise DSS architecture implementation
Complete implementation of enterprise design system validation:

Phase 1 - @dss/rules npm package:
- CLI with validate and init commands
- 16 rules across 5 categories (colors, spacing, typography, components, a11y)
- dss-ignore support (inline and next-line)
- Break-glass [dss-skip] for emergency merges
- CI workflow templates (Gitea, GitHub, GitLab)

Phase 2 - Metrics dashboard:
- FastAPI metrics API with SQLite storage
- Portfolio-wide metrics aggregation
- Project drill-down with file:line:column violations
- Trend charts and history tracking

Phase 3 - Local analysis cache:
- LocalAnalysisCache for offline-capable validation
- Mode detection (LOCAL/REMOTE/CI)
- Stale cache warnings with recommendations

Phase 4 - Project onboarding:
- dss-init command for project setup
- Creates ds.config.json, .dss/ folder structure
- Updates .gitignore and package.json scripts
- Optional CI workflow setup

Architecture decisions:
- No commit-back: CI uploads to dashboard, not git
- Three-tier: Dashboard (read-only) → CI (authoritative) → Local (advisory)
- Pull-based rules via npm for version control

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 09:41:36 -03:00

271 lines
9.9 KiB
Python

"""
DSS Configuration Module
========================
Handles configuration management for the Design System Server (DSS) Claude Plugin.
Supports local/remote/CI mode detection, persistent configuration storage, and
environment variable overrides.
Enterprise Architecture:
- LOCAL: Developer workstation, reads from .dss/ cache, advisory validation
- REMOTE: Headless/server mode, full analysis, metrics upload
- CI: CI/CD pipeline, authoritative enforcement, blocking validation
- AUTO: Detect environment automatically (CI env vars -> CI, else LOCAL with cache)
"""
import json
import logging
import os
import uuid
from enum import Enum
from pathlib import Path
from typing import Optional
import aiohttp
from pydantic import BaseModel, Field, ValidationError
# Configure module-level logger
logger = logging.getLogger(__name__)
CONFIG_DIR = Path.home() / ".dss"
CONFIG_FILE = CONFIG_DIR / "config.json"
DEFAULT_REMOTE_URL = "https://dss.overbits.luz.uy"
DEFAULT_LOCAL_URL = "http://localhost:6006"
DEFAULT_DASHBOARD_URL = "https://dss.overbits.luz.uy/api/metrics"
# CI environment variables that indicate we're running in a pipeline
CI_ENV_VARS = [
"CI",
"GITEA_ACTIONS",
"GITHUB_ACTIONS",
"GITLAB_CI",
"JENKINS_URL",
"CIRCLECI",
"TRAVIS",
"BUILDKITE",
]
class DSSMode(str, Enum):
"""Operation modes for the DSS plugin."""
LOCAL = "local" # Developer workstation - advisory, uses cache
REMOTE = "remote" # Headless server - full analysis
CI = "ci" # CI/CD pipeline - authoritative enforcement
AUTO = "auto" # Auto-detect based on environment
class DSSConfig(BaseModel):
"""
Configuration model for DSS Plugin.
Attributes:
mode (DSSMode): The configured operation mode (default: AUTO).
remote_url (str): URL for the remote DSS API.
local_url (str): URL for the local DSS API (usually localhost).
dashboard_url (str): URL for metrics dashboard API.
session_id (str): Unique identifier for this client instance.
project_path (str): Current project path (for local analysis).
rules_version (str): Pinned @dss/rules version for this project.
"""
mode: DSSMode = Field(default=DSSMode.AUTO, description="Operation mode preference")
remote_url: str = Field(default=DEFAULT_REMOTE_URL, description="Remote API endpoint")
local_url: str = Field(default=DEFAULT_LOCAL_URL, description="Local API endpoint")
dashboard_url: str = Field(default=DEFAULT_DASHBOARD_URL, description="Metrics dashboard API")
session_id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Persistent session ID"
)
project_path: Optional[str] = Field(default=None, description="Current project path")
rules_version: Optional[str] = Field(default=None, description="Pinned @dss/rules version")
class Config:
validate_assignment = True
extra = "ignore" # Allow forward compatibility with new config keys
@classmethod
def load(cls) -> "DSSConfig":
"""
Load configuration from ~/.dss/config.json.
Returns a default instance if the file does not exist or is invalid.
"""
if not CONFIG_FILE.exists():
logger.debug(f"No config found at {CONFIG_FILE}, using defaults.")
return cls()
try:
content = CONFIG_FILE.read_text(encoding="utf-8")
data = json.loads(content)
# Ensure complex types are handled by Pydantic validation
return cls.model_validate(data)
except (json.JSONDecodeError, ValidationError) as e:
logger.warning(f"Failed to load config from {CONFIG_FILE}: {e}. Using defaults.")
return cls()
except Exception as e:
logger.error(f"Unexpected error loading config: {e}")
return cls()
def save(self) -> None:
"""
Save the current configuration to ~/.dss/config.json.
Creates the directory if it does not exist.
"""
try:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
# Export using mode='json' to handle enums and urls correctly
json_data = self.model_dump_json(indent=2)
CONFIG_FILE.write_text(json_data, encoding="utf-8")
logger.debug(f"Configuration saved to {CONFIG_FILE}")
except Exception as e:
logger.error(f"Failed to save config to {CONFIG_FILE}: {e}")
raise
async def get_active_mode(self) -> DSSMode:
"""
Determine the actual runtime mode based on priority rules.
Priority:
1. DSS_MODE environment variable (explicit override)
2. CI environment detection (GITEA_ACTIONS, CI, GITHUB_ACTIONS, etc.)
3. Configured 'mode' (if not AUTO)
4. Auto-detection (check for .dss/ folder, ping local health)
5. Fallback to LOCAL (developer-first)
Returns:
DSSMode: The resolved active mode (LOCAL, REMOTE, or CI).
"""
# 1. Check Environment Variable (explicit override)
env_mode = os.getenv("DSS_MODE")
if env_mode:
try:
resolved = DSSMode(env_mode.lower())
logger.info(f"Mode set via DSS_MODE env var: {resolved.value}")
return resolved
except ValueError:
logger.warning(f"Invalid DSS_MODE env var '{env_mode}', ignoring.")
# 2. Check CI environment variables
if self._is_ci_environment():
logger.info("CI environment detected. Using CI mode (authoritative enforcement).")
return DSSMode.CI
# 3. Check Configuration (if explicit, not AUTO)
if self.mode != DSSMode.AUTO:
logger.info(f"Using configured mode: {self.mode.value}")
return self.mode
# 4. Auto-detect based on environment
logger.info("Auto-detecting DSS mode...")
# Check for local .dss/ folder (indicates project setup)
if self._has_local_dss_folder():
logger.info("Found .dss/ folder. Using LOCAL mode with cache.")
return DSSMode.LOCAL
# Check if local server is running
is_local_healthy = await self._check_local_health()
if is_local_healthy:
logger.info(f"Local server detected at {self.local_url}. Using LOCAL mode.")
return DSSMode.LOCAL
# 5. Fallback to LOCAL (developer-first, will use stale cache if available)
logger.info("Fallback to LOCAL mode (offline-capable with cache).")
return DSSMode.LOCAL
def _is_ci_environment(self) -> bool:
"""Check if running in a CI/CD environment."""
for env_var in CI_ENV_VARS:
if os.getenv(env_var):
logger.debug(f"CI detected via {env_var} env var")
return True
return False
def _has_local_dss_folder(self) -> bool:
"""Check if current directory or project has .dss/ folder."""
# Check current working directory
cwd_dss = Path.cwd() / ".dss"
if cwd_dss.exists() and cwd_dss.is_dir():
return True
# Check configured project path
if self.project_path:
project_dss = Path(self.project_path) / ".dss"
if project_dss.exists() and project_dss.is_dir():
return True
return False
async def _check_local_health(self) -> bool:
"""
Ping the local server health endpoint to check availability.
Returns:
bool: True if server responds with 200 OK, False otherwise.
"""
health_url = f"{self.local_url.rstrip('/')}/health"
try:
timeout = aiohttp.ClientTimeout(total=2.0) # Short timeout for responsiveness
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(health_url) as response:
if response.status == 200:
return True
logger.debug(f"Local health check returned status {response.status}")
except aiohttp.ClientError as e:
logger.debug(f"Local health check connection failed: {e}")
except Exception as e:
logger.debug(f"Unexpected error during health check: {e}")
return False
def get_api_url(self, active_mode: DSSMode) -> str:
"""Helper to get the correct API URL for the determined mode."""
if active_mode == DSSMode.LOCAL:
return self.local_url
return self.remote_url
def get_mode_behavior(self, active_mode: DSSMode) -> dict:
"""
Get behavior configuration for the active mode.
Returns dict with:
- blocking: Whether validation errors block operations
- upload_metrics: Whether to upload metrics to dashboard
- use_cache: Whether to use local .dss/ cache
- cache_ttl: Cache time-to-live in seconds
"""
behaviors = {
DSSMode.LOCAL: {
"blocking": False, # Advisory only
"upload_metrics": False,
"use_cache": True,
"cache_ttl": 3600, # 1 hour
"show_stale_warning": True,
},
DSSMode.REMOTE: {
"blocking": True,
"upload_metrics": True,
"use_cache": False,
"cache_ttl": 0,
"show_stale_warning": False,
},
DSSMode.CI: {
"blocking": True, # Authoritative enforcement
"upload_metrics": True,
"use_cache": False,
"cache_ttl": 0,
"show_stale_warning": False,
},
DSSMode.AUTO: {
# AUTO resolves to another mode, shouldn't reach here
"blocking": False,
"upload_metrics": False,
"use_cache": True,
"cache_ttl": 3600,
"show_stale_warning": True,
},
}
return behaviors.get(active_mode, behaviors[DSSMode.LOCAL])