"""Configuration loading (INI config + YAML manifest) and FlowContext.""" import configparser from dataclasses import dataclass, field from pathlib import Path from typing import Any, Dict, List, Optional import yaml from flow.core.console import ConsoleLogger from flow.core import paths from flow.core.platform import PlatformInfo @dataclass class TargetConfig: namespace: str platform: str ssh_host: str ssh_identity: Optional[str] = None @dataclass class AppConfig: dotfiles_url: str = "" dotfiles_branch: str = "main" projects_dir: str = "~/projects" container_registry: str = "registry.tomastm.com" container_tag: str = "latest" tmux_session: str = "default" targets: List[TargetConfig] = field(default_factory=list) def _parse_target_config(key: str, value: str) -> Optional[TargetConfig]: """Parse a target line from config. Supported formats: 1) namespace = platform ssh_host [ssh_identity] 2) namespace@platform = ssh_host [ssh_identity] """ parts = value.split() if not parts: return None if "@" in key: namespace, platform = key.split("@", 1) ssh_host = parts[0] ssh_identity = parts[1] if len(parts) > 1 else None if not namespace or not platform: return None return TargetConfig( namespace=namespace, platform=platform, ssh_host=ssh_host, ssh_identity=ssh_identity, ) if len(parts) < 2: return None return TargetConfig( namespace=key, platform=parts[0], ssh_host=parts[1], ssh_identity=parts[2] if len(parts) > 2 else None, ) def load_config(path: Optional[Path] = None) -> AppConfig: """Load INI config file into AppConfig with cascading priority. Priority: 1. Dotfiles repo (self-hosted): ~/.local/share/devflow/dotfiles/flow/.config/flow/config 2. Local override: ~/.config/devflow/config 3. Empty fallback """ cfg = AppConfig() if path is None: # Priority 1: Check dotfiles repo for self-hosted config if paths.DOTFILES_CONFIG.exists(): path = paths.DOTFILES_CONFIG # Priority 2: Fall back to local config else: path = paths.CONFIG_FILE assert path is not None if not path.exists(): return cfg parser = configparser.ConfigParser() parser.read(path) if parser.has_section("repository"): cfg.dotfiles_url = parser.get("repository", "dotfiles_url", fallback=cfg.dotfiles_url) cfg.dotfiles_branch = parser.get("repository", "dotfiles_branch", fallback=cfg.dotfiles_branch) if parser.has_section("paths"): cfg.projects_dir = parser.get("paths", "projects_dir", fallback=cfg.projects_dir) if parser.has_section("defaults"): cfg.container_registry = parser.get("defaults", "container_registry", fallback=cfg.container_registry) cfg.container_tag = parser.get("defaults", "container_tag", fallback=cfg.container_tag) cfg.tmux_session = parser.get("defaults", "tmux_session", fallback=cfg.tmux_session) if parser.has_section("targets"): for key in parser.options("targets"): raw_value = parser.get("targets", key) tc = _parse_target_config(key, raw_value) if tc is not None: cfg.targets.append(tc) return cfg def load_manifest(path: Optional[Path] = None) -> Dict[str, Any]: """Load YAML manifest file with cascading priority. Priority: 1. Dotfiles repo (self-hosted): ~/.local/share/devflow/dotfiles/flow/.config/flow/manifest.yaml 2. Local override: ~/.config/devflow/manifest.yaml 3. Empty fallback """ if path is None: # Priority 1: Check dotfiles repo for self-hosted manifest if paths.DOTFILES_MANIFEST.exists(): path = paths.DOTFILES_MANIFEST # Priority 2: Fall back to local manifest else: path = paths.MANIFEST_FILE assert path is not None if not path.exists(): return {} try: with open(path, "r") as f: data = yaml.safe_load(f) except yaml.YAMLError as e: raise RuntimeError(f"Invalid YAML in {path}: {e}") from e return data if isinstance(data, dict) else {} @dataclass class FlowContext: config: AppConfig manifest: Dict[str, Any] platform: PlatformInfo console: ConsoleLogger