Files
xteink-apps/scripts/update-manifests.py
T

103 lines
3.3 KiB
Python
Executable File

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
import argparse
import hashlib
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
APPS_DIR = ROOT / "apps"
ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
DESCRIPTION_PATTERN = re.compile(r"^--\s*DESCRIPTION:\s*(.+?)\s*$")
def sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def payload_files(app_dir: Path):
for path in sorted(app_dir.rglob("*")):
if path.is_symlink():
raise ValueError(f"symlinks are not supported: {path.relative_to(ROOT)}")
if path.is_file() and path.name != "manifest.txt":
yield path
def description(app_dir: Path) -> str:
with (app_dir / "main.lua").open(encoding="utf-8") as source:
for line in source:
match = DESCRIPTION_PATTERN.match(line)
if match:
value = match.group(1)
if "|" in value:
raise ValueError(f"description contains '|': {app_dir.name}")
return value
raise ValueError(f"missing DESCRIPTION in {app_dir.name}/main.lua")
def build_manifest(app_dir: Path) -> bytes:
lines = ["XTEINK-MANIFEST|1"]
for path in payload_files(app_dir):
relative = path.relative_to(app_dir).as_posix()
if "|" in relative:
raise ValueError(f"path contains '|': {relative}")
data = path.read_bytes()
lines.append(f"{relative}|{len(data)}|{sha256(data)}")
return ("\n".join(lines) + "\n").encode()
def generated_files():
apps = [path for path in APPS_DIR.iterdir() if path.is_dir() and (path / "main.lua").is_file()]
apps.sort(key=lambda path: path.name.casefold())
manifests = {}
catalog = ["XTEINK-CATALOG|1"]
for app_dir in apps:
app_id = app_dir.name
if not ID_PATTERN.fullmatch(app_id):
raise ValueError(f"invalid app id: {app_id}")
manifest = build_manifest(app_dir)
manifest_path = f"apps/{app_id}/manifest.txt"
manifests[ROOT / manifest_path] = manifest
catalog.append(
f"{app_id}|{description(app_dir)}|{manifest_path}|{len(manifest)}|{sha256(manifest)}"
)
manifests[ROOT / "catalog.txt"] = ("\n".join(catalog) + "\n").encode()
return manifests
def main() -> int:
parser = argparse.ArgumentParser(description="Regenerate app manifests and catalog.txt")
parser.add_argument("--check", action="store_true", help="fail instead of updating stale files")
args = parser.parse_args()
stale = []
for path, expected in generated_files().items():
if not path.exists() or path.read_bytes() != expected:
stale.append(path)
if not args.check:
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_bytes(expected)
temporary.replace(path)
print(f"updated {path.relative_to(ROOT)}")
if args.check and stale:
for path in stale:
print(f"stale: {path.relative_to(ROOT)}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except ValueError as error:
print(error, file=sys.stderr)
raise SystemExit(1)