1
0
mirror of https://github.com/j178/prek.git synced 2026-06-19 17:17:53 +02:00

Reimplement @j178/prek npm package (#1973)

Reimplements the `@j178/prek` npm package.

The package is now split into a dependency-free wrapper package plus
platform-specific `optionalDependencies`, so the npm package no longer
ships cargo-dist installer glue or extra runtime dependencies.

Closes #1969 
Closes #1918
Closes #1784

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Jo
2026-04-21 18:44:40 +08:00
committed by GitHub
parent fba1c85b1f
commit b6c591dbbc
7 changed files with 722 additions and 16 deletions
+24 -10
View File
@@ -7,6 +7,9 @@ on:
required: true
type: string
env:
UV_VERSION: "0.11.6"
jobs:
publish:
name: Upload to npmjs registry
@@ -19,11 +22,14 @@ jobs:
env:
PLAN: ${{ inputs.plan }}
steps:
- name: Fetch npm packages
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
pattern: artifacts-build-global
path: npm/
persist-credentials: false
- name: Fetch binary archives
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
pattern: artifacts-*
path: npm-artifacts/
merge-multiple: true
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -31,13 +37,21 @@ jobs:
registry-url: "https://registry.npmjs.org"
- name: Update npm
run: npm install -g npm@latest
- run: |
for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith("-npm-package.tar.gz")] | any)'); do
pkg=$(echo "$release" | jq '.artifacts[] | select(endswith("-npm-package.tar.gz"))' --raw-output)
prerelease=$(echo "$PLAN" | jq ".announcement_is_prerelease")
- name: "Install uv"
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
version: ${{ env.UV_VERSION }}
- name: Build npm packages
run: |
printf '%s' "$PLAN" > plan-dist-manifest.json
uv run ./scripts/build-npm-packages.py --plan plan-dist-manifest.json --artifacts-dir ./npm-artifacts --out-dir ./npm/.output
- name: Publish npm packages
run: |
prerelease=$(echo "$PLAN" | jq --raw-output '.announcement_is_prerelease')
jq --raw-output '.publishOrder[]' ./npm/.output/manifest.json | while IFS= read -r pkg; do
if [ "$prerelease" = "true" ]; then
npm publish --tag beta --provenance --access public "./npm/${pkg}"
npm publish --tag beta --provenance --access public "$pkg"
else
npm publish --provenance --access public "./npm/${pkg}"
npm publish --provenance --access public "$pkg"
fi
done
+5
View File
@@ -7,6 +7,9 @@ on:
required: true
type: string
env:
UV_VERSION: "0.11.6"
jobs:
publish:
name: Upload to PyPI
@@ -19,6 +22,8 @@ jobs:
steps:
- name: "Install uv"
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
version: ${{ env.UV_VERSION }}
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: wheels-*
+4 -1
View File
@@ -6,6 +6,9 @@ on:
permissions: {}
env:
UV_VERSION: "0.11.6"
jobs:
sync:
if: github.repository == 'j178/prek'
@@ -19,7 +22,7 @@ jobs:
persist-credentials: false
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
version: "latest"
version: ${{ env.UV_VERSION }}
enable-cache: true
- name: "Sync identify tags"
run: uv run --upgrade gen.py
+4
View File
@@ -18,3 +18,7 @@ site/
# profiling flamegraphs
*.flamegraph.svg
# NPM packages
npm-artifacts/
npm/.output/
+1 -5
View File
@@ -24,11 +24,7 @@ github-attestations-phase = "announce"
# Whether to publish prereleases to package managers
publish-prereleases = true
# The installers to generate for each app
installers = ["shell", "powershell", "npm", "homebrew"]
# A namespace to use when publishing this package to the npm registry
npm-scope = "@j178"
# Whether to produce an npm lockfile
npm-shrinkwrap = false
installers = ["shell", "powershell", "homebrew"]
# Target platforms to build apps for (Rust target-triple syntax)
targets = [
"aarch64-apple-darwin",
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
const { createRequire } = require("module");
const DEBUG = process.env.PREK_DEBUG === "1";
const PLATFORMS = require("../platforms.json");
function debug(message) {
if (DEBUG) {
console.error(`[prek-debug] ${message}`);
}
}
function isMusl() {
if (process.platform !== "linux") {
return false;
}
if (fs.existsSync("/etc/alpine-release")) {
debug("detected musl via /etc/alpine-release");
return true;
}
const report = process.report?.getReport?.();
if (report?.header?.glibcVersionRuntime) {
debug(`detected glibc ${report.header.glibcVersionRuntime}`);
return false;
}
if (report?.sharedObjects?.some((entry) => entry.includes("musl"))) {
debug("detected musl via process.report shared objects");
return true;
}
try {
const result = spawnSync("ldd", ["--version"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.toLowerCase();
const musl = output.includes("musl");
debug(`detected ${musl ? "musl" : "glibc"} via ldd`);
return musl;
} catch {
debug("failed to run ldd, assuming glibc");
}
try {
for (const libDir of ["/lib", "/lib64"]) {
if (!fs.existsSync(libDir)) {
continue;
}
const files = fs.readdirSync(libDir);
if (
files.some(
(file) => file.startsWith("ld-musl-") || file.startsWith("libc.musl-"),
)
) {
debug(`detected musl via ${libDir}`);
return true;
}
}
} catch {
debug("failed to scan /lib and /lib64 for musl");
}
const ldLibraryPath = process.env.LD_LIBRARY_PATH ?? "";
if (ldLibraryPath.toLowerCase().includes("musl")) {
debug("detected musl via LD_LIBRARY_PATH");
return true;
}
return false;
}
function armVersion() {
const version = Number(process.config.variables.arm_version ?? 0);
return Number.isFinite(version) ? version : 0;
}
function matchesArmVersion(spec) {
const version = armVersion();
if (spec.armVersionMin != null && version < spec.armVersionMin) {
return false;
}
if (spec.armVersionMax != null && version > spec.armVersionMax) {
return false;
}
return true;
}
function currentLibc() {
if (process.platform !== "linux") {
return null;
}
return isMusl() ? "musl" : "glibc";
}
function pickPlatformPackage() {
const libc = currentLibc();
debug(
`selecting package for platform=${process.platform} arch=${process.arch}` +
`${libc ? ` libc=${libc}` : ""} armVersion=${armVersion()}`,
);
const candidates = PLATFORMS.filter((spec) => {
if (!spec.os.includes(process.platform)) {
return false;
}
if (!spec.cpu.includes(process.arch)) {
return false;
}
if (spec.libc && spec.libc !== libc) {
return false;
}
if (!matchesArmVersion(spec)) {
return false;
}
return true;
}).sort((left, right) => {
const leftSpecificity = left.armVersionMin ?? 0;
const rightSpecificity = right.armVersionMin ?? 0;
return rightSpecificity - leftSpecificity;
});
if (candidates.length === 0) {
const libcSuffix = libc ? ` (${libc})` : "";
throw new Error(
`Unsupported platform: ${process.platform} ${process.arch}${libcSuffix}`,
);
}
return candidates[0];
}
function resolvePackageJson(packageName) {
const specifier = `${packageName}/package.json`;
const attempts = [];
const resolvers = [
() => createRequire(__filename).resolve(specifier),
() =>
path.resolve(
__dirname,
"..",
"..",
packageName.split("/")[1],
"package.json",
),
];
for (const resolve of resolvers) {
try {
const packageJsonPath = resolve();
attempts.push(packageJsonPath);
debug(`resolved ${specifier} candidate: ${packageJsonPath}`);
if (fs.existsSync(packageJsonPath)) {
debug(`using ${specifier}: ${packageJsonPath}`);
return packageJsonPath;
}
debug(`candidate does not exist: ${packageJsonPath}`);
} catch (error) {
attempts.push(String(error.message ?? error));
debug(`failed to resolve ${specifier}: ${error.message ?? error}`);
}
}
debug(`resolution attempts for ${packageName}: ${attempts.join(" | ")}`);
throw new Error(
`Platform package ${packageName} is not installed. Reinstall @j178/prek for this platform.`,
);
}
function ensureExecutable(binaryPath) {
if (process.platform === "win32") {
return;
}
try {
fs.accessSync(binaryPath, fs.constants.X_OK);
} catch {
fs.chmodSync(binaryPath, 0o755);
}
}
function main() {
if (process.env.PREK_BINARY) {
const override = path.resolve(process.env.PREK_BINARY);
debug(`using PREK_BINARY override: ${override}`);
if (!fs.existsSync(override)) {
throw new Error(`PREK_BINARY does not exist: ${override}`);
}
ensureExecutable(override);
runBinary(override);
return;
}
const spec = pickPlatformPackage();
debug(`selected package ${spec.packageName}`);
const packageJsonPath = resolvePackageJson(spec.packageName);
const binaryPath = path.join(path.dirname(packageJsonPath), spec.binaryName);
debug(`selected binary ${binaryPath}`);
ensureExecutable(binaryPath);
runBinary(binaryPath);
}
function runBinary(binaryPath) {
debug(`running ${binaryPath} ${process.argv.slice(2).join(" ")}`);
const result = spawnSync(binaryPath, process.argv.slice(2), {
stdio: "inherit",
});
if (result.error) {
throw result.error;
}
if (result.signal) {
process.kill(process.pid, result.signal);
return;
}
process.exit(result.status ?? 1);
}
try {
main();
} catch (error) {
console.error(`prek: ${error.message}`);
process.exit(1);
}
+450
View File
@@ -0,0 +1,450 @@
# /// 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(
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 Git hook manager written in Rust, designed as a drop-in alternative "
"to pre-commit."
),
"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)