1
0
mirror of https://github.com/j178/prek.git synced 2026-04-25 02:11:36 +02:00
Files
prek/scripts/build-npm-packages.py
Jo 09f36b0550 Update project slogan (#2000)
Update the project slogan across docs, CLI, and package metadata.
2026-04-24 12:14:23 +08:00

463 lines
15 KiB
Python

# /// 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)