#!/usr/bin/env python3 import argparse import datetime import json import os import pathlib import shutil from typing import Dict, List ROOT = pathlib.Path(__file__).resolve().parents[1] def load_catalog(path: pathlib.Path) -> List[Dict[str, object]]: with path.open("r", encoding="utf-8") as handle: return json.load(handle) def build_app_id(slug: str) -> str: sanitized = ''.join(ch.lower() if ch.isalnum() else '.' for ch in slug) sanitized = '.'.join(filter(None, sanitized.split('.'))) return f"com.knel.{sanitized}" def default_tags() -> List[str]: return ["custom", "known-element"] def default_placeholder_map(entry: Dict[str, object]) -> Dict[str, str]: slug = entry["slug"] title = entry["title"] repo = entry["repo"] issue = entry.get("issue", "") website = entry.get("website") or repo.rstrip(".git") description = entry.get("description") or "TODO: Add package description." tags = entry.get("tags") or default_tags() http_port = str(entry.get("httpPort", 3000)) base_image = entry.get("baseImage", "cloudron/base:5.0.0") placeholder_map = { "{{APP_ID}}": build_app_id(slug), "{{APP_TITLE}}": title, "{{APP_SLUG}}": slug, "{{APP_REPO}}": repo, "{{APP_ISSUE}}": issue, "{{APP_WEBSITE}}": website, "{{APP_DESCRIPTION}}": description, "{{HTTP_PORT}}": http_port, "{{BASE_IMAGE}}": base_image, "{{APP_TAGS}}": json.dumps(tags) } return placeholder_map def render_templates(template_dir: pathlib.Path, destination: pathlib.Path, entry: Dict[str, object]) -> None: shutil.copytree(template_dir, destination, dirs_exist_ok=True) placeholders = default_placeholder_map(entry) for path in destination.rglob('*'): if path.is_dir(): continue text = path.read_text(encoding='utf-8') for key, value in placeholders.items(): text = text.replace(key, value) path.write_text(text, encoding='utf-8') # Ensure critical scripts are executable for relpath in ["start.sh", "test/smoke.sh"]: target = destination / relpath if target.exists(): mode = target.stat().st_mode target.chmod(mode | 0o111) # Drop metadata file for bookkeeping metadata = { "slug": entry["slug"], "title": entry["title"], "issue": entry.get("issue"), "repo": entry.get("repo"), "additionalRepos": entry.get("additionalRepos", []), "created": datetime.datetime.utcnow().replace(microsecond=0).isoformat() + "Z", "notes": entry.get("notes", "TODO: capture packaging notes") } with (destination / "metadata.json").open("w", encoding="utf-8") as handle: json.dump(metadata, handle, indent=2) def ensure_app_directory(entry: Dict[str, object], template_dir: pathlib.Path, apps_dir: pathlib.Path, force: bool) -> pathlib.Path: app_dir = apps_dir / entry["slug"] if app_dir.exists(): if not force: raise FileExistsError(f"Destination {app_dir} already exists. Use --force to overwrite.") shutil.rmtree(app_dir) render_templates(template_dir, app_dir, entry) return app_dir def main() -> None: parser = argparse.ArgumentParser(description="Scaffold Cloudron app packages from catalog entries") parser.add_argument("--slug", help="Generate scaffold for a single slug", default=None) parser.add_argument("--catalog", default=str(ROOT / "apps" / "catalog.json"), help="Path to catalog JSON") parser.add_argument("--template", default=str(ROOT / "templates" / "cloudron-app"), help="Template directory") parser.add_argument("--apps-dir", default=str(ROOT / "apps"), help="Destination apps directory") parser.add_argument("--force", action="store_true", help="Overwrite existing app directory") args = parser.parse_args() catalog = load_catalog(pathlib.Path(args.catalog)) entries = catalog if args.slug: entries = [entry for entry in catalog if entry["slug"] == args.slug] if not entries: raise SystemExit(f"Slug {args.slug} not found in catalog") template_dir = pathlib.Path(args.template) apps_dir = pathlib.Path(args.apps_dir) apps_dir.mkdir(parents=True, exist_ok=True) for entry in entries: app_dir = ensure_app_directory(entry, template_dir, apps_dir, args.force) print(f"Created {app_dir.relative_to(ROOT)}") if __name__ == "__main__": main()