import json import re from dataclasses import dataclass, field from pathlib import Path REQUIRED_FIELDS = ( "name", "display_name", "version", "description", "volumes", "ports", "icon", ) VALID_SETTING_TYPES = frozenset({"text", "password", "number"}) SETTING_NAME_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$") class ManifestError(Exception): pass @dataclass(frozen=True) class Setting: name: str # env-var name, e.g. SMB_PASSWORD label: str # human label shown in the UI description: str # one-sentence help text under the input type: str # "text" | "password" | "number" required: bool default: str | None @dataclass(frozen=True) class Manifest: name: str display_name: str version: str description: str volumes: tuple[str, ...] ports: tuple[int, ...] icon: str description_long: str = "" settings: tuple[Setting, ...] = field(default_factory=tuple) def volume_name(self, short: str) -> str: # Namespace volume names so two apps can each declare e.g. "data" # without colliding in `docker volume ls`. if short not in self.volumes: raise ManifestError(f"{self.name}: volume {short!r} not declared in manifest") return f"furtka_{self.name}_{short}" def _parse_settings(raw: object, manifest_path: Path) -> tuple[Setting, ...]: if raw is None: return () if not isinstance(raw, list): raise ManifestError(f"{manifest_path}: settings must be a list") out: list[Setting] = [] seen: set[str] = set() for i, item in enumerate(raw): if not isinstance(item, dict): raise ManifestError(f"{manifest_path}: settings[{i}] must be an object") name = item.get("name") if not isinstance(name, str) or not SETTING_NAME_RE.match(name): raise ManifestError( f"{manifest_path}: settings[{i}].name must be an UPPER_SNAKE_CASE env-var name" ) if name in seen: raise ManifestError(f"{manifest_path}: settings has duplicate name {name!r}") seen.add(name) label = item.get("label", name) description = item.get("description", "") type_ = item.get("type", "text") if type_ not in VALID_SETTING_TYPES: valid = sorted(VALID_SETTING_TYPES) raise ManifestError(f"{manifest_path}: settings[{name}].type must be one of {valid}") required = bool(item.get("required", False)) default = item.get("default") if default is not None and not isinstance(default, str): default = str(default) out.append( Setting( name=name, label=str(label), description=str(description), type=type_, required=required, default=default, ) ) return tuple(out) def load_manifest(path: Path, expected_name: str | None = None) -> Manifest: """Parse and validate a manifest.json. `expected_name` is used by the scanner (where the install location's folder name IS the source of truth and must match the manifest). For loading from arbitrary source folders during install, leave it None — the manifest's own `name` field decides the install target. """ try: raw = json.loads(path.read_text()) except json.JSONDecodeError as e: raise ManifestError(f"{path}: invalid JSON: {e}") from e if not isinstance(raw, dict): raise ManifestError(f"{path}: top-level must be an object") missing = [f for f in REQUIRED_FIELDS if f not in raw] if missing: raise ManifestError(f"{path}: missing required fields: {', '.join(missing)}") name = raw["name"] if not isinstance(name, str) or not name: raise ManifestError(f"{path}: name must be a non-empty string") if expected_name is not None and name != expected_name: raise ManifestError(f"{path}: name {name!r} must equal {expected_name!r}") volumes = raw["volumes"] if not isinstance(volumes, list) or not all(isinstance(v, str) and v for v in volumes): raise ManifestError(f"{path}: volumes must be a list of non-empty strings") ports = raw["ports"] if not isinstance(ports, list) or not all(isinstance(p, int) for p in ports): raise ManifestError(f"{path}: ports must be a list of integers") settings = _parse_settings(raw.get("settings"), path) return Manifest( name=name, display_name=str(raw["display_name"]), version=str(raw["version"]), description=str(raw["description"]), volumes=tuple(volumes), ports=tuple(ports), icon=str(raw["icon"]), description_long=str(raw.get("description_long", "")), settings=settings, )