# /// script # requires-python = ">=3.11" # /// from __future__ import annotations import argparse import json import shutil import sys import tarfile import zipfile from dataclasses import dataclass from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent NPM_ROOT = REPO_ROOT / "npm" @dataclass(frozen=True) class PlatformSpec: rust_target: str package_name: str archive_file: str binary_name: str os: list[str] cpu: list[str] libc: str | None = None arm_version_min: int | None = None arm_version_max: int | None = None def output_dir(self, base_dir: Path) -> Path: return base_dir.joinpath(*self.package_name.split("/")) def runtime_config(self) -> dict[str, object]: config: dict[str, object] = { "rustTarget": self.rust_target, "packageName": self.package_name, "binaryName": self.binary_name, "os": self.os, "cpu": self.cpu, } if self.libc is not None: config["libc"] = self.libc if self.arm_version_min is not None: config["armVersionMin"] = self.arm_version_min if self.arm_version_max is not None: config["armVersionMax"] = self.arm_version_max return config def package_json(self, version: str) -> dict[str, object]: package_json: dict[str, object] = { "name": self.package_name, "version": version, "description": f"Native {self.platform_label()} binary for prek.", "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/j178/prek.git", }, "homepage": "https://prek.j178.dev/", "bugs": { "url": "https://github.com/j178/prek/issues", }, "engines": { "node": ">=18", }, "preferUnplugged": True, "os": self.os, "cpu": self.cpu, "files": [self.binary_name, "README.md", "LICENSE"], } if self.libc is not None: package_json["libc"] = [self.libc] return package_json def platform_label(self) -> str: parts = [*self.os, *self.cpu] if self.libc is not None: parts.append(self.libc) return " ".join(parts) PLATFORMS = ( PlatformSpec( rust_target="aarch64-apple-darwin", package_name="@j178/prek-darwin-arm64", archive_file="prek-aarch64-apple-darwin.tar.gz", binary_name="prek", os=["darwin"], cpu=["arm64"], ), PlatformSpec( rust_target="x86_64-apple-darwin", package_name="@j178/prek-darwin-x64", archive_file="prek-x86_64-apple-darwin.tar.gz", binary_name="prek", os=["darwin"], cpu=["x64"], ), PlatformSpec( rust_target="aarch64-unknown-linux-gnu", package_name="@j178/prek-linux-arm64-gnu", archive_file="prek-aarch64-unknown-linux-gnu.tar.gz", binary_name="prek", os=["linux"], cpu=["arm64"], libc="glibc", ), PlatformSpec( rust_target="aarch64-unknown-linux-musl", package_name="@j178/prek-linux-arm64-musl", archive_file="prek-aarch64-unknown-linux-musl.tar.gz", binary_name="prek", os=["linux"], cpu=["arm64"], libc="musl", ), PlatformSpec( # Android/Termux reports process.platform as "android" and uses # Bionic, not glibc or musl. Reuse the static Linux musl artifact via a # dedicated package with no package-level libc restriction so npm can # install it on Android. rust_target="aarch64-unknown-linux-musl", package_name="@j178/prek-android-arm64", archive_file="prek-aarch64-unknown-linux-musl.tar.gz", binary_name="prek", os=["android"], cpu=["arm64"], ), PlatformSpec( rust_target="armv7-unknown-linux-gnueabihf", package_name="@j178/prek-linux-arm-gnueabihf", archive_file="prek-armv7-unknown-linux-gnueabihf.tar.gz", binary_name="prek", os=["linux"], cpu=["arm"], libc="glibc", arm_version_min=7, ), PlatformSpec( rust_target="arm-unknown-linux-musleabihf", package_name="@j178/prek-linux-arm-musleabihf", archive_file="prek-arm-unknown-linux-musleabihf.tar.gz", binary_name="prek", os=["linux"], cpu=["arm"], libc="musl", ), PlatformSpec( rust_target="armv7-unknown-linux-musleabihf", package_name="@j178/prek-linux-armv7-musleabihf", archive_file="prek-armv7-unknown-linux-musleabihf.tar.gz", binary_name="prek", os=["linux"], cpu=["arm"], libc="musl", arm_version_min=7, ), PlatformSpec( rust_target="i686-unknown-linux-gnu", package_name="@j178/prek-linux-ia32-gnu", archive_file="prek-i686-unknown-linux-gnu.tar.gz", binary_name="prek", os=["linux"], cpu=["ia32"], libc="glibc", ), PlatformSpec( rust_target="i686-unknown-linux-musl", package_name="@j178/prek-linux-ia32-musl", archive_file="prek-i686-unknown-linux-musl.tar.gz", binary_name="prek", os=["linux"], cpu=["ia32"], libc="musl", ), PlatformSpec( rust_target="riscv64gc-unknown-linux-gnu", package_name="@j178/prek-linux-riscv64-gnu", archive_file="prek-riscv64gc-unknown-linux-gnu.tar.gz", binary_name="prek", os=["linux"], cpu=["riscv64"], libc="glibc", ), PlatformSpec( rust_target="s390x-unknown-linux-gnu", package_name="@j178/prek-linux-s390x-gnu", archive_file="prek-s390x-unknown-linux-gnu.tar.gz", binary_name="prek", os=["linux"], cpu=["s390x"], libc="glibc", ), PlatformSpec( rust_target="x86_64-unknown-linux-gnu", package_name="@j178/prek-linux-x64-gnu", archive_file="prek-x86_64-unknown-linux-gnu.tar.gz", binary_name="prek", os=["linux"], cpu=["x64"], libc="glibc", ), PlatformSpec( rust_target="x86_64-unknown-linux-musl", package_name="@j178/prek-linux-x64-musl", archive_file="prek-x86_64-unknown-linux-musl.tar.gz", binary_name="prek", os=["linux"], cpu=["x64"], libc="musl", ), PlatformSpec( rust_target="aarch64-pc-windows-msvc", package_name="@j178/prek-win32-arm64-msvc", archive_file="prek-aarch64-pc-windows-msvc.zip", binary_name="prek.exe", os=["win32"], cpu=["arm64"], ), PlatformSpec( rust_target="i686-pc-windows-msvc", package_name="@j178/prek-win32-ia32-msvc", archive_file="prek-i686-pc-windows-msvc.zip", binary_name="prek.exe", os=["win32"], cpu=["ia32"], ), PlatformSpec( rust_target="x86_64-pc-windows-msvc", package_name="@j178/prek-win32-x64-msvc", archive_file="prek-x86_64-pc-windows-msvc.zip", binary_name="prek.exe", os=["win32"], cpu=["x64"], ), ) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description=("Build npm wrapper and platform packages from release archives."), ) parser.add_argument( "--plan", type=Path, required=True, help="The cargo-dist plan JSON file.", ) parser.add_argument( "--artifacts-dir", type=Path, default=REPO_ROOT / "npm-artifacts", help="Directory containing prek release archives.", ) parser.add_argument( "--out-dir", type=Path, default=NPM_ROOT / ".output", help="Directory where npm package trees will be written.", ) return parser.parse_args() def read_plan_version(plan_path: Path) -> str: print(f"Reading version from dist plan: {plan_path}") with plan_path.open(encoding="utf-8") as file: plan = json.load(file) versions = sorted( { release["app_version"] for release in plan["releases"] if release["app_name"] == "prek" }, ) if len(versions) != 1: raise RuntimeError( f"Expected exactly one prek release version, got: {', '.join(versions)}", ) return versions[0] def create_wrapper_package( output_dir: Path, version: str, platforms: list[PlatformSpec], ) -> None: bin_dir = output_dir / "bin" bin_dir.mkdir(parents=True, exist_ok=True) shutil.copy2(NPM_ROOT / "bin" / "prek.js", bin_dir / "prek.js") (bin_dir / "prek.js").chmod(0o755) with (output_dir / "platforms.json").open("w", encoding="utf-8") as file: json.dump([platform.runtime_config() for platform in platforms], file, indent=2) file.write("\n") shutil.copy2(REPO_ROOT / "README.md", output_dir / "README.md") shutil.copy2(REPO_ROOT / "CHANGELOG.md", output_dir / "CHANGELOG.md") shutil.copy2(REPO_ROOT / "LICENSE", output_dir / "LICENSE") optional_dependencies = {platform.package_name: version for platform in platforms} package_json = { "name": "@j178/prek", "version": version, "description": ( "A fast Git hook manager written in Rust, designed as a drop-in " "alternative to pre-commit, reimagined." ), "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/j178/prek.git", }, "homepage": "https://prek.j178.dev/", "bugs": { "url": "https://github.com/j178/prek/issues", }, "bin": { "prek": "bin/prek.js", }, "engines": { "node": ">=18", }, "preferUnplugged": True, "files": ["bin", "platforms.json", "README.md", "CHANGELOG.md", "LICENSE"], "optionalDependencies": optional_dependencies, } with (output_dir / "package.json").open("w", encoding="utf-8") as file: json.dump(package_json, file, indent=2) file.write("\n") def create_platform_package( artifacts_dir: Path, output_dir: Path, spec: PlatformSpec, version: str, ) -> None: output_dir.mkdir(parents=True, exist_ok=True) shutil.copy2(REPO_ROOT / "LICENSE", output_dir / "LICENSE") (output_dir / "README.md").write_text( ( f"{spec.package_name}\n\n" "Platform package for @j178/prek. Not meant to be installed directly.\n" ), encoding="utf-8", ) archive_path = artifacts_dir / spec.archive_file binary_bytes = extract_binary(archive_path, spec.binary_name) binary_path = output_dir / spec.binary_name binary_path.write_bytes(binary_bytes) if binary_path.suffix != ".exe": binary_path.chmod(0o755) with (output_dir / "package.json").open("w", encoding="utf-8") as file: json.dump(spec.package_json(version), file, indent=2) file.write("\n") def extract_binary(archive_path: Path, binary_name: str) -> bytes: if archive_path.suffixes[-2:] == [".tar", ".gz"]: return extract_from_tar_gz(archive_path, binary_name) if archive_path.suffix == ".zip": return extract_from_zip(archive_path, binary_name) raise RuntimeError(f"Unsupported archive format: {archive_path.name}") def extract_from_tar_gz(archive_path: Path, binary_name: str) -> bytes: with tarfile.open(archive_path, mode="r:gz") as archive: for member in archive.getmembers(): if Path(member.name).name != binary_name: continue extracted = archive.extractfile(member) if extracted is None: raise RuntimeError( f"Failed to extract {member.name} from {archive_path.name}", ) return extracted.read() raise RuntimeError(f"Could not find {binary_name} in {archive_path.name}") def extract_from_zip(archive_path: Path, binary_name: str) -> bytes: with zipfile.ZipFile(archive_path) as archive: for member in archive.namelist(): if Path(member).name == binary_name: return archive.read(member) raise RuntimeError(f"Could not find {binary_name} in {archive_path.name}") def validate_artifacts_dir(artifacts_dir: Path, platforms: list[PlatformSpec]) -> None: if not artifacts_dir.is_dir(): raise RuntimeError( f"Artifacts directory does not exist: {artifacts_dir}\n" "This script packages prebuilt release archives; it does not build them.\n" "Download the release artifacts into that directory or pass --artifacts-dir.", ) missing = [ platform.archive_file for platform in platforms if not (artifacts_dir / platform.archive_file).is_file() ] if missing: formatted_missing = "\n".join(f" - {name}" for name in missing) raise RuntimeError( f"Missing binary archives in {artifacts_dir}:\n{formatted_missing}", ) def build_packages(version: str, artifacts_dir: Path, out_dir: Path) -> None: platforms = list(PLATFORMS) print(f"Building {len(platforms)} platform package(s) for prek {version}") print(f"Validating binary archives in {artifacts_dir}") validate_artifacts_dir(artifacts_dir, platforms) print(f"Writing npm packages to {out_dir}") shutil.rmtree(out_dir, ignore_errors=True) out_dir.mkdir(parents=True, exist_ok=True) wrapper_dir = out_dir / "@j178" / "prek" print(f"Building wrapper package: {wrapper_dir}") create_wrapper_package(wrapper_dir, version, platforms) platform_dirs: list[Path] = [] for spec in platforms: package_dir = spec.output_dir(out_dir) print(f"Building platform package: {spec.package_name}") create_platform_package(artifacts_dir, package_dir, spec, version) platform_dirs.append(package_dir) manifest = { "version": version, "wrapper": str(wrapper_dir), "platforms": [str(path) for path in platform_dirs], "publishOrder": [str(path) for path in [*platform_dirs, wrapper_dir]], } with (out_dir / "manifest.json").open("w", encoding="utf-8") as file: json.dump(manifest, file, indent=2) file.write("\n") print(f"Wrote manifest: {out_dir / 'manifest.json'}") def main() -> int: args = parse_args() version = read_plan_version(args.plan) build_packages( version, args.artifacts_dir.resolve(), args.out_dir.resolve(), ) return 0 if __name__ == "__main__": try: raise SystemExit(main()) except Exception as exc: print(f"error: {exc}", file=sys.stderr) raise SystemExit(1)