feat(pnpm): publish pnpm v12 (the Rust port) as pnpm and @pnpm/exe (#12670)

Make the pacquet Rust port installable from npm as pnpm v12, published with
equal content under two names — `pnpm` and `@pnpm/exe` — on the `next-12`
dist-tag (never `latest`, which keeps serving the production TypeScript pnpm
v11). First release is 12.0.0-alpha.0.

CLI presents as pnpm: clap name/bin_name are `pnpm`; PACQUET_VERSION holds the
pnpm 12.x version; the default User-Agent is the plain `pnpm/<version>` form;
the reporter footer reads `Done in ... using pnpm v<version>`; the `pnpm link`
/ `pnpm patch` / `pnpm patch-commit` hints now name pnpm (parity fixes); and
launched as `pnpx`/`pnx`, the binary injects the `dlx` subcommand itself by
detecting its current_exe name (porting pnpm's buildArgv).

npm packaging mirrors how `@pnpm/exe` ships pnpm: both the `pnpm` and `@pnpm/exe`
wrappers ship root-level placeholder bins (pnpm/pn/pnpx/pnx) plus an install.js
preinstall that hardlinks the host's native binary over the placeholder (no Node
launcher). Native binaries publish as `@pnpm/exe.<platform>-<arch>[-musl]` (file
`pnpm` inside). `pacquet`/`@pnpm/pacquet` are no longer published.

installPnpm initializes v12 exactly like `@pnpm/exe`: linkExePlatformBinary takes
the wrapper name and resolves the native binary via require, and PNPM_ALLOW_BUILDS
includes both names so the GVS hash is per-platform. resolvePackageManagerIntegrities
already resolves both `pnpm` and `@pnpm/exe`, so publishing `@pnpm/exe@12` makes
self-update / version-switch work with no v12-specific logic. From v12 the
install always uses the `pnpm` package (even from a `@pnpm/exe` build), via
pnpmPackageNameToInstall().

The configDependencies install-engine path still installs the published
`pacquet`/`@pnpm/pacquet` releases (which ship `@pacquet/<target>` binaries) and
is left untouched.
This commit is contained in:
Zoltan Kochan
2026-06-27 01:24:33 +02:00
committed by GitHub
parent 103b99bdb4
commit a33eeec9cd
31 changed files with 748 additions and 331 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/engine.pm.commands": minor
"pnpm": minor
---
`pnpm self-update` and `packageManager` version-switching can now install and link pnpm v12 (the Rust port), published with equal content under both the `pnpm` and `@pnpm/exe` names on the `next-12` dist-tag. Its native binaries ship as `@pnpm/exe.<platform>-<arch>` packages, which pnpm's built-in installer links directly — no Node.js launcher, so the command pays no Node startup cost. v12 is initialized exactly like `@pnpm/exe`, including per-platform global-virtual-store hashing. From v12 onward the install converges on the unscoped `pnpm` package (the Rust exe) — even when updating from the SEA `@pnpm/exe` build.

View File

@@ -1,16 +1,27 @@
name: Release Pacquet
name: Release pnpm (Rust)
# Manual-trigger only. Type the version to publish — the workflow patches
# pacquet/npm/pacquet/package.json with it before generating per-platform
# packages and publishing. No git tag is created and no GitHub release
# asset is uploaded; npm is the authoritative artifact store.
# Manual-trigger only: patches the version into pacquet/npm/pnpm/package.json,
# generates the per-platform packages, and publishes (no git tag / release asset
# — npm is the artifact store). Ships the Rust port under two names, `pnpm` and
# `@pnpm/exe`, both depending on the `@pnpm/exe.<target>` natives. The `tag`
# defaults to `next-12` so a release never touches the production `latest`.
on:
workflow_dispatch:
inputs:
version:
description: 'Version to publish (e.g. 0.2.3 or 0.2.3-rc.1)'
description: 'Version to publish (e.g. 12.0.0-alpha.0)'
required: true
type: string
tag:
# A `choice` (not free-form) so a mistaken `latest` can't move the
# production `pnpm` dist-tag. `latest` is intentionally omitted.
description: 'npm dist-tag to publish under'
required: true
default: 'next-12'
type: choice
options:
- next-12
- next
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -160,18 +171,15 @@ jobs:
exit 1
fi
- name: Inject version into pacquet/npm/pacquet/package.json
# The committed package.json has no `version` field; the version comes
# from the workflow_dispatch input. Patching it here means
# generate-packages.mjs (which copies the version into each
# per-platform manifest) and `pnpm publish` (which reads from
# package.json) both see the right value.
- name: Inject version into pacquet/npm/pnpm/package.json
# The committed package.json has no `version`; patch it from the input so
# generate-packages.mjs and `pnpm publish` both pick it up.
env:
VERSION: ${{ inputs.version }}
run: |
jq --arg v "$VERSION" '.version = $v' pacquet/npm/pacquet/package.json > pacquet/npm/pacquet/package.json.tmp
mv pacquet/npm/pacquet/package.json.tmp pacquet/npm/pacquet/package.json
cat pacquet/npm/pacquet/package.json
jq --arg v "$VERSION" '.version = $v' pacquet/npm/pnpm/package.json > pacquet/npm/pnpm/package.json.tmp
mv pacquet/npm/pnpm/package.json.tmp pacquet/npm/pnpm/package.json
cat pacquet/npm/pnpm/package.json
- name: Install pnpm and Node
uses: pnpm/setup@77cf06832101b3ac8c65caaf76a21643936d07a4
@@ -195,22 +203,19 @@ jobs:
- name: Generate npm packages
run: |
node pacquet/npm/pacquet/scripts/generate-packages.mjs
cat pacquet/npm/pacquet/package.json
for package in pacquet/npm/pacquet* pacquet/npm/pnpm-pacquet; do cat $package/package.json ; echo ; done
node pacquet/npm/pnpm/scripts/generate-packages.mjs
cat pacquet/npm/pnpm/package.json
for package in pacquet/npm/pacquet-* pacquet/npm/pnpm*; do cat $package/package.json ; echo ; done
- name: Publish npm packages as latest
# Auth is via npm's trusted publishing: `id-token: write` above grants
# this job an OIDC token that pnpm/npm exchange with the registry,
# so no NPM_TOKEN is needed. `--provenance` attaches the same OIDC
# token to a provenance attestation on each tarball.
# The trailing slash on $package/ changes it to publishing the directory.
# `pnpm-pacquet` is the `@pnpm/pacquet` scoped alias mirror —
# same shim and same `@pacquet/<target>` optional deps as the
# unscoped `pacquet`. Published alongside so users can adopt
# either name; the per-target binary sub-packages stay under
# the `@pacquet/` scope (no need to mirror those).
- name: Publish npm packages
# Auth via npm trusted publishing (the `id-token: write` OIDC token), so
# no NPM_TOKEN; `--provenance` attests each tarball. The trailing slash
# publishes the directory. The glob covers the native sub-packages (build
# dirs `pacquet-*`, published as `@pnpm/exe.<target>`) plus the `pnpm` and
# `@pnpm/exe` (`pnpm-exe`) wrappers.
env:
TAG: ${{ inputs.tag }}
run: |
for package in pacquet/npm/pacquet* pacquet/npm/pnpm-pacquet; do
pnpm publish "$package/" --tag latest --access public --provenance --no-git-checks
for package in pacquet/npm/pacquet-* pacquet/npm/pnpm*; do
pnpm publish "$package/" --tag "$TAG" --access public --provenance --no-git-checks
done

View File

@@ -110,8 +110,8 @@ use why::WhyArgs;
/// Experimental package manager for node.js written in rust.
#[derive(Debug, Parser)]
#[clap(name = "pacquet")]
#[clap(bin_name = "pacquet")]
#[clap(name = "pnpm")]
#[clap(bin_name = "pnpm")]
#[clap(version = pacquet_config::PACQUET_VERSION)]
#[clap(about = "Experimental package manager for node.js")]
pub struct CliArgs {

View File

@@ -22,11 +22,11 @@ pub struct LinkArgs {
#[derive(Debug, Display, Error, Diagnostic)]
#[non_exhaustive]
pub enum LinkError {
#[display("You must provide a parameter. Usage: pacquet link <dir>")]
#[display("You must provide a parameter. Usage: pnpm link <dir>")]
#[diagnostic(code(ERR_PNPM_LINK_BAD_PARAMS))]
NoParams,
#[display(r#"Cannot link by package name. Use a relative or absolute path instead, e.g. "pacquet link ./{name}""#)]
#[display(r#"Cannot link by package name. Use a relative or absolute path instead, e.g. "pnpm link ./{name}""#)]
#[diagnostic(code(ERR_PNPM_LINK_BAD_PARAMS))]
LinkByName {
#[error(not(source))]

View File

@@ -36,7 +36,7 @@ pub struct PatchArgs {
#[derive(Debug, Display, Error, Diagnostic)]
#[non_exhaustive]
pub enum PatchError {
#[display("`pacquet patch` requires the package name")]
#[display("`pnpm patch` requires the package name")]
#[diagnostic(code(ERR_PNPM_MISSING_PACKAGE_NAME))]
MissingPackageName,
@@ -538,7 +538,7 @@ fn print_success(edit_dir: &Path) {
fn render_success(edit_dir: &Path, colors_enabled: bool) -> String {
let edit_dir = edit_dir.display().to_string();
let command = format!("pacquet patch-commit {}", shell_quote(&edit_dir));
let command = format!("pnpm patch-commit {}", shell_quote(&edit_dir));
let edit_dir = if colors_enabled { edit_dir.blue().to_string() } else { edit_dir };
let command = if colors_enabled { command.green().to_string() } else { command };
render_success_parts(&edit_dir, &command)

View File

@@ -97,7 +97,7 @@ fn success_message_colors_edit_dir_and_commit_command_when_enabled() {
assert!(rendered.contains("\u{1b}[34m/tmp/edit-dir\u{1b}[39m"), "{rendered:?}");
assert!(
rendered.contains(&format!(
"\u{1b}[32mpacquet patch-commit {quote}/tmp/edit-dir{quote}\u{1b}[39m",
"\u{1b}[32mpnpm patch-commit {quote}/tmp/edit-dir{quote}\u{1b}[39m",
)),
"{rendered:?}",
);
@@ -112,7 +112,7 @@ fn success_message_is_plain_when_colors_are_disabled() {
assert_eq!(
rendered,
format!(
"Patch: You can now edit the package at:\n\n /tmp/edit-dir\n\nTo commit your changes, run:\n\n pacquet patch-commit {quote}/tmp/edit-dir{quote}\n\n",
"Patch: You can now edit the package at:\n\n /tmp/edit-dir\n\nTo commit your changes, run:\n\n pnpm patch-commit {quote}/tmp/edit-dir{quote}\n\n",
),
);
}
@@ -123,7 +123,7 @@ fn success_message_shell_quotes_single_quotes_in_edit_dir() {
let edit_dir = Path::new("/tmp/patch user's dir");
let rendered = render_success(edit_dir, false);
assert!(rendered.contains(r"pacquet patch-commit '/tmp/patch user'\''s dir'"), "{rendered}");
assert!(rendered.contains(r"pnpm patch-commit '/tmp/patch user'\''s dir'"), "{rendered}");
}
#[cfg(unix)]

View File

@@ -10,6 +10,7 @@ use config_overrides::ConfigOverrides;
use miette::set_panic_hook;
use pacquet_diagnostics::enable_tracing_by_env;
use state::State;
use std::{ffi::OsString, path::Path};
pub fn main() -> miette::Result<()> {
enable_tracing_by_env();
@@ -19,7 +20,7 @@ pub fn main() -> miette::Result<()> {
// arbitrary, so a `--config.registry=...` from pnpm's forwarded flags
// would otherwise error out as "unexpected argument". Each extracted
// token is layered onto `Config` after `.npmrc` / yaml run.
let (config_overrides, argv) = ConfigOverrides::extract(std::env::args_os());
let (config_overrides, argv) = ConfigOverrides::extract(argv_with_alias_subcommand());
// The default reporter's `Done in ... using pacquet v<version>` footer needs
// the version before the first event (including the fast path's).
pacquet_default_reporter::set_package_version(pacquet_config::PACQUET_VERSION);
@@ -67,6 +68,27 @@ pub fn main() -> miette::Result<()> {
/// same room on every platform, including Windows (1 MiB default).
const MAIN_STACK_SIZE: usize = 32 * 1024 * 1024;
/// Process argv with a leading `dlx` injected when launched as `pnpx`/`pnx`
/// (shorthand for `pnpm dlx`), mirroring pnpm's `buildArgv`. Only the Windows
/// hardlink aliases rely on this — the Unix alias scripts inject `dlx`
/// themselves — and there `current_exe` is the only signal of the launch name.
fn argv_with_alias_subcommand() -> Vec<OsString> {
let exe = std::env::current_exe().ok();
let exe_name =
exe.as_deref().and_then(Path::file_stem).map(|stem| stem.to_string_lossy().to_lowercase());
inject_alias_subcommand(exe_name.as_deref(), std::env::args_os().collect())
}
/// Insert a leading `dlx` token after the program name when `exe_name` is a
/// `pnpx`/`pnx` alias. Split out from [`argv_with_alias_subcommand`] so the
/// argv rewrite is unit-testable without depending on `current_exe`.
fn inject_alias_subcommand(exe_name: Option<&str>, mut argv: Vec<OsString>) -> Vec<OsString> {
if matches!(exe_name, Some("pnpx" | "pnx")) {
argv.insert(argv.len().min(1), OsString::from("dlx"));
}
argv
}
/// Size rayon's global pool at `2 × available_parallelism`. The link
/// phase is dominated by clonefile / hardlink syscalls that block the
/// calling thread on the kernel's metadata journal, not by CPU work,
@@ -123,3 +145,6 @@ fn configure_rayon_pool() {
.max(4);
let _ = rayon::ThreadPoolBuilder::new().num_threads(n).build_global();
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,28 @@
use super::inject_alias_subcommand;
use std::ffi::OsString;
fn argv(parts: &[&str]) -> Vec<OsString> {
parts.iter().map(OsString::from).collect()
}
#[test]
fn pnpx_and_pnx_inject_dlx_after_the_program_name() {
for alias in ["pnpx", "pnx"] {
let result = inject_alias_subcommand(Some(alias), argv(&[alias, "create-vite", "app"]));
assert_eq!(result, argv(&[alias, "dlx", "create-vite", "app"]));
}
}
#[test]
fn pnpm_pn_and_pacquet_names_are_left_untouched() {
for name in ["pnpm", "pn", "pacquet", "PNPX-not-exact"] {
let original = argv(&[name, "install"]);
assert_eq!(inject_alias_subcommand(Some(name), original.clone()), original);
}
}
#[test]
fn an_unknown_executable_name_is_left_untouched() {
let original = argv(&["whatever", "install"]);
assert_eq!(inject_alias_subcommand(None, original.clone()), original);
}

View File

@@ -0,0 +1,27 @@
//! Covers the `current_exe`-based `dlx` injection (`argv_with_alias_subcommand`)
//! that pnpm relies on for the Windows `pnpx`/`pnx` hardlinks, exercised on Unix
//! by copying the binary under the alias name — the same code runs everywhere.
#![cfg(unix)]
use std::{fs, os::unix::fs::PermissionsExt, process::Command};
use tempfile::TempDir;
#[test]
fn launched_as_pnpx_injects_the_dlx_subcommand() {
let pacquet = env!("CARGO_BIN_EXE_pacquet");
let dir = TempDir::new().expect("create temp dir");
let pnpx = dir.path().join("pnpx");
fs::copy(pacquet, &pnpx).expect("copy the binary under the pnpx name");
fs::set_permissions(&pnpx, fs::Permissions::from_mode(0o755)).expect("make pnpx executable");
let via_pnpx = Command::new(&pnpx).arg("--help").output().expect("run `pnpx --help`");
let via_dlx = Command::new(pacquet).args(["dlx", "--help"]).output().expect("run `dlx --help`");
assert!(via_pnpx.status.success(), "`pnpx --help` exited with a failure status");
assert!(via_dlx.status.success(), "`dlx --help` (the control) exited with a failure status");
let pnpx_help = String::from_utf8(via_pnpx.stdout).expect("pnpx help is UTF-8");
let dlx_help = String::from_utf8(via_dlx.stdout).expect("dlx help is UTF-8");
assert_eq!(pnpx_help, dlx_help, "`pnpx --help` should equal `dlx --help` (dlx was injected)");
}

View File

@@ -273,11 +273,13 @@ pub fn default_fetch_retry_maxtimeout() -> u64 {
60_000
}
/// pacquet's user-facing release version — the same value
/// `pacquet --version` prints. Single source of truth so the CLI
/// The CLI's user-facing release version — the same value
/// `pnpm --version` prints. Single source of truth so the CLI
/// version string and the default `User-Agent` (`default_user_agent`)
/// can't drift apart.
pub const PACQUET_VERSION: &str = "0.2.2";
/// can't drift apart. The release workflow patches this constant to the
/// version being published; the committed value tracks the current
/// pre-release line.
pub const PACQUET_VERSION: &str = "12.0.0-alpha.0";
pub fn default_fetch_timeout() -> u64 {
pacquet_network::DEFAULT_FETCH_TIMEOUT_MS
@@ -286,14 +288,14 @@ pub fn default_fetch_timeout() -> u64 {
/// Default `User-Agent`, mirroring pnpm v11's
/// [`config/reader/src/index.ts:293`](https://github.com/pnpm/pnpm/blob/1819226b51/config/reader/src/index.ts#L293)
/// format `${name}/${version} npm/? node/${nodeVersion} ${platform} ${arch}`.
/// The `name/version` segment is `pnpm/pacquet-<version>` so registries can
/// tell pacquet's traffic apart from the TypeScript pnpm CLI. pacquet has no
/// embedded Node runtime, so the `node/` segment is the `?` placeholder pnpm
/// already uses for `npm/`. Platform and arch use Node's naming via
/// The `name/version` segment is `pnpm/<version>`, matching the TypeScript
/// pnpm CLI exactly. There is no embedded Node runtime, so the `node/`
/// segment is the `?` placeholder pnpm already uses for `npm/`. Platform and
/// arch use Node's naming via
/// [`pacquet_detect_libc::host_platform`] / [`pacquet_detect_libc::host_arch`].
pub fn default_user_agent() -> String {
format!(
"pnpm/pacquet-{PACQUET_VERSION} npm/? node/? {} {}",
"pnpm/{PACQUET_VERSION} npm/? node/? {} {}",
pacquet_detect_libc::host_platform(),
pacquet_detect_libc::host_arch(),
)

View File

@@ -360,12 +360,12 @@ fn fetch_timeout_default_matches_pnpm() {
}
/// The exact platform / arch depend on where the test runs, so assert
/// the `pnpm/pacquet-<version> npm/? node/? ` prefix and the two
/// trailing space-separated tokens rather than the full string.
/// the `pnpm/<version> npm/? node/? ` prefix and the two trailing
/// space-separated tokens rather than the full string.
#[test]
fn user_agent_default_matches_pnpm_format() {
let ua = default_user_agent();
let prefix = format!("pnpm/pacquet-{PACQUET_VERSION} npm/? node/? ");
let prefix = format!("pnpm/{PACQUET_VERSION} npm/? node/? ");
assert!(ua.starts_with(&prefix), "user-agent {ua:?} must start with {prefix:?}");
let tail: Vec<&str> = ua[prefix.len()..].split(' ').collect();
assert_eq!(tail.len(), 2, "expected `<platform> <arch>` tail, got {ua:?}");

View File

@@ -1029,7 +1029,7 @@ pub struct Config {
/// Value of the `User-Agent` header sent on every registry request.
/// Mirrors pnpm's `userAgent`; the default is pnpm's
/// `pnpm/pacquet-<version> npm/? node/? <platform> <arch>` format (built by
/// `pnpm/<version> npm/? node/? <platform> <arch>` format (built by
/// `default_user_agent`).
#[default(_code = "default_user_agent()")]
pub user_agent: String,

View File

@@ -42,7 +42,7 @@ pub fn set_cwd(cwd: impl Into<String>) {
let _ = CWD.set(cwd.into());
}
/// Set the version rendered in the `Done in ... using pacquet v<version>`
/// Set the version rendered in the `Done in ... using pnpm v<version>`
/// footer. Call once before the first event; ignored if already set.
pub fn set_package_version(version: impl Into<String>) {
let _ = PACKAGE_VERSION.set(version.into());

View File

@@ -960,7 +960,7 @@ impl ReporterState {
fn on_execution_time(&mut self, log: &ExecutionTimeLog) {
let elapsed = log.ended_at.saturating_sub(log.started_at);
let msg =
format!("Done in {} using pacquet v{}", pretty_ms(elapsed), crate::package_version());
format!("Done in {} using pnpm v{}", pretty_ms(elapsed), crate::package_version());
let mut slot = std::mem::take(&mut self.exec_slot);
self.frame.emit(&mut slot, msg, true);
self.exec_slot = slot;

View File

@@ -206,7 +206,7 @@ fn execution_time_renders_done_footer() {
ended_at: 3500,
})],
);
assert!(frame.starts_with("Done in 2.5s using pacquet v"), "got: {frame}");
assert!(frame.starts_with("Done in 2.5s using pnpm v"), "got: {frame}");
}
#[test]
@@ -250,7 +250,7 @@ fn full_install_frame_orders_blocks_like_pnpm() {
frame,
"Packages: +1\n+\n\ndependencies:\n+ foo 1.0.0\n\n\
Progress: resolved 1, reused 1, downloaded 0, added 1, done\n\
Done in 1.2s using pacquet v0.0.1",
Done in 1.2s using pnpm v0.0.1",
);
}

View File

@@ -30,10 +30,10 @@ use std::{collections::HashMap, num::NonZeroUsize, ops::Deref, sync::Arc, time::
///
/// Production installs override this with the value resolved by
/// `pacquet-config` (`userAgent`, defaulting to pnpm's
/// `pnpm/pacquet-<version> npm/? node/? <platform> <arch>` format — see
/// `config/reader/src/index.ts`). The `pnpm` token is preserved in
/// that default so any UA-keyed allow / rate-limit rule that lets pnpm
/// through also lets pacquet through.
/// `pnpm/<version> npm/? node/? <platform> <arch>` format — see
/// `config/reader/src/index.ts`). The leading `pnpm` token matches the
/// TypeScript CLI exactly, so any UA-keyed allow / rate-limit rule that
/// lets pnpm through also lets this build through.
///
/// A default `reqwest::Client` sends *no* User-Agent at all, which
/// some registry CDNs and corporate WAFs treat as a bot signature and

View File

@@ -1,17 +0,0 @@
# pacquet
> **pacquet is under active development and not yet ready for production use.** See the [project roadmap](https://github.com/pnpm/pacquet/issues/299).
The official pnpm rewrite in Rust.
pacquet is a port of the [pnpm](https://github.com/pnpm/pnpm) CLI from TypeScript to Rust. It is not a new package manager and not a reimagining of pnpm. Its behavior, flags, defaults, error codes, file formats, and directory layout will match pnpm exactly.
## Installation
```sh
pnpm add -g pacquet
```
This package is a thin Node.js wrapper that dispatches to a platform-specific native binary for your operating system and architecture.
Prebuilt binaries are available for `linux-x64`, `linux-arm64`, `linux-x64-musl`, `linux-arm64-musl`, `darwin-x64`, `darwin-arm64`, `win32-x64`, and `win32-arm64`.

View File

@@ -1,4 +0,0 @@
This is a placeholder. pacquet's native binary replaces this file during
installation (see ../install.js). If you are reading this, the install/build
script did not run — reinstall with build scripts enabled (e.g. allow-list
pacquet's build under pnpm or Bun, or drop --ignore-scripts).

View File

@@ -1,148 +0,0 @@
// Code copied from [Rome](https://github.com/rome/tools/blob/392d188a49/npm/rome/scripts/generate-packages.mjs)
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import * as fs from "node:fs";
const BIN_NAME = "pacquet";
// The wrapper package is published under two names: the original
// `pacquet` (kept for back-compat) and `@pnpm/pacquet` (the official
// pnpm-scoped alias). Both ship the same placeholder bin + preinstall
// relinker and depend on the same `@pacquet/<target>` binary sub-packages.
const SCOPED_ALIAS_NAME = "@pnpm/pacquet";
const SCOPED_ALIAS_DIR = "pnpm-pacquet";
const PACQUET_ROOT = resolve(fileURLToPath(import.meta.url), "../..");
const PACKAGES_ROOT = resolve(PACQUET_ROOT, "..");
const REPO_ROOT = resolve(PACKAGES_ROOT, "../..");
const MANIFEST_PATH = resolve(PACQUET_ROOT, "package.json");
const rootManifest = JSON.parse(
fs.readFileSync(MANIFEST_PATH, "utf-8")
);
function generateNativePackage(target) {
const packageName = nativePackageName(target);
const packageRoot = resolve(PACKAGES_ROOT, `${BIN_NAME}-${target.codeTarget}`);
// Remove the directory just in case it already exists (it's autogenerated
// so there shouldn't be anything important there anyway)
fs.rmSync(packageRoot, { recursive: true, force: true });
// Create the package directory
console.log(`Create directory ${packageRoot}`);
fs.mkdirSync(packageRoot);
// Generate the package.json manifest
const { version } = rootManifest;
// publishConfig.executableFiles tells pnpm pack to keep mode 0755
// on the binary in the published tarball. Without it, pack normalizes
// every non-bin file to 0644 and the preinstall hard-link inherits a
// non-executable inode, so the relinked `pacquet` fails with EACCES.
const ext = target.platform === "win32" ? ".exe" : "";
const manifestData = {
name: packageName,
version,
os: [target.platform],
cpu: [target.arch],
repository: {
type: "git",
url: "https://github.com/pnpm/pnpm",
},
publishConfig: {
executableFiles: [`./${BIN_NAME}${ext}`],
},
};
if (target.libc) {
manifestData.libc = [target.libc];
}
const manifest = JSON.stringify(manifestData, null, 2);
const manifestPath = resolve(packageRoot, "package.json");
console.log(`Create manifest ${manifestPath}`);
fs.writeFileSync(manifestPath, manifest);
// Copy the binary
const binarySource = resolve(REPO_ROOT, `${BIN_NAME}-${target.codeTarget}${ext}`);
const binaryTarget = resolve(packageRoot, `${BIN_NAME}${ext}`);
console.log(`Copy binary ${binaryTarget}`);
fs.copyFileSync(binarySource, binaryTarget);
fs.chmodSync(binaryTarget, 0o755);
}
function writeManifest() {
const manifestPath = resolve(PACKAGES_ROOT, BIN_NAME, "package.json");
const manifestData = JSON.parse(
fs.readFileSync(manifestPath, "utf-8")
);
const nativePackages = TARGETS.map((target) => [
nativePackageName(target),
rootManifest.version,
]);
manifestData["version"] = rootManifest.version;
manifestData["optionalDependencies"] = Object.fromEntries(nativePackages);
console.log(`Update manifest ${manifestPath}`);
const content = JSON.stringify(manifestData);
fs.writeFileSync(manifestPath, content);
}
function generateScopedAliasPackage() {
const aliasRoot = resolve(PACKAGES_ROOT, SCOPED_ALIAS_DIR);
fs.rmSync(aliasRoot, { recursive: true, force: true });
fs.mkdirSync(resolve(aliasRoot, "bin"), { recursive: true });
// Mirror the placeholder bin and the preinstall relinker 1:1. Copying instead
// of symlinking keeps the tarball self-contained for `pnpm publish`.
fs.copyFileSync(
resolve(PACQUET_ROOT, "bin", BIN_NAME),
resolve(aliasRoot, "bin", BIN_NAME),
);
fs.copyFileSync(
resolve(PACQUET_ROOT, "install.js"),
resolve(aliasRoot, "install.js"),
);
// The pacquet manifest at this point already carries the version and
// optionalDependencies that `writeManifest` patched in. Reuse it and
// swap only the package name + repo.directory pointer.
const baseManifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, "utf-8"));
const aliasManifest = {
...baseManifest,
name: SCOPED_ALIAS_NAME,
repository: { ...baseManifest.repository, directory: `pacquet/npm/${SCOPED_ALIAS_DIR}` },
};
fs.writeFileSync(
resolve(aliasRoot, "package.json"),
JSON.stringify(aliasManifest),
);
}
function nativePackageName(target) {
return `@${BIN_NAME}/${target.packageTarget}`;
}
const TARGETS = [
{ platform: "win32", arch: "x64", codeTarget: "win32-x64", packageTarget: "win32-x64" },
{ platform: "win32", arch: "arm64", codeTarget: "win32-arm64", packageTarget: "win32-arm64" },
{ platform: "darwin", arch: "x64", codeTarget: "darwin-x64", packageTarget: "darwin-x64" },
{ platform: "darwin", arch: "arm64", codeTarget: "darwin-arm64", packageTarget: "darwin-arm64" },
{ platform: "linux", arch: "x64", libc: "glibc", codeTarget: "linux-x64", packageTarget: "linux-x64" },
{ platform: "linux", arch: "arm64", libc: "glibc", codeTarget: "linux-arm64", packageTarget: "linux-arm64" },
{ platform: "linux", arch: "x64", libc: "musl", codeTarget: "linux-x64-musl", packageTarget: "linux-x64-musl" },
{ platform: "linux", arch: "arm64", libc: "musl", codeTarget: "linux-arm64-musl", packageTarget: "linux-arm64-musl" },
];
for (const target of TARGETS) {
generateNativePackage(target);
}
writeManifest();
// Must run after `writeManifest`: the alias mirrors the patched
// pacquet manifest (version + optionalDependencies), so reading it
// before the patch would copy stale values.
generateScopedAliasPackage();

272
pacquet/npm/pnpm/README.md Normal file
View File

@@ -0,0 +1,272 @@
[简体中文](https://pnpm.io/zh/) |
[日本語](https://pnpm.io/ja/) |
[한국어](https://pnpm.io/ko/) |
[Italiano](https://pnpm.io/it/) |
[Português Brasileiro](https://pnpm.io/pt/)
<picture>
<source media="(prefers-color-scheme: light)" srcset="https://i.imgur.com/qlW1eEG.png">
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/qlW1eEG.png">
<img src="https://i.imgur.com/qlW1eEG.png" alt="pnpm">
</picture>
Fast, disk space efficient package manager:
* **Fast.** Up to 2x faster than the alternatives (see [benchmark](#benchmark)).
* **Efficient.** Files inside `node_modules` are linked from a single content-addressable storage.
* **[Great for monorepos](https://pnpm.io/workspaces).**
* **Strict.** A package can access only dependencies that are specified in its `package.json`.
* **Deterministic.** Has a lockfile called `pnpm-lock.yaml`.
* **Works as a Node.js version manager.** See [pnpm runtime](https://pnpm.io/11.x/cli/runtime).
* **Works everywhere.** Supports Windows, Linux, and macOS.
* **Battle-tested.** Used in production by teams of [all sizes](https://pnpm.io/workspaces#usage-examples) since 2016.
* [See the full feature comparison with npm and Yarn](https://pnpm.io/feature-comparison).
To quote the [Rush](https://rushjs.io/) team:
> Microsoft uses pnpm in Rush repos with hundreds of projects and hundreds of PRs per day, and weve found it to be very fast and reliable.
[![npm version](https://img.shields.io/npm/v/pnpm.svg?label=latest)](https://github.com/pnpm/pnpm/releases/latest)
[![OpenCollective](https://opencollective.com/pnpm/backers/badge.svg)](https://opencollective.com/pnpm)
[![OpenCollective](https://opencollective.com/pnpm/sponsors/badge.svg)](https://opencollective.com/pnpm)
[![X Follow](https://img.shields.io/twitter/follow/pnpmjs.svg?style=social&label=Follow)](https://x.com/intent/follow?screen_name=pnpmjs&region=follow_link)
[![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/badges/StandWithUkraine.svg)](https://stand-with-ukraine.pp.ua)
<!-- sponsors -->
## Platinum Sponsors
<table>
<tbody>
<tr>
<td align="center" valign="middle">
<a href="https://bit.cloud/?utm_source=pnpm&utm_medium=readme" target="_blank" rel="noopener noreferrer"><img src="https://pnpm.io/img/users/bit.svg" width="80" alt="Bit"></a>
</td>
<td align="center" valign="middle">
<a href="https://openai.com/?utm_source=pnpm&utm_medium=readme" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/openai_dark.svg" />
<source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/openai_light.svg" />
<img src="https://pnpm.io/img/users/openai_dark.svg" width="160" alt="OpenAI" />
</picture>
</a>
</td>
</tr>
</tbody>
</table>
## Gold Sponsors
<table>
<tbody>
<tr>
<td align="center" valign="middle">
<a href="https://sanity.io/?utm_source=pnpm&utm_medium=readme" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/sanity.svg" />
<source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/sanity_light.svg" />
<img src="https://pnpm.io/img/users/sanity.svg" width="120" alt="Sanity" />
</picture>
</a>
</td>
<td align="center" valign="middle">
<a href="https://discord.com/?utm_source=pnpm&utm_medium=readme" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/discord.svg" />
<source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/discord_light.svg" />
<img src="https://pnpm.io/img/users/discord.svg" width="220" alt="Discord" />
</picture>
</a>
</td>
<td align="center" valign="middle">
<a href="https://vite.dev/?utm_source=pnpm&utm_medium=readme" target="_blank" rel="noopener noreferrer"><img src="https://pnpm.io/img/users/vitejs.svg" width="42" alt="Vite"></a>
</td>
</tr>
<tr>
<td align="center" valign="middle">
<a href="https://serpapi.com/?utm_source=pnpm&utm_medium=readme" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/serpapi_dark.svg" />
<source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/serpapi_light.svg" />
<img src="https://pnpm.io/img/users/serpapi_dark.svg" width="160" alt="SerpApi" />
</picture>
</a>
</td>
<td align="center" valign="middle">
<a href="https://coderabbit.ai/?utm_source=pnpm&utm_medium=readme" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/coderabbit.svg" />
<source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/coderabbit_light.svg" />
<img src="https://pnpm.io/img/users/coderabbit.svg" width="220" alt="CodeRabbit" />
</picture>
</a>
</td>
<td align="center" valign="middle">
<a href="https://stackblitz.com/?utm_source=pnpm&utm_medium=readme" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/stackblitz.svg" />
<source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/stackblitz_light.svg" />
<img src="https://pnpm.io/img/users/stackblitz.svg" width="190" alt="Stackblitz" />
</picture>
</a>
</td>
</tr>
<tr>
<td align="center" valign="middle">
<a href="https://workleap.com/?utm_source=pnpm&utm_medium=readme" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/workleap.svg" />
<source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/workleap_light.svg" />
<img src="https://pnpm.io/img/users/workleap.svg" width="190" alt="Workleap" />
</picture>
</a>
</td>
<td align="center" valign="middle">
<a href="https://nx.dev/?utm_source=pnpm&utm_medium=readme" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/nx.svg" />
<source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/nx_light.svg" />
<img src="https://pnpm.io/img/users/nx.svg" width="50" alt="Nx" />
</picture>
</a>
</td>
</tr>
</tbody>
</table>
## Silver Sponsors
<table>
<tbody>
<tr>
<td align="center" valign="middle">
<a href="https://replit.com/?utm_source=pnpm&utm_medium=readme" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/replit.png" />
<source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/replit_light.png" />
<img src="https://pnpm.io/img/users/replit.png" width="140" alt="Replit" />
</picture>
</a>
</td>
<td align="center" valign="middle">
<a href="https://cybozu.co.jp/?utm_source=pnpm&utm_medium=readme" target="_blank" rel="noopener noreferrer"><img src="https://pnpm.io/img/users/cybozu.svg" width="70" alt="Cybozu"></a>
</td>
<td align="center" valign="middle">
<a href="https://www.bairesdev.com/?utm_source=pnpm&utm_medium=readme" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/bairesdev.svg" />
<source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/bairesdev_light.svg" />
<img src="https://pnpm.io/img/users/bairesdev.svg" width="160" alt="BairesDev" />
</picture>
</a>
</td>
</tr>
<tr>
<td align="center" valign="middle">
<a href="https://www.thesys.dev/?utm_source=pnpm&utm_medium=readme" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/thesys.svg" />
<source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/thesys_light.svg" />
<img src="https://pnpm.io/img/users/thesys.svg" width="120" alt="Thesys" />
</picture>
</a>
</td>
<td align="center" valign="middle">
<a href="https://devowl.io/?utm_source=pnpm&utm_medium=readme" target="_blank" rel="noopener noreferrer"><img src="https://pnpm.io/img/users/devowlio.svg" width="100" alt="devowl.io"></a>
</td>
<td align="center" valign="middle">
<a href="https://uscreen.de/?utm_source=pnpm&utm_medium=readme" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/uscreen.svg" />
<source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/uscreen_light.svg" />
<img src="https://pnpm.io/img/users/uscreen.svg" width="180" alt="u|screen" />
</picture>
</a>
</td>
</tr>
<tr>
<td align="center" valign="middle">
<a href="https://www.leniolabs.com/?utm_source=pnpm&utm_medium=readme" target="_blank" rel="noopener noreferrer"><img src="https://pnpm.io/img/users/leniolabs.jpg" width="40" alt="Leniolabs_"></a>
</td>
<td align="center" valign="middle">
<a href="https://depot.dev/?utm_source=pnpm&utm_medium=readme" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/depot.svg" />
<source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/depot_light.svg" />
<img src="https://pnpm.io/img/users/depot.svg" width="100" alt="Depot" />
</picture>
</a>
</td>
<td align="center" valign="middle">
<a href="https://cerbos.dev/?utm_source=pnpm&utm_medium=readme" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/cerbos.svg" />
<source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/cerbos_light.svg" />
<img src="https://pnpm.io/img/users/cerbos.svg" width="90" alt="Cerbos" />
</picture>
</a>
</td>
</tr>
<tr>
<td align="center" valign="middle">
<a href="https://time.now/?utm_source=pnpm&utm_medium=readme" target="_blank" rel="noopener noreferrer">⏱️ Time.now</a>
</td>
</tr>
</tbody>
</table>
<!-- sponsors end -->
Support this project by [becoming a sponsor](https://opencollective.com/pnpm#sponsor).
## Background
pnpm uses a content-addressable filesystem to store all files from all module directories on a disk.
When using npm, if you have 100 projects using lodash, you will have 100 copies of lodash on disk.
With pnpm, lodash will be stored in a content-addressable storage, so:
1. If you depend on different versions of lodash, only the files that differ are added to the store.
If lodash has 100 files, and a new version has a change only in one of those files,
`pnpm update` will only add 1 new file to the storage.
1. All the files are saved in a single place on the disk. When packages are installed, their files are linked
from that single place consuming no additional disk space. Linking is performed using either hard-links or reflinks (copy-on-write).
As a result, you save gigabytes of space on your disk and you have a lot faster installations!
If you'd like more details about the unique `node_modules` structure that pnpm creates and
why it works fine with the Node.js ecosystem, read this small article: [Flat node_modules is not the only way](https://pnpm.io/blog/2020/05/27/flat-node-modules-is-not-the-only-way).
💖 Like this project? Let people know with a [tweet](https://r.pnpm.io/tweet)
## Installation
For installation options [visit our website](https://pnpm.io/installation).
## Usage
Just use pnpm in place of npm/Yarn. E.g., install dependencies via:
```
pnpm install
```
For more advanced usage, read [pnpm CLI](https://pnpm.io/pnpm-cli) on our website, or run `pnpm help`.
## Benchmark
pnpm is up to 2x faster than npm and Yarn classic. See all benchmarks [here](https://r.pnpm.io/benchmarks).
Benchmarks on an app with lots of dependencies:
![](https://pnpm.io/img/benchmarks/alotta-files.svg)
## Support
- [Frequently Asked Questions](https://pnpm.io/faq)
- [X](https://x.com/pnpmjs)
- [Bluesky](https://bsky.app/profile/pnpm.io)
- [Discord](https://r.pnpm.io/chat)
## License
[MIT](https://github.com/pnpm/pnpm/blob/main/LICENSE)

View File

@@ -1,14 +1,15 @@
#!/usr/bin/env node
// Preinstall: replace the placeholder `bin/pacquet` with the platform's native
// binary, so the command runs the binary directly instead of paying Node.js
// startup on every call. Mirrors how `@pnpm/exe` ships pnpm.
// Preinstall for the pnpm v12 wrapper (shared verbatim by `pnpm` and
// `@pnpm/exe`): replace the shebang-less placeholder bins with the host's native
// binary so `pnpm` runs directly, no Node startup per call. A placeholder (not a
// Node launcher) is required because the Windows shim is generated from the bin
// file and npm won't re-read package.json after preinstall; the tradeoff is no
// fallback when build scripts are blocked (`--ignore-scripts`, pnpm/Bun default).
//
// The published `bin/pacquet` is a shebang-less placeholder: the Windows `.bin`
// shim is generated from the bin file, so a Node launcher there would bake in a
// `node bin/pacquet` call this script cannot rewrite (npm does not re-read
// package.json after preinstall). The cost is that there is no fallback — when
// build scripts are blocked (`--ignore-scripts`, pnpm/Bun defaults) the
// placeholder stays until the build is allow-listed.
// `pn`/`pnpx`/`pnx` are committed `#!/bin/sh` scripts on Unix (so only `pnpm` is
// relinked); on Windows the native binary is hardlinked onto each and
// self-detects its launch name to inject `dlx` (see `argv_with_alias_subcommand`
// in the cli crate).
import console from 'node:console'
import fs from 'node:fs'
import { createRequire } from 'node:module'
@@ -22,31 +23,33 @@ const { platform, arch } = process
const PLATFORMS = {
win32: {
x64: '@pacquet/win32-x64/pacquet.exe',
arm64: '@pacquet/win32-arm64/pacquet.exe',
x64: '@pnpm/exe.win32-x64/pnpm.exe',
arm64: '@pnpm/exe.win32-arm64/pnpm.exe',
},
darwin: {
x64: '@pacquet/darwin-x64/pacquet',
arm64: '@pacquet/darwin-arm64/pacquet',
x64: '@pnpm/exe.darwin-x64/pnpm',
arm64: '@pnpm/exe.darwin-arm64/pnpm',
},
linux: {
x64: {
glibc: '@pacquet/linux-x64/pacquet',
musl: '@pacquet/linux-x64-musl/pacquet',
glibc: '@pnpm/exe.linux-x64/pnpm',
musl: '@pnpm/exe.linux-x64-musl/pnpm',
},
arm64: {
glibc: '@pacquet/linux-arm64/pacquet',
musl: '@pacquet/linux-arm64-musl/pacquet',
glibc: '@pnpm/exe.linux-arm64/pnpm',
musl: '@pnpm/exe.linux-arm64-musl/pnpm',
},
},
}
const BIN_NAMES = ['pnpm', 'pn', 'pnpx', 'pnx']
setup()
function setup () {
const candidates = getBinCandidates()
if (candidates.length === 0) {
fail(`pacquet does not ship a prebuilt binary for ${platform}-${arch}.`)
fail(`pnpm does not ship a prebuilt binary for ${platform}-${arch}.`)
}
// Use whichever platform package the package manager installed: it already
@@ -56,35 +59,36 @@ function setup () {
try {
nativeBinary = require.resolve(target)
break
} catch {
// Not installed for this host; try the next candidate.
}
} catch {}
}
if (nativeBinary == null) {
const pkgName = candidates[0].split('/').slice(0, 2).join('/')
fail(
`The "${pkgName}" package is not installed, so pacquet has no native binary to run.\n` +
`The "${pkgName}" package is not installed, so pnpm has no native binary to run.\n` +
'If your package manager skipped optional dependencies or blocked build scripts, ' +
'enable them and reinstall.'
)
}
const binDir = path.join(ownDir, 'bin')
if (platform === 'win32') {
// The existing shim points at `bin/pacquet`, so that file must become the
// binary; the `.exe` twin and `bin` rewrite are for shims generated later.
placeBinary(nativeBinary, path.join(binDir, 'pacquet.exe'))
placeBinary(nativeBinary, path.join(binDir, 'pacquet'))
rewriteBin('bin/pacquet.exe')
const newBin = {}
for (const name of BIN_NAMES) {
// The existing shim points at the no-ext file, so it must become the
// binary; the `.exe` twin + bin rewrite are for shims generated later.
placeBinary(nativeBinary, path.join(ownDir, `${name}.exe`))
placeBinary(nativeBinary, path.join(ownDir, name))
newBin[name] = `${name}.exe`
}
rewriteBin(newBin)
} else {
placeBinary(nativeBinary, path.join(binDir, 'pacquet'), 0o755)
placeBinary(nativeBinary, path.join(ownDir, 'pnpm'), 0o755)
}
}
/**
* Atomically place `nativeBinary` at `destPath` (hard link, falling back to a
* copy across filesystems, via a temp file + rename). Exits the process on
* failure without the binary there is no working `pacquet`.
* failure without the binary there is no working `pnpm`.
*
* @param {string} nativeBinary Absolute path to the resolved native binary.
* @param {string} destPath Absolute path to create.
@@ -92,7 +96,7 @@ function setup () {
* source inode (the shared store blob under pnpm), so its mode must not change.
*/
function placeBinary (nativeBinary, destPath, mode) {
const tempPath = `${destPath}.pacquet-tmp`
const tempPath = `${destPath}.pnpm-tmp`
try {
fs.rmSync(tempPath, { force: true })
let linked = false
@@ -109,31 +113,24 @@ function placeBinary (nativeBinary, destPath, mode) {
} catch (err) {
try {
fs.rmSync(tempPath, { force: true })
} catch {
// Nothing to clean up.
}
fail(`Could not install the pacquet binary at ${destPath}: ${err.message}`)
} catch {}
fail(`Could not install the pnpm binary at ${destPath}: ${err.message}`)
}
}
function rewriteBin (binValue) {
function rewriteBin (binMap) {
const pkgJsonPath = path.join(ownDir, 'package.json')
// Write a fresh file and rename it over package.json rather than truncating in
// place: pnpm hard-links package.json from its content-addressable store, so an
// in-place write would mutate the shared store blob. Best-effort — it only
// helps shims generated later.
const tempPath = `${pkgJsonPath}.pacquet-tmp`
// Temp file + rename, not in-place: package.json is hard-linked from the store.
const tempPath = `${pkgJsonPath}.pnpm-tmp`
try {
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'))
pkg.bin = binValue
pkg.bin = binMap
fs.writeFileSync(tempPath, JSON.stringify(pkg, null, 2))
fs.renameSync(tempPath, pkgJsonPath)
} catch {
try {
fs.rmSync(tempPath, { force: true })
} catch {
// Nothing to clean up.
}
} catch {}
}
}

View File

@@ -1,22 +1,25 @@
{
"name": "pacquet",
"description": "The official pnpm rewrite in Rust (preview, not production-ready)",
"name": "pnpm",
"description": "Fast, disk space efficient package manager — the Rust port of pnpm (alpha, not production-ready)",
"keywords": [
"pnpm"
"pnpm",
"package manager",
"rust"
],
"author": {
"name": "Yagiz Nizipli",
"email": "yagiz@nizipli.com"
},
"license": "MIT",
"homepage": "https://github.com/pnpm/pnpm/tree/main/pacquet",
"bugs": "https://github.com/pnpm/pnpm/issues",
"repository": {
"type": "git",
"url": "https://github.com/pnpm/pnpm",
"directory": "pacquet/npm/pacquet"
"directory": "pacquet/npm/pnpm"
},
"bin": {
"pnpm": "pnpm",
"pn": "pn",
"pnpx": "pnpx",
"pnx": "pnx"
},
"bin": "bin/pacquet",
"type": "module",
"scripts": {
"preinstall": "node install.js"
@@ -25,7 +28,10 @@
"node": ">=18.*"
},
"files": [
"bin/pacquet",
"pnpm",
"pn",
"pnpx",
"pnx",
"install.js"
]
}

2
pacquet/npm/pnpm/pn Executable file
View File

@@ -0,0 +1,2 @@
#!/bin/sh
exec pnpm "$@"

4
pacquet/npm/pnpm/pnpm Normal file
View File

@@ -0,0 +1,4 @@
This is a placeholder. pnpm's native binary replaces this file during
installation (see ./install.js). If you are reading this, the install/build
script did not run — reinstall with build scripts enabled (e.g. allow-list
pnpm's build under pnpm or Bun, or drop --ignore-scripts).

2
pacquet/npm/pnpm/pnpx Executable file
View File

@@ -0,0 +1,2 @@
#!/bin/sh
exec pnpm dlx "$@"

2
pacquet/npm/pnpm/pnx Executable file
View File

@@ -0,0 +1,2 @@
#!/bin/sh
exec pnpm dlx "$@"

View File

@@ -0,0 +1,138 @@
// Adapted from Rome (https://github.com/rome/tools/blob/392d188a49/npm/rome/scripts/generate-packages.mjs).
//
// Generates the per-platform `@pnpm/exe.<target>` native packages and the
// `@pnpm/exe` wrapper (an equal-content copy of the committed `pnpm` wrapper).
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import * as fs from "node:fs";
// Cargo bin / archived binary name; the workflow uploads `pacquet-<codeTarget>`.
const BIN_NAME = "pacquet";
// Binary file name inside each native package; `installPnpm`'s
// `linkExePlatformBinary` hardcodes `pnpm`, so both must agree.
const NATIVE_BIN_FILE = "pnpm";
const EXE_WRAPPER_NAME = "@pnpm/exe";
const EXE_WRAPPER_DIR = "pnpm-exe";
// Files shared verbatim by both wrappers: the root-level bins + preinstall + README.
const WRAPPER_FILES = ["pnpm", "pn", "pnpx", "pnx", "install.js", "README.md"];
const PNPM_ROOT = resolve(fileURLToPath(import.meta.url), "../..");
const PACKAGES_ROOT = resolve(PNPM_ROOT, "..");
const REPO_ROOT = resolve(PACKAGES_ROOT, "../..");
const MANIFEST_PATH = resolve(PNPM_ROOT, "package.json");
const rootManifest = JSON.parse(
fs.readFileSync(MANIFEST_PATH, "utf-8")
);
function generateNativePackage(target) {
const packageName = nativePackageName(target);
const packageRoot = resolve(PACKAGES_ROOT, `${BIN_NAME}-${target.codeTarget}`);
// Remove the directory just in case it already exists (it's autogenerated
// so there shouldn't be anything important there anyway)
fs.rmSync(packageRoot, { recursive: true, force: true });
console.log(`Create directory ${packageRoot}`);
fs.mkdirSync(packageRoot);
const { version } = rootManifest;
// executableFiles keeps the binary at 0755 in the tarball; otherwise pack
// normalizes it to 0644 and the preinstall hard-link inherits a non-exec
// inode, so the relinked `pnpm` fails with EACCES.
const ext = target.platform === "win32" ? ".exe" : "";
const manifestData = {
name: packageName,
version,
os: [target.platform],
cpu: [target.arch],
repository: {
type: "git",
url: "https://github.com/pnpm/pnpm",
},
publishConfig: {
executableFiles: [`./${NATIVE_BIN_FILE}${ext}`],
},
};
if (target.libc) {
manifestData.libc = [target.libc];
}
const manifest = JSON.stringify(manifestData, null, 2);
const manifestPath = resolve(packageRoot, "package.json");
console.log(`Create manifest ${manifestPath}`);
fs.writeFileSync(manifestPath, manifest);
const binarySource = resolve(REPO_ROOT, `${BIN_NAME}-${target.codeTarget}${ext}`);
const binaryTarget = resolve(packageRoot, `${NATIVE_BIN_FILE}${ext}`);
console.log(`Copy binary ${binaryTarget}`);
fs.copyFileSync(binarySource, binaryTarget);
fs.chmodSync(binaryTarget, 0o755);
}
// Patch the committed `pnpm` manifest with the release version + the full set of
// `@pnpm/exe.<target>` optional dependencies, preserving its other fields.
function patchPnpmWrapperManifest() {
const nativePackages = TARGETS.map((target) => [
nativePackageName(target),
rootManifest.version,
]);
rootManifest["version"] = rootManifest.version;
rootManifest["optionalDependencies"] = Object.fromEntries(nativePackages);
console.log(`Update manifest ${MANIFEST_PATH}`);
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(rootManifest));
}
// Generate the `@pnpm/exe` wrapper as a copy of the `pnpm` wrapper with only the
// name + repo.directory changed. Must run after `patchPnpmWrapperManifest` so the
// copied manifest already carries the version and optionalDependencies.
function generateExeWrapper() {
const exeRoot = resolve(PACKAGES_ROOT, EXE_WRAPPER_DIR);
fs.rmSync(exeRoot, { recursive: true, force: true });
fs.mkdirSync(exeRoot, { recursive: true });
// Copy instead of symlinking so the tarball is self-contained for publish.
for (const file of WRAPPER_FILES) {
fs.copyFileSync(resolve(PNPM_ROOT, file), resolve(exeRoot, file));
fs.chmodSync(resolve(exeRoot, file), fs.statSync(resolve(PNPM_ROOT, file)).mode);
}
const baseManifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, "utf-8"));
const exeManifest = {
...baseManifest,
name: EXE_WRAPPER_NAME,
repository: { ...baseManifest.repository, directory: `pacquet/npm/${EXE_WRAPPER_DIR}` },
};
console.log(`Create wrapper ${exeRoot}`);
fs.writeFileSync(resolve(exeRoot, "package.json"), JSON.stringify(exeManifest));
}
// `@pnpm/exe.<target>` is the convention `installPnpm`'s relinker already
// anticipates (`exePlatformPkgDirNameNext`), so a switch to v12 needs no special
// case.
function nativePackageName(target) {
return `@pnpm/exe.${target.packageTarget}`;
}
const TARGETS = [
{ platform: "win32", arch: "x64", codeTarget: "win32-x64", packageTarget: "win32-x64" },
{ platform: "win32", arch: "arm64", codeTarget: "win32-arm64", packageTarget: "win32-arm64" },
{ platform: "darwin", arch: "x64", codeTarget: "darwin-x64", packageTarget: "darwin-x64" },
{ platform: "darwin", arch: "arm64", codeTarget: "darwin-arm64", packageTarget: "darwin-arm64" },
{ platform: "linux", arch: "x64", libc: "glibc", codeTarget: "linux-x64", packageTarget: "linux-x64" },
{ platform: "linux", arch: "arm64", libc: "glibc", codeTarget: "linux-arm64", packageTarget: "linux-arm64" },
{ platform: "linux", arch: "x64", libc: "musl", codeTarget: "linux-x64-musl", packageTarget: "linux-x64-musl" },
{ platform: "linux", arch: "arm64", libc: "musl", codeTarget: "linux-arm64-musl", packageTarget: "linux-arm64-musl" },
];
for (const target of TARGETS) {
generateNativePackage(target);
}
patchPnpmWrapperManifest();
generateExeWrapper();

View File

@@ -1,5 +1,5 @@
export { selfUpdate } from './self-updater/index.js'
export { exePlatformPkgDirName, exePlatformPkgDirNameNext, installPnpm, installPnpmToStore, linkExePlatformBinary } from './self-updater/installPnpm.js'
export { exePlatformPkgDirName, exePlatformPkgDirNameNext, installPnpm, installPnpmToStore, linkExePlatformBinary, pnpmPackageNameToInstall } from './self-updater/installPnpm.js'
export { verifyPnpmEngineIdentity, type VerifyPnpmEngineIdentityOptions } from './self-updater/verifyPnpmEngineIdentity.js'
export { setup } from './setup/index.js'
export { withCmd } from './with/index.js'

View File

@@ -23,13 +23,27 @@ import type { EnvLockfile, LockfileObject, PackageSnapshot } from '@pnpm/lockfil
import { registerProject, type StoreController } from '@pnpm/store.controller'
import type { DepPath, ProjectId, ProjectRootDir, Registries } from '@pnpm/types'
import { familySync } from 'detect-libc'
import semver from 'semver'
import { symlinkDir } from 'symlink-dir'
import { verifyPnpmEngineIdentity, type VerifyPnpmEngineIdentityOptions } from './verifyPnpmEngineIdentity.js'
// @pnpm/exe has platform-specific binaries, so its GVS hash must
// include ENGINE_NAME for correct per-platform resolution.
const PNPM_ALLOW_BUILDS: Record<string, boolean> = { '@pnpm/exe': true }
// Both pnpm wrappers (`@pnpm/exe`, unscoped `pnpm`) carry platform-specific
// binaries; marking them buildable puts ENGINE_NAME in the GVS hash so each
// platform resolves to its own entry instead of colliding.
const PNPM_ALLOW_BUILDS: Record<string, boolean> = { '@pnpm/exe': true, 'pnpm': true }
/**
* Package name to install for a switch to `pnpmVersion`. From v12 the unscoped
* `pnpm` is itself the native exe (equal content to `@pnpm/exe`), so v12+ always
* converges on `pnpm`, even from a SEA `@pnpm/exe` build. Earlier majors keep
* `pnpm` (JS) and `@pnpm/exe` (SEA) distinct, preserving the running identity.
*/
export function pnpmPackageNameToInstall (pnpmVersion: string): string {
const parsed = semver.parse(pnpmVersion, { loose: true })
if (parsed != null && parsed.major >= 12) return 'pnpm'
return getCurrentPackageName()
}
export interface InstallPnpmResult {
binDir: string
@@ -51,15 +65,15 @@ export interface InstallPnpmOptions extends GlobalAddOptions {
* Creates an entry in globalPkgDir that is visible to `pnpm ls -g`.
*/
export async function installPnpm (pnpmVersion: string, opts: InstallPnpmOptions): Promise<InstallPnpmResult> {
const currentPkgName = getCurrentPackageName()
const pkgName = pnpmPackageNameToInstall(pnpmVersion)
const wantedLockfile = opts.envLockfile
? buildLockfileFromEnvLockfile(opts.envLockfile, currentPkgName, pnpmVersion)
? buildLockfileFromEnvLockfile(opts.envLockfile, pkgName, pnpmVersion)
: undefined
const result = await installPnpmToGlobalDir(
opts,
currentPkgName,
pkgName,
pnpmVersion,
wantedLockfile
)
@@ -87,13 +101,13 @@ export async function installPnpmToStore (
packageManager?: { name: string, version: string }
} & VerifyPnpmEngineIdentityOptions
): Promise<{ binDir: string }> {
const currentPkgName = getCurrentPackageName()
const wantedLockfile = buildLockfileFromEnvLockfile(opts.envLockfile, currentPkgName, pnpmVersion)
const pkgName = pnpmPackageNameToInstall(pnpmVersion)
const wantedLockfile = buildLockfileFromEnvLockfile(opts.envLockfile, pkgName, pnpmVersion)
const globalVirtualStoreDir = path.join(opts.storeDir, 'links')
// Compute the GVS hash for the pnpm package to find its path
const pnpmGvsPath = findPnpmGvsPath(wantedLockfile, currentPkgName, globalVirtualStoreDir, PNPM_ALLOW_BUILDS)
const pnpmPkgDir = path.join(pnpmGvsPath, 'node_modules', currentPkgName)
const pnpmGvsPath = findPnpmGvsPath(wantedLockfile, pkgName, globalVirtualStoreDir, PNPM_ALLOW_BUILDS)
const pnpmPkgDir = path.join(pnpmGvsPath, 'node_modules', pkgName)
const binDir = path.join(pnpmGvsPath, 'bin')
// Check if already installed in the GVS
@@ -125,7 +139,7 @@ export async function installPnpmToStore (
})
// Now the GVS should be populated — create bins alongside the GVS entry
linkExePlatformBinary(pnpmGvsPath)
linkExePlatformBinary(pnpmGvsPath, pkgName)
await linkBins(path.join(pnpmGvsPath, 'node_modules'), binDir, { warn: noop })
return { binDir }
@@ -219,7 +233,7 @@ async function installPnpmToGlobalDir (
await installFromResolution(installDir, opts, [`${pkgName}@${version}`])
}
linkExePlatformBinary(installDir)
linkExePlatformBinary(installDir, pkgName)
await linkBins(path.join(installDir, 'node_modules'), binDir, { warn: noop })
// Create hash symlink for the global packages system
@@ -360,10 +374,14 @@ function legacyOsSegment (platform: NodeJS.Platform, libcFamily: string | null):
}
/**
* Future scope-local directory name of the `@pnpm/exe` platform package, under
* the `exe.<platform>-<arch>[-musl]` scheme that matches the workspace
* directory layout. `linkExePlatformBinary` checks this as a fallback so a
* future rename of the published packages works without touching this logic.
* Scope-local directory name of the platform package under the
* `exe.<platform>-<arch>[-musl]` scheme, i.e. the published package
* `@pnpm/exe.<platform>-<arch>[-musl]`. pnpm v12 (the Rust port) ships its
* native binaries under exactly this convention, so `linkExePlatformBinary`
* relinks a v12 install with no v12-specific logic. `@pnpm/exe` (the
* TypeScript SEA build) is expected to adopt the same scheme in a future
* release, which is why the legacy `@pnpm/<os>-<arch>` name is still checked
* first as a fallback.
*/
export function exePlatformPkgDirNameNext (
platform: NodeJS.Platform,
@@ -375,60 +393,71 @@ export function exePlatformPkgDirNameNext (
return `exe.${platform}-${normalizedArch}${libcSuffix}`
}
// @pnpm/exe bundles Node.js via optional platform-specific packages
// (e.g. @pnpm/macos-arm64, @pnpm/linuxstatic-x64; or, after a future rename,
// @pnpm/exe.darwin-arm64, @pnpm/exe.linux-x64-musl). Its postinstall script
// links the correct binary into the @pnpm/exe package dir. Since scripts are
// disabled during install (to support systems without Node.js), we replicate
// that linking here, checking both naming schemes so self-update works across
// the rename.
export function linkExePlatformBinary (installDir: string): void {
const exePkgDir = path.join(installDir, 'node_modules', '@pnpm', 'exe')
if (!fs.existsSync(exePkgDir)) return
// In pnpm's symlinked node_modules layout, the platform package is not hoisted
// to the top-level node_modules. It's a dependency of @pnpm/exe and lives as a
// sibling in the virtual store. Resolve through the @pnpm/exe symlink to find it.
const exeRealDir = fs.realpathSync(exePkgDir)
// The wrapper's preinstall links the platform binary into the wrapper dir, but
// scripts are disabled during pnpm's own installs, so replicate it here — trying
// the legacy and the newer `exe.<target>` platform-package names.
export function linkExePlatformBinary (installDir: string, wrapperPkgName: string = '@pnpm/exe'): void {
const wrapperDir = path.join(installDir, 'node_modules', ...wrapperPkgName.split('/'))
if (!fs.existsSync(wrapperDir)) return
const platform = process.platform
const arch = process.arch
const libcFamily = familySync()
const executable = platform === 'win32' ? 'pnpm.exe' : 'pnpm'
// Resolve the platform binary by its explicit adjacent path in the real
// virtual store, not via Node resolution: a `node_modules` walk could be
// shadowed by a higher-precedence `@pnpm/<dirName>` in a repo-controlled
// `store-dir`. `@pnpm/exe`'s parent is already `@pnpm`; `pnpm` descends into it.
const wrapperRealDir = fs.realpathSync(wrapperDir)
const scopeDir = wrapperPkgName.startsWith('@')
? path.dirname(wrapperRealDir)
: path.join(path.dirname(wrapperRealDir), '@pnpm')
const candidateDirNames = [
exePlatformPkgDirName(platform, arch, libcFamily),
exePlatformPkgDirNameNext(platform, arch, libcFamily),
]
let src: string | undefined
for (const dirName of candidateDirNames) {
const candidate = path.join(path.dirname(exeRealDir), dirName, executable)
const candidate = path.join(scopeDir, dirName, executable)
if (fs.existsSync(candidate)) {
src = candidate
break
}
}
if (src == null) return
const dest = path.join(exePkgDir, executable)
const dest = path.join(wrapperDir, executable)
forceLink(src, dest)
if (platform === 'win32') {
// Aliases (pn / pnpx / pnx) need to be .exe hardlinks of the SEA binary,
// Aliases (pn / pnpx / pnx) need to be .exe hardlinks of the native binary,
// not the .cmd wrappers we ship in the tarball. cmd-shim's Bash shim for
// a .cmd target wraps it in `exec cmd /C ...`, and MSYS2 / Git Bash
// mangles `/C` into a Windows path — cmd.exe then falls into interactive
// mode and prints its banner instead of running the alias. .exe sources
// sidestep cmd-shim's wrapper. The SEA binary detects which name it was
// sidestep cmd-shim's wrapper. The native binary detects which name it was
// launched as via process.execPath and prepends `dlx` for pnpx / pnx.
// See https://github.com/pnpm/pnpm/issues/11486.
for (const alias of ['pn', 'pnpx', 'pnx']) {
forceLink(src, path.join(exePkgDir, `${alias}.exe`))
forceLink(src, path.join(wrapperDir, `${alias}.exe`))
}
const exePkgJsonPath = path.join(exePkgDir, 'package.json')
const exePkg = JSON.parse(fs.readFileSync(exePkgJsonPath, 'utf8'))
exePkg.bin.pnpm = 'pnpm.exe'
exePkg.bin.pn = 'pn.exe'
exePkg.bin.pnpx = 'pnpx.exe'
exePkg.bin.pnx = 'pnx.exe'
fs.writeFileSync(exePkgJsonPath, JSON.stringify(exePkg, null, 2))
const wrapperPkgJsonPath = path.join(wrapperDir, 'package.json')
const wrapperPkg = JSON.parse(fs.readFileSync(wrapperPkgJsonPath, 'utf8'))
wrapperPkg.bin.pnpm = 'pnpm.exe'
wrapperPkg.bin.pn = 'pn.exe'
wrapperPkg.bin.pnpx = 'pnpx.exe'
wrapperPkg.bin.pnx = 'pnx.exe'
// Temp file + rename, not in-place: package.json is hard-linked from the
// content-addressable store, so writing in place would mutate the shared blob.
const tempPkgJsonPath = `${wrapperPkgJsonPath}.pnpm-tmp`
try {
fs.writeFileSync(tempPkgJsonPath, JSON.stringify(wrapperPkg, null, 2))
fs.renameSync(tempPkgJsonPath, wrapperPkgJsonPath)
} catch (err: unknown) {
try {
fs.rmSync(tempPkgJsonPath, { force: true })
} catch {}
throw err
}
}
}

View File

@@ -24,7 +24,7 @@ jest.unstable_mockModule('@pnpm/cli.meta', () => {
},
}
})
const { selfUpdate, installPnpm, linkExePlatformBinary, exePlatformPkgDirName, exePlatformPkgDirNameNext } = await import('@pnpm/engine.pm.commands')
const { selfUpdate, installPnpm, linkExePlatformBinary, exePlatformPkgDirName, exePlatformPkgDirNameNext, pnpmPackageNameToInstall } = await import('@pnpm/engine.pm.commands')
beforeEach(async () => {
await setupMockAgent()
@@ -1122,6 +1122,32 @@ describe('linkExePlatformBinary', () => {
expect(result).toBe(fakeBinaryContent)
})
test('links the pnpm v12 wrapper from its @pnpm/exe.<target> dependency', () => {
const dir = tempDir(false)
// pnpm v12 (the Rust port) is published as the unscoped `pnpm` wrapper that
// depends on `@pnpm/exe.<platform>-<arch>[-musl]` — the `exe.<...>` scheme.
const nextPkgName = exePlatformPkgDirNameNext(platform, arch, libcFamily)
const wrapperDir = path.join(dir, 'node_modules', 'pnpm')
const platformDir = path.join(dir, 'node_modules', '@pnpm', nextPkgName)
fs.mkdirSync(wrapperDir, { recursive: true })
fs.mkdirSync(platformDir, { recursive: true })
fs.writeFileSync(path.join(wrapperDir, executable), 'This is a placeholder.')
fs.writeFileSync(path.join(wrapperDir, 'package.json'), JSON.stringify({
bin: { pnpm: 'pnpm', pn: 'pn', pnpx: 'pnpx', pnx: 'pnx' },
}))
const fakeBinaryContent = '#!/bin/sh\necho "fake pnpm v12 binary"'
fs.writeFileSync(path.join(platformDir, executable), fakeBinaryContent)
linkExePlatformBinary(dir, 'pnpm')
const result = fs.readFileSync(path.join(wrapperDir, executable), 'utf8')
expect(result).toBe(fakeBinaryContent)
})
// Regression coverage for https://github.com/pnpm/pnpm/issues/11486 — the
// `pn` / `pnpx` / `pnx` aliases were broken in MSYS2 / Git Bash on Windows.
// Root cause: linkExePlatformBinary pointed those bin entries at .cmd files,
@@ -1171,6 +1197,21 @@ describe('linkExePlatformBinary', () => {
})
})
describe('pnpmPackageNameToInstall', () => {
test('installs the unscoped `pnpm` package from v12 onward', () => {
expect(pnpmPackageNameToInstall('12.0.0-alpha.0')).toBe('pnpm')
expect(pnpmPackageNameToInstall('12.3.4')).toBe('pnpm')
expect(pnpmPackageNameToInstall('13.0.0')).toBe('pnpm')
})
test('keeps the running package identity before v12', () => {
// getCurrentPackageName() is `pnpm` in the (non-SEA) test runtime, so this
// asserts v11 and earlier are not forced onto a different package.
expect(pnpmPackageNameToInstall('11.9.0')).toBe('pnpm')
expect(pnpmPackageNameToInstall('9.1.0')).toBe('pnpm')
})
})
describe('exePlatformPkgDirName', () => {
test('uses linuxstatic- prefix for linux + musl libc family', () => {
expect(exePlatformPkgDirName('linux', 'x64', 'musl')).toBe('linuxstatic-x64')

View File

@@ -1,4 +1,4 @@
// Adapted from pacquet/npm/pacquet/scripts/generate-packages.mjs, which was
// Adapted from pacquet/npm/pnpm/scripts/generate-packages.mjs, which was
// itself copied from Rome (https://github.com/rome/tools/blob/392d188a49/npm/rome/scripts/generate-packages.mjs).
import { resolve } from "node:path";