Files
xteink-apps/scripts/update-manifests.py
2026-07-25 22:34:38 -04:00

151 lines
5.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_-]+$")
PATH_PATTERN = re.compile(r"^[A-Za-z0-9._/-]+$")
DESCRIPTION_PATTERN = re.compile(r"^--\s*DESCRIPTION:\s*(.+?)\s*$")
MAX_APPS = 64
MAX_FILES = 64
MAX_FILE_BYTES = 8 * 1024 * 1024
MAX_APP_BYTES = 16 * 1024 * 1024
MAX_MANIFEST_BYTES = 16384
MAX_CATALOG_BYTES = 32768
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.relative_to(app_dir).as_posix() not in {"manifest.txt", "README.md"}:
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 encode_lines(lines: list[str]) -> bytes:
for line in lines:
if len(line.encode()) > 192:
raise ValueError(f"generated line exceeds 192 bytes: {line[:40]}")
return ("\n".join(lines) + "\n").encode()
def build_manifest(app_dir: Path) -> bytes:
lines = ["XTEINK-MANIFEST|1"]
seen_files = set()
seen_directories = set()
total = 0
for path in payload_files(app_dir):
relative = path.relative_to(app_dir).as_posix()
lower = relative.lower()
if (not PATH_PATTERN.fullmatch(relative) or lower == ".manifest.txt" or
any(part in ("", ".", "..") for part in relative.split("/"))):
raise ValueError(f"unsupported app path: {relative}")
parent = ""
for component in lower.split("/"):
if parent:
if parent in seen_files:
raise ValueError(f"path collision: {relative}")
seen_directories.add(parent)
parent = component if not parent else f"{parent}/{component}"
if lower in seen_files or lower in seen_directories:
raise ValueError(f"path collision: {relative}")
data = path.read_bytes()
if not data or len(data) > MAX_FILE_BYTES:
raise ValueError(f"unsupported app file size: {relative}")
total += len(data)
if len(lines) > MAX_FILES or total > MAX_APP_BYTES:
raise ValueError(f"app exceeds package limits: {app_dir.name}")
seen_files.add(lower)
lines.append(f"{relative}|{len(data)}|{sha256(data)}")
manifest = encode_lines(lines)
if len(manifest) > MAX_MANIFEST_BYTES:
raise ValueError(f"manifest exceeds {MAX_MANIFEST_BYTES} bytes: {app_dir.name}")
return manifest
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())
if len(apps) > MAX_APPS:
raise ValueError(f"catalog exceeds {MAX_APPS} apps")
manifests = {}
catalog = ["XTEINK-CATALOG|1"]
seen_ids = set()
for app_dir in apps:
app_id = app_dir.name
lower_id = app_id.lower()
if not ID_PATTERN.fullmatch(app_id) or len(app_id) > 48 or lower_id in seen_ids:
raise ValueError(f"invalid or duplicate app id: {app_id}")
seen_ids.add(lower_id)
app_description = description(app_dir)
if len(app_description.encode()) > 80:
raise ValueError(f"description exceeds 80 bytes: {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}|{app_description}|{manifest_path}|{len(manifest)}|{sha256(manifest)}"
)
catalog_data = encode_lines(catalog)
if len(catalog_data) > MAX_CATALOG_BYTES:
raise ValueError(f"catalog exceeds {MAX_CATALOG_BYTES} bytes")
manifests[ROOT / "catalog.txt"] = catalog_data
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)