#!/usr/bin/env python3 """Package the browser extension. Default builds a testing/unpacked-style archive that keeps manifest.key so the Chromium extension ID stays stable for native messaging. ``--webstore`` writes the same runtime files but strips ``key`` from manifest.json because the Chrome Web Store rejects that field. ``--firefox`` writes a Firefox-friendly archive with the Gecko extension ID and without Chromium-only manifest keys. """ from __future__ import annotations import argparse import json import shutil import zipfile from pathlib import Path ROOT = Path(__file__).resolve().parents[1] EXTENSION_DIR = ROOT / "extension" DIST_DIR = ROOT / "dist" RUNTIME_FILES = ( "manifest.json", "background.js", "content-dispatch.js", "content.js", "icon.svg", ) RUNTIME_DIRS = ("icons",) def _read_manifest(webstore: bool, firefox: bool) -> dict: manifest = json.loads((EXTENSION_DIR / "manifest.json").read_text(encoding="utf-8")) if webstore or firefox: manifest.pop("key", None) if firefox: manifest["permissions"] = [p for p in manifest.get("permissions", []) if p != "windows"] manifest["background"] = {"scripts": ["background.js"]} gecko = manifest.setdefault("browser_specific_settings", {}).setdefault("gecko", {}) gecko["strict_min_version"] = "140.0" manifest.setdefault("browser_specific_settings", {}).setdefault("gecko_android", {})["strict_min_version"] = "142.0" gecko["data_collection_permissions"] = {"required": ["none"]} return manifest def _copy_tree(src: Path, dst: Path) -> None: if dst.exists(): shutil.rmtree(dst) shutil.copytree(src, dst) def package_extension(*, webstore: bool = False, firefox: bool = False, out: Path | None = None) -> Path: if webstore and firefox: raise ValueError("--webstore and --firefox are mutually exclusive") manifest = _read_manifest(webstore, firefox) version = manifest["version"] suffix = "firefox" if firefox else "webstore" if webstore else "testing" out = out or DIST_DIR / f"browser-cli-extension-{suffix}-v{version}.zip" staging = DIST_DIR / f"extension-package-{suffix}" if staging.exists(): shutil.rmtree(staging) staging.mkdir(parents=True) out.parent.mkdir(parents=True, exist_ok=True) for file_name in RUNTIME_FILES: source = EXTENSION_DIR / file_name if file_name == "manifest.json": (staging / file_name).write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") else: shutil.copy2(source, staging / file_name) for dir_name in RUNTIME_DIRS: _copy_tree(EXTENSION_DIR / dir_name, staging / dir_name) if out.exists(): out.unlink() with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zf: for path in sorted(staging.rglob("*")): if path.is_file(): zf.write(path, path.relative_to(staging).as_posix()) return out def main() -> None: parser = argparse.ArgumentParser(description="Package browser-cli extension") parser.add_argument("--webstore", action="store_true", help="strip manifest.key for Chrome Web Store upload") parser.add_argument("--firefox", action="store_true", help="build a Firefox-friendly extension zip") parser.add_argument("--out", type=Path, default=None, help="output zip path") args = parser.parse_args() print(package_extension(webstore=args.webstore, firefox=args.firefox, out=args.out)) if __name__ == "__main__": main()