Files
OneCAD/scripts/build_onecad_addon.py
T

215 lines
9.7 KiB
Python

#!/usr/bin/env python3
"""OneBSpace (OneCAD) — build the SolidCraft wasm bundle into the one_cad Odoo addon.
Stdlib-only build chain (python3 scripts/build_onecad_addon.py [--dry-run]):
1. optional clean: ODA_CLEAN=1 wipes the dist target before building
2. build: prefer trunk (apps/solidcraft-web/Trunk.toml); fallback is
onepixel's tools/cargo-run chain verbatim:
cargo build --release --target wasm32-unknown-unknown -p solidcraft-web
wasm-bindgen --target web --out-dir <dist> --out-name onecad <wasm>
wasm-opt -Oz (when on PATH)
plus index.html + static assets copied into <dist>
3. rebrand: <title>OneCAD</title> + data-app-name="OneCAD" in index.html;
drop sw.js / manifest.webmanifest / build.json / icon files
4. verify gate: FAIL on absolute /(assets|fonts|blobs)/ references in any text
file of the dist; require index.html, a *_bg.wasm and a
wasm-bindgen glue .js — exits non-zero on failure
5. mirror-copy: dist -> /home/z/my-project/one-os-dev/core-components/one_cad/static/onecad/
(rsync-like: stale files in the destination are removed)
6. reminder: bump bundleVersion (static/src/one_cad_app.js) + addon manifest version
Exit codes: 0 ok, 1 gate failure, 2 build environment problem.
"""
from __future__ import annotations
import argparse
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
APP = REPO / "apps" / "solidcraft-web"
DIST = REPO / "dist" / "web"
DEST = Path("/home/z/my-project/one-os-dev/core-components/one_cad/static/onecad")
WASM_TARGET = "wasm32-unknown-unknown"
WASM_ARTIFACT = REPO / "target" / WASM_TARGET / "release" / "solidcraft_web.wasm"
OUT_NAME = "onecad"
TEXT_SUFFIXES = {".html", ".js", ".mjs", ".css", ".json", ".svg", ".txt", ".webmanifest", ".map"}
ABS_REF = re.compile(r"""["'=(]/(?:assets|fonts|blobs)/""")
ICON_FILE = re.compile(r"^(icon|apple-touch-icon|favicon).*", re.I)
DROP_FILES = {"sw.js", "manifest.webmanifest", "site.webmanifest", "build.json"}
def log(message: str) -> None:
print(f"[onecad] {message}")
def sh(cmd: list[str], cwd: Path | None = None) -> None:
log("$ " + " ".join(cmd) + (f" (cwd={cwd})" if cwd else ""))
try:
subprocess.run([str(c) for c in cmd], cwd=str(cwd) if cwd else None, check=True)
except FileNotFoundError as exc:
sys.exit(f"[onecad] tool not found: {exc.filename}")
except subprocess.CalledProcessError as exc:
sys.exit(f"[onecad] command failed with exit code {exc.returncode}: {' '.join(str(c) for c in cmd)}")
def clean_dist() -> None:
if DIST.exists():
log(f"ODA_CLEAN=1: wiping {DIST}")
shutil.rmtree(DIST, ignore_errors=True)
def build() -> Path:
"""Build the wasm bundle; return the dist directory that holds index.html."""
if shutil.which("trunk"):
sh(["trunk", "build", "--release", "--public-url", "./"], cwd=APP)
else:
log("trunk not on PATH — using the cargo + wasm-bindgen chain (onepixel convention)")
sh(["cargo", "build", "--release", "--target", WASM_TARGET, "-p", "solidcraft-web"], cwd=REPO)
if not WASM_ARTIFACT.exists():
sys.exit(f"[onecad] expected wasm artifact missing: {WASM_ARTIFACT}")
DIST.mkdir(parents=True, exist_ok=True)
sh(["wasm-bindgen", "--target", "web", "--out-dir", DIST, "--out-name", OUT_NAME, WASM_ARTIFACT])
if shutil.which("wasm-opt"):
optimized = DIST / f"{OUT_NAME}_bg.wasm.opt"
sh(["wasm-opt", "-Oz", DIST / f"{OUT_NAME}_bg.wasm", "-o", optimized])
os.replace(optimized, DIST / f"{OUT_NAME}_bg.wasm")
else:
log("wasm-opt not on PATH — skipping -Oz (larger bundle, still correct)")
# Static assets trunk would have copied: index.html + the assets tree.
if (APP / "index.html").exists():
shutil.copy2(APP / "index.html", DIST / "index.html")
if (APP / "assets").is_dir():
shutil.copytree(APP / "assets", DIST / "assets", dirs_exist_ok=True)
for candidate in (DIST, APP / "dist"):
if (candidate / "index.html").is_file():
return candidate
sys.exit("[onecad] build produced no index.html in dist/web or apps/solidcraft-web/dist")
def rebrand(dist: Path) -> None:
index = dist / "index.html"
html = index.read_text(encoding="utf-8")
html, n_title = re.subn(r"<title\b[^>]*>.*?</title>", "<title>OneCAD</title>", html, flags=re.S | re.I)
if 'data-app-name="' in html:
html = re.sub(r'data-app-name="[^"]*"', 'data-app-name="OneCAD"', html)
else:
html = re.sub(r"<html\b", '<html data-app-name="OneCAD"', html, count=1)
# Service worker, PWA manifest and icon links have no meaning inside the
# Odoo iframe — drop the tags along with the files.
html = re.sub(r"<link[^>]+rel=[\"']manifest[\"'][^>]*>\s*", "", html, flags=re.I)
html = re.sub(r"<link[^>]+rel=[\"'][^\"']*(icon|apple-touch)[^\"']*[\"'][^>]*>\s*", "", html, flags=re.I)
index.write_text(html, encoding="utf-8")
log(f"rebranded {index} (title replacements: {n_title})")
dropped = 0
for path in sorted(dist.rglob("*")):
if not path.is_file():
continue
if path.name in DROP_FILES or ICON_FILE.match(path.name):
path.unlink()
dropped += 1
log(f"dropped {dropped} sw/manifest/icon files from the bundle")
def verify(dist: Path) -> list[str]:
"""The gate: absolute /(assets|fonts|blobs)/ refs fail; glue + wasm must exist."""
errors: list[str] = []
if not (dist / "index.html").is_file():
errors.append("missing index.html")
wasm_files = [p for p in dist.rglob("*.wasm")]
if not any(p.name.endswith("_bg.wasm") for p in wasm_files):
errors.append("no *_bg.wasm (wasm-bindgen output) in dist")
glue = [
p
for p in dist.rglob("*.js")
if "wasm_bindgen" in p.read_text(encoding="utf-8", errors="ignore")
or "wasm-bindgen" in p.read_text(encoding="utf-8", errors="ignore")
]
if not glue:
errors.append("no wasm-bindgen glue .js in dist")
offenders: list[str] = []
for path in sorted(dist.rglob("*")):
if not path.is_file() or path.suffix.lower() not in TEXT_SUFFIXES:
continue
text = path.read_text(encoding="utf-8", errors="ignore")
if ABS_REF.search(text):
offenders.append(str(path.relative_to(dist)))
if offenders:
errors.append(
"absolute /(assets|fonts|blobs)/ references (must be relative — the bundle is served "
f"from /one_cad/static/onecad/): {', '.join(offenders[:10])}"
+ (f" … (+{len(offenders) - 10} more)" if len(offenders) > 10 else "")
)
if errors:
for error in errors:
log(f"GATE FAIL: {error}")
else:
log(f"GATE PASS: {len(wasm_files)} wasm, {len(glue)} glue js, all references relative")
return errors
def mirror(src: Path, dst: Path) -> None:
"""rsync-like: make dst exactly src (extra files in dst are removed)."""
src_files = {p.relative_to(src) for p in src.rglob("*") if p.is_file()}
src_dirs = {p.relative_to(src) for p in src.rglob("*") if p.is_dir()}
dst.mkdir(parents=True, exist_ok=True)
removed = 0
for path in sorted(dst.rglob("*"), key=lambda p: len(p.parts), reverse=True):
rel = path.relative_to(dst)
if path.is_dir() and rel not in src_dirs:
shutil.rmtree(path)
removed += 1
elif path.is_file() and rel not in src_files:
path.unlink()
removed += 1
for rel in sorted(src_dirs):
(dst / rel).mkdir(parents=True, exist_ok=True)
for rel in sorted(src_files):
shutil.copy2(src / rel, dst / rel)
log(f"mirrored {len(src_files)} files -> {dst} ({removed} stale entries removed)")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--dry-run", action="store_true", help="print the plan, no builds/writes; gates run read-only against an existing dist")
args = parser.parse_args()
tools = {name: bool(shutil.which(name)) for name in ("trunk", "cargo", "wasm-bindgen", "wasm-opt")}
tool_report = ", ".join(f"{name}={'yes' if present else 'no'}" for name, present in tools.items())
log(f"tools: {tool_report}")
if not args.dry_run and not (tools["trunk"] or (tools["cargo"] and tools["wasm-bindgen"])):
log("need trunk, or cargo + wasm-bindgen, on PATH")
return 2
if args.dry_run:
log(f"DRY RUN: clean={'yes' if os.environ.get('ODA_CLEAN') == '1' else 'no'} -> build ({'trunk' if tools['trunk'] else 'cargo chain'}) -> {DIST} -> rebrand -> gate -> mirror {DEST}")
if (DIST / "index.html").is_file():
log("an existing dist was found; running the verification gate read-only")
return 1 if verify(DIST) else 0
log("no dist yet — plan only")
return 0
if os.environ.get("ODA_CLEAN") == "1":
clean_dist()
dist = build()
rebrand(dist)
if verify(dist):
return 1
mirror(dist, DEST)
log("NEXT STEPS:")
log(" 1. bump bundleVersion in one-os-dev/core-components/one_cad/static/src/one_cad_app.js (cache-busts the iframe bundle)")
log(" 2. bump the version in one-os-dev/core-components/one_cad/__manifest__.py to match the addon release")
log(" 3. upgrade the module (odoo -u one_cad) so the new static bundle is served")
return 0
if __name__ == "__main__":
sys.exit(main())