Scaffold Cloudron packaging workspace

This commit is contained in:
2025-10-02 13:39:36 -05:00
parent 482d4ff1b8
commit fe0ade1dd9
366 changed files with 4035 additions and 2493 deletions

View File

@@ -1,13 +1,21 @@
#!/usr/bin/env python3
import argparse
import datetime
import datetime as dt
import json
import os
import pathlib
import shutil
from typing import Dict, List
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]]:
@@ -16,109 +24,124 @@ def load_catalog(path: pathlib.Path) -> List[Dict[str, object]]:
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}"
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 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"]
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.")
raise FileExistsError(
f"Destination {app_dir} already exists. Use --force to overwrite or remove it manually."
)
shutil.rmtree(app_dir)
render_templates(template_dir, app_dir, entry)
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 main() -> None:
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", help="Generate scaffold for a single slug", default=None)
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")
args = parser.parse_args()
return parser.parse_args()
def main() -> None:
args = 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)
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)}")