Some checks failed
DSS Project Analysis / dss-context-update (push) Has been cancelled
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>
271 lines
9.9 KiB
Python
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])
|