#!/usr/bin/env python3 """Build one self-contained channel release from frontend/source. Domains must be passed explicitly (build_api always supplies them). Paths: frontend/{source,tools} → /{web,sync,out} Default channel root: artifacts/channel// under coruna-lab-web. """ from __future__ import annotations import argparse import hashlib import json import shutil import subprocess import sys from pathlib import Path from _channel_patch import gen_channel_id, validate_channel_id from _domain_patch import parse_domain_list TOOLS = Path(__file__).resolve().parent FRONTEND_ROOT = TOOLS.parent PROJECT_ROOT = FRONTEND_ROOT.parent SOURCE_ROOT = FRONTEND_ROOT / "source" ARTIFACTS_ROOT = PROJECT_ROOT / "artifacts" LAB_ROOT = FRONTEND_ROOT # cwd / out/ for tooling SUPPORT_TEMPLATES = ("test", "blank") DEFAULT_SUPPORT_TEMPLATE = "test" SUPPORT_TEMPLATE_ROOT = SOURCE_ROOT / "templates" / "support" def _ignore_junk(_dir: str, names: list[str]) -> set[str]: skip = {"_bak", "__pycache__", ".DS_Store"} return {n for n in names if n in skip or n.endswith(".pyc")} def replace_tree(src: Path, dst: Path) -> None: if dst.exists(): shutil.rmtree(dst) shutil.copytree(src, dst, symlinks=False, ignore=_ignore_junk) def normalize_support_template(value: str | None) -> str: template = (value or DEFAULT_SUPPORT_TEMPLATE).strip().lower() if template not in SUPPORT_TEMPLATES: raise SystemExit( f"unsupported --support-template {value!r}; " f"choose one of: {', '.join(SUPPORT_TEMPLATES)}" ) return template def apply_support_template(campaign_dir: Path, template: str) -> None: """Replace web/support.html according to the selected template. test — keep the campaign copy (current source HUD page) blank — overwrite with templates/support/blank.html (no HUD UI) """ template = normalize_support_template(template) dest = campaign_dir / "support.html" if template == "test": if not dest.is_file(): raise SystemExit(f"missing support.html after campaign copy: {dest}") return src = SUPPORT_TEMPLATE_ROOT / f"{template}.html" if not src.is_file(): raise SystemExit(f"missing support template: {src}") shutil.copyfile(src, dest) def copy_campaign_template(dst_campaign: Path) -> None: src = SOURCE_ROOT / "web" if not src.is_dir(): raise SystemExit(f"missing source web template: {src}") if not (src / "support.html").is_file(): raise SystemExit(f"missing source web/support.html under {src}") if dst_campaign.exists(): raise SystemExit(f"web dest already exists: {dst_campaign}") shutil.copytree(src, dst_campaign, symlinks=False, ignore=_ignore_junk) def load_json(path: Path) -> dict | None: if not path.is_file(): return None return json.loads(path.read_text()) def release_files(channel_root: Path) -> list[dict[str, object]]: files: list[dict[str, object]] = [] for top in ("web", "sync"): for path in sorted((channel_root / top).rglob("*")): if not path.is_file(): continue data = path.read_bytes() files.append( { "path": path.relative_to(channel_root).as_posix(), "size": len(data), "sha256": hashlib.sha256(data).hexdigest(), } ) return files def domains_equal(prev: dict | None, dep: list[str], rep: list[str]) -> bool: if not prev or prev.get("mode") != "fixed_domains": return False return ( prev.get("deployment", {}).get("domains") == dep and prev.get("reporting", {}).get("domains") == rep ) def seeds_from_domains(dep: list[str], rep: list[str]) -> tuple[str, str]: def one(label: str, domains: list[str]) -> str: material = label.encode("ascii") + b"\0" + b"\0".join(d.encode("ascii") for d in domains) return hashlib.sha256(material).hexdigest()[:32] dep_seed = one("deployment", dep) rep_seed = one("reporting", rep) if dep_seed == rep_seed: rep_seed = one("reporting/alt", rep) return dep_seed, rep_seed def pick_channel(explicit: str | None, channels_root: Path) -> str: channel = validate_channel_id(explicit) if explicit else gen_channel_id() while (channels_root / channel).exists(): if explicit: raise SystemExit(f"channel/{channel} already exists; choose another --channel-id") channel = gen_channel_id() return channel def resolve_python() -> str: """PHP/panel often launches `python3` such that sys.executable is ''.""" exe = (sys.executable or "").strip() if exe: return exe for name in ("python3", "python"): found = shutil.which(name) if found: return found raise SystemExit( "cannot locate python interpreter (sys.executable is empty; install python3 on PATH)" ) def run(cmd: list[str]) -> None: print("+", " ".join(cmd), flush=True) subprocess.run(cmd, cwd=str(LAB_ROOT), check=True) def resolve_domains(cli_dep: list[str], cli_rep: list[str]) -> tuple[list[str], list[str]]: if not cli_dep or not cli_rep: raise SystemExit( "provide both --deployment-domains and --reporting-domains " "(Laravel no longer supplies domains to this project)." ) return ( parse_domain_list(cli_dep, label="deployment"), parse_domain_list(cli_rep, label="reporting"), ) def main() -> int: parser = argparse.ArgumentParser( description=( "Create an isolated channel release containing web/ + sync/ from source." ) ) parser.add_argument( "--channel-id", help="optional 32-char [0-9a-z] channel id (default: random, unique under web/)", ) parser.add_argument( "--deployment-domains", action="append", required=True, help="Deployment hosts (comma-separated or repeatable)", ) parser.add_argument( "--reporting-domains", action="append", required=True, help="Reporting hosts (comma-separated or repeatable)", ) parser.add_argument("--deployment-seed", help="optional; default: derive from domains") parser.add_argument("--reporting-seed", help="optional; default: derive from domains") parser.add_argument( "--root", type=Path, help=( "exact channel output root containing web/ + sync/ " "(default: artifacts/channel/)" ), ) parser.add_argument( "--force", action="store_true", help="replace an existing channel output root", ) parser.add_argument("--force-sync", action="store_true", help=argparse.SUPPRESS) parser.add_argument( "-n", "--count", type=int, default=5, help="DGA candidates for patch_all when rebuilding sync (default 5)", ) parser.add_argument( "--support-template", default=DEFAULT_SUPPORT_TEMPLATE, choices=SUPPORT_TEMPLATES, help=( "support.html template: test (source HUD page) or blank " "(no HUD UI; default: test)" ), ) args = parser.parse_args() src_campaign = SOURCE_ROOT / "web" src_sync = SOURCE_ROOT / "sync" if not src_campaign.is_dir() or not (src_campaign / "support.html").is_file(): raise SystemExit(f"missing source web template (need web/support.html): {src_campaign}") if not src_sync.is_dir(): raise SystemExit(f"missing source sync: {src_sync}") fixed_dep, fixed_rep = resolve_domains(args.deployment_domains, args.reporting_domains) support_template = normalize_support_template(args.support_template) channels_root = ARTIFACTS_ROOT / "channel" channel = ( validate_channel_id(args.channel_id) if args.channel_id else pick_channel(None, channels_root) ) channel_root = args.root.resolve() if args.root else (channels_root / channel).resolve() if channel_root == SOURCE_ROOT.resolve() or SOURCE_ROOT.resolve() in channel_root.parents: raise SystemExit("refusing to build into frontend/source") if channel_root.exists(): if not (args.force or args.force_sync): raise SystemExit(f"channel output already exists: {channel_root} (use --force)") shutil.rmtree(channel_root) channel_root.parent.mkdir(parents=True, exist_ok=True) campaign_dir = channel_root / "web" sync_dir = channel_root / "sync" out_root = channel_root / "out" print("=== new_project ===") print(f"channel: {channel}") print(f"web dest: {campaign_dir}") print(f"support: template={support_template}") print("domains: rebuild isolated web + sync") print(f" deployment: {', '.join(fixed_dep)}") print(f" reporting: {', '.join(fixed_rep)}") print() print("=== copy campaign template ===") copy_campaign_template(campaign_dir) apply_support_template(campaign_dir, support_template) replace_tree(src_sync, sync_dir) print(f"created web/ + sync/ (support_template={support_template})") domain_args: list[str] = [] for item in fixed_dep: domain_args += ["--deployment-domains", item] for item in fixed_rep: domain_args += ["--reporting-domains", item] py = resolve_python() derived_dep, derived_rep = seeds_from_domains(fixed_dep, fixed_rep) dep = args.deployment_seed or derived_dep rep = args.reporting_seed or derived_rep cmd = [ py, str(TOOLS / "patch_all.py"), "--apply", "--root", str(channel_root), "--channel-id", channel, "--deployment-seed", dep, "--reporting-seed", rep, "-n", str(args.count), *domain_args, ] try: print() print("=== patch_all --apply ===") run(cmd) except BaseException: shutil.rmtree(channel_root, ignore_errors=True) raise secondary_manifest = load_json(out_root / "secondary" / "MANIFEST.json") or {} sync_manifest = load_json(out_root / "sync" / "MANIFEST.json") or {} release_manifest = { "schema_version": 1, "channel_id": channel, "support_template": support_template, "deployment_seed": dep, "reporting_seed": rep, "deployment_domains": fixed_dep, "reporting_domains": fixed_rep, "support_path": f"/channel/{channel}/web/support.html", "daily_path": f"/channel/{channel}/sync/daily.html", "secondary_manifest_sha256": hashlib.sha256( json.dumps(secondary_manifest, sort_keys=True).encode() ).hexdigest(), "sync_manifest_sha256": hashlib.sha256( json.dumps(sync_manifest, sort_keys=True).encode() ).hexdigest(), "files": release_files(channel_root), } (channel_root / "manifest.json").write_text( json.dumps(release_manifest, indent=2) + "\n" ) print() print("=== ready ===") print(f"web: {campaign_dir}") print(f"sync: {sync_dir}") print(f"channel: {out_root / 'channel.json'}") print(f"domains: {out_root / 'domains.json'}") print(f"manifest:{channel_root / 'manifest.json'}") print() print(f"served: /channel/{channel}/web/support.html") print(f" /channel/{channel}/sync/daily.html") return 0 if __name__ == "__main__": raise SystemExit(main())