#!/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 --out-name onecad wasm-opt -Oz (when on PATH) plus index.html + static assets copied into 3. rebrand: OneCAD + 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"]*>.*?", "OneCAD", 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"]+rel=[\"']manifest[\"'][^>]*>\s*", "", html, flags=re.I) html = re.sub(r"]+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())