mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-14 19:02:22 +02:00
149 lines
6.5 KiB
Python
149 lines
6.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Publish explicitly owned native ES modules; never run at application startup."""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
from pathlib import Path, PurePosixPath
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def module_path(base: Path, relative: str) -> Path:
|
|
"""Accept canonical JS paths only, contained within the declared tree."""
|
|
if not isinstance(relative, str):
|
|
raise ValueError("module paths must be strings")
|
|
parts = PurePosixPath(relative)
|
|
if (
|
|
parts.is_absolute()
|
|
or parts.as_posix() != relative
|
|
or ".." in parts.parts
|
|
or "\\" in relative
|
|
or "?" in relative
|
|
or "#" in relative
|
|
or parts.suffix != ".js"
|
|
):
|
|
raise ValueError(f"invalid module path: {relative!r}")
|
|
path = base / relative
|
|
if not path.resolve().is_relative_to(base.resolve()):
|
|
raise ValueError(f"module path escapes its tree: {relative!r}")
|
|
if any(p.is_symlink() for p in (path, *path.parents)):
|
|
raise ValueError(f"symlink in module path: {relative!r}")
|
|
return path
|
|
|
|
|
|
def check_lf_attributes(root: Path, paths: set[Path]) -> None:
|
|
"""Require portable, repository-owned LF attributes for every mapped path.
|
|
|
|
Ask Git in an isolated temporary repository so global attributes, local
|
|
info/attributes and alternate Git directories cannot mask missing rules.
|
|
Copy only attribute files at ancestors of the manifest's source/output paths.
|
|
"""
|
|
git = shutil.which("git")
|
|
if git is None:
|
|
raise ValueError("Git is required for frontend publication attribute checks")
|
|
attributes = {root / ".gitattributes"}
|
|
for path in paths:
|
|
for parent in path.parents:
|
|
if parent == root:
|
|
break
|
|
attributes.add(parent / ".gitattributes")
|
|
env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")}
|
|
env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull)
|
|
relative = sorted(path.relative_to(root).as_posix() for path in paths)
|
|
with tempfile.TemporaryDirectory(prefix="ody-frontend-attributes-") as directory:
|
|
target = Path(directory)
|
|
for attribute in attributes:
|
|
if attribute.is_symlink():
|
|
raise ValueError(f"symlink attribute file: {attribute.relative_to(root)}")
|
|
if attribute.is_file():
|
|
copy = target / attribute.relative_to(root)
|
|
copy.parent.mkdir(parents=True, exist_ok=True)
|
|
copy.write_bytes(attribute.read_bytes())
|
|
command = [git, "-c", f"core.attributesFile={os.devnull}", "-C", str(target)]
|
|
try:
|
|
subprocess.run(command + ["init", "--quiet", "--template="], env=env, check=True, capture_output=True, timeout=15)
|
|
result = subprocess.run(
|
|
command + ["check-attr", "-z", "--stdin", "text", "eol"],
|
|
input=("\0".join(relative) + "\0").encode("utf-8"),
|
|
env=env, check=True, capture_output=True, timeout=15,
|
|
)
|
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
|
|
raise ValueError("Git frontend attribute check failed") from error
|
|
fields = result.stdout.decode("utf-8").split("\0")[:-1]
|
|
effective = {name: {} for name in relative}
|
|
for index in range(0, len(fields), 3):
|
|
name, attribute, value = fields[index:index + 3]
|
|
effective[name][attribute] = value
|
|
invalid = [
|
|
f"{name} (text={values.get('text')}, eol={values.get('eol')})"
|
|
for name, values in effective.items()
|
|
if values != {"text": "set", "eol": "lf"}
|
|
]
|
|
if invalid:
|
|
raise ValueError("manifest paths require repository-owned 'text eol=lf' Git attributes: " + ", ".join(invalid))
|
|
|
|
|
|
def synchronize(root: Path = ROOT, *, check: bool = False) -> list[str]:
|
|
"""Validate the entire plan before writing. Return stale deployment paths."""
|
|
root = root.resolve()
|
|
source_root = root / "frontend" / "src"
|
|
static_root = root / "static"
|
|
manifest = json.loads((root / "frontend" / "modules.json").read_text(encoding="utf-8"))
|
|
if not isinstance(manifest, dict) or manifest.get("version") != 1 or not isinstance(manifest.get("modules"), list):
|
|
raise ValueError("expected frontend module manifest version 1")
|
|
if not manifest["modules"]:
|
|
raise ValueError("module manifest must not be empty")
|
|
sources, outputs, plan = set(), set(), []
|
|
for entry in manifest["modules"]:
|
|
source = module_path(source_root, entry["source"])
|
|
output = module_path(static_root, entry["deployment"])
|
|
if not entry["deployment"].startswith("js/"):
|
|
raise ValueError("deployment modules must be under static/js/")
|
|
if source in sources or output in outputs:
|
|
raise ValueError("each source and deployment path must be unique")
|
|
sources.add(source)
|
|
outputs.add(output)
|
|
content = source.read_bytes()
|
|
if output.exists() and not output.is_file():
|
|
raise ValueError(f"deployment path is not a file: {output}")
|
|
plan.append((output, content))
|
|
unlisted = set(source_root.rglob("*.js")) - sources
|
|
if unlisted:
|
|
raise ValueError(f"source modules missing from manifest: {sorted(map(str, unlisted))}")
|
|
check_lf_attributes(root, sources | outputs)
|
|
stale = []
|
|
for output, content in plan:
|
|
if not output.is_file() or output.read_bytes() != content:
|
|
stale.append(output.relative_to(root).as_posix())
|
|
if not check:
|
|
for output, content in plan:
|
|
if output.relative_to(root).as_posix() in stale:
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
output.write_bytes(content)
|
|
return stale
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--check", action="store_true", help="fail on invalid attributes or stale output; write no deployment files")
|
|
args = parser.parse_args()
|
|
try:
|
|
stale = synchronize(check=args.check)
|
|
except (OSError, ValueError, KeyError, TypeError) as error:
|
|
parser.exit(2, f"frontend: {error}\n")
|
|
if args.check and stale:
|
|
print("Stale frontend deployment modules: " + ", ".join(stale))
|
|
print("Run python scripts/build_frontend.py and include the source and output changes together.")
|
|
return 1
|
|
print(f"Frontend {'check passed' if args.check else 'published'} ({len(stale)} changed modules).")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|