furtka-apps/scripts/vendor/furtka_manifest.py
Daniel Maksymilian Syrnicki 8cd9e3bcf4
All checks were successful
CI / validate (push) Successful in 36s
CI / shellcheck (push) Successful in 1m1s
Release / release (push) Successful in 38s
chore: bootstrap furtka-apps catalog repo
Initial layout: apps/fileshare/ (seeded from daniel/furtka apps/), CI
(JSON + manifest validator + shellcheck), release pipeline (tag-driven,
mirrors core repo), vendored manifest schema for offline validation.

The core repo (daniel/furtka) at 26.6-alpha keeps apps/fileshare as a
seed so offline first-boot still has an installable app; this catalog
becomes authoritative once a box has synced at least once.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:14:50 +02:00

140 lines
4.7 KiB
Python

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,
)