#!/usr/bin/env python3 import argparse import datetime as dt import json import pathlib import shutil from typing import Dict, Iterable, List, Sequence try: import jinja2 except ModuleNotFoundError as exc: # pragma: no cover - guidance for local execution raise SystemExit( "Jinja2 is required. Run this script via './run/dev.sh python scripts/new_app.py ...' so dependencies are provided by the devtools container." ) from exc ROOT = pathlib.Path(__file__).resolve().parents[1] DEFAULT_BASE_IMAGE = "cloudron/base:5.0.0" DEFAULT_BUILDER_IMAGE = "cloudron/base:5.0.0" 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: import re normalized = slug.lower() normalized = re.sub(r"[^a-z0-9]+", ".", normalized) normalized = re.sub(r"\.\.+", ".", normalized).strip(".") if not normalized: raise ValueError(f"Unable to derive Cloudron app id from slug '{slug}'") return f"com.knel.{normalized}" def ensure_clean_destination(app_dir: pathlib.Path, force: bool) -> None: if app_dir.exists(): if not force: raise FileExistsError( f"Destination {app_dir} already exists. Use --force to overwrite or remove it manually." ) shutil.rmtree(app_dir) app_dir.mkdir(parents=True, exist_ok=True) def render_templates(template_dir: pathlib.Path, destination: pathlib.Path, context: Dict[str, object]) -> None: env = jinja2.Environment( loader=jinja2.FileSystemLoader(str(template_dir)), keep_trailing_newline=True, autoescape=False, ) for source in template_dir.rglob("*"): if source.is_dir(): continue relative = source.relative_to(template_dir) target = destination / relative if source.suffix == ".j2": rendered_path = target.with_suffix("") rendered_path.parent.mkdir(parents=True, exist_ok=True) template = env.get_template(str(relative)) rendered_path.write_text(template.render(**context), encoding="utf-8") else: target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source, target) for relpath in ("start.sh", "test/smoke.sh"): path = destination / relpath if path.exists(): path.chmod(path.stat().st_mode | 0o111) def build_context(entry: Dict[str, object]) -> Dict[str, object]: generated_at = dt.datetime.utcnow().replace(microsecond=0).isoformat() + "Z" tags = entry.get("tags") or ["custom", "known-element"] website = entry.get("website") or entry.get("repo", "").rstrip(".git") http_port = int(entry.get("httpPort", 3000)) base_image = entry.get("baseImage", DEFAULT_BASE_IMAGE) builder_image = entry.get("builderImage", DEFAULT_BUILDER_IMAGE) additional_repos = entry.get("additionalRepos", []) return { "app_id": build_app_id(entry["slug"]), "app_slug": entry["slug"], "app_title": entry["title"], "app_repo": entry["repo"], "app_issue": entry.get("issue", ""), "app_website": website, "app_tags": json.dumps(tags), "http_port": http_port, "base_image": base_image, "builder_image": builder_image, "generated_at": generated_at, "additional_repos_json": json.dumps(additional_repos, indent=2), "default_app_version": entry.get("defaultVersion", "latest"), } def render_app(entry: Dict[str, object], template_dir: pathlib.Path, apps_dir: pathlib.Path, force: bool) -> pathlib.Path: app_dir = apps_dir / entry["slug"] ensure_clean_destination(app_dir, force) context = build_context(entry) render_templates(template_dir, app_dir, context) return app_dir def iter_entries(catalog: Sequence[Dict[str, object]], slugs: Iterable[str] | None) -> Iterable[Dict[str, object]]: if slugs is None: yield from catalog return slug_set = set(slugs) for entry in catalog: if entry["slug"] in slug_set: yield entry slug_set.remove(entry["slug"]) if slug_set: missing = ", ".join(sorted(slug_set)) raise SystemExit(f"Unknown slug(s): {missing}") def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Scaffold Cloudron app packages from catalog entries") parser.add_argument("--slug", action="append", dest="slugs", help="Generate scaffold for the provided slug (repeatable)") 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") return parser.parse_args() def main() -> None: args = parse_args() catalog = load_catalog(pathlib.Path(args.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 iter_entries(catalog, args.slugs): app_dir = render_app(entry, template_dir, apps_dir, args.force) print(f"Created {app_dir.relative_to(ROOT)}") if __name__ == "__main__": main()