Files
pnpm/pnpm/bundle-deps.ts
Zoltan Kochan f54347e415 feat: replace pkg with Node.js SEA for standalone executables (#10661)
* feat: switch from pkg to Node.js SEA for creating standalone executables

Replace @yao-pkg/pkg with Node.js native Single Executable Applications
(--build-sea, Node.js 25.5+). The SEA binary embeds only pnpm.cjs (CJS
bootstrap), while pnpm.mjs and all assets live in a dist/ directory
shipped alongside the binary in platform-specific tarballs.

* refactor: move dist/ from platform packages to @pnpm/exe

The dist/ directory (pnpm.mjs, worker.js, templates, etc.) is identical
across all platforms, so ship it once in @pnpm/exe instead of duplicating
it in each platform package. Platform packages now only contain the
binary. The self-updater installs @pnpm/exe (not the platform package)
so it gets both dist/ and the binary via optionalDependencies.

* refactor: externalize @reflink/reflink in esbuild bundle

Make @reflink/reflink external in both the main and worker esbuild
bundles so the require() calls resolve at runtime from dist/node_modules
instead of being inlined. Add @reflink/reflink as a production dependency
of both pnpm (bundled into dist/node_modules by bundle-deps.ts) and
@pnpm/exe (installed by npm alongside the binary).

For GitHub release tarballs, only the target platform's reflink package
is kept. For @pnpm/exe npm publishing, all reflink platform packages
are stripped from dist/ since npm installs the right one automatically.

* chore: update cspell list

* test: update system-node-version tests for SEA detection

Mock @pnpm/cli-meta's detectIfCurrentPkgIsExecutable instead of
setting process.pkg, which is no longer used for SEA detection.

* test: improve cli-meta test coverage for SEA migration

Add tests for detectIfCurrentPkgIsExecutable() (non-SEA path) and
isExecutedByCorepack() which were previously untested. The SEA=true
path of detectIfCurrentPkgIsExecutable() cannot be unit tested since
node:sea is unavailable in an ESM test environment.

* refactor: move GitHub tarball assembly to copy-artifacts.ts

build-artifacts.ts (prepublishOnly of @pnpm/exe) now only builds the
SEA executables and prepares the exe npm dist/. The per-target dist/
assembly for GitHub release tarballs moves to copy-artifacts.ts, which
is the natural owner of that concern.

Other changes:
- Extract getReflinkKeepPackages/stripReflinkPackages to reflink-utils.ts
  with tests using node:test
- Move --force from top-level pnpm install in release.yml to the pnpm
  deploy in bundle-deps.ts, where it is actually needed to install all
  @reflink/reflink-* platform packages into dist/node_modules
- Change @pnpm/exe prepublishOnly to run pnpm's full prepublishOnly
  (compile + bundle-deps) so dist/node_modules is populated before
  build-artifacts.ts and copy-artifacts.ts read from pnpm/dist

* fix: copy dist/ alongside binary when running pnpm setup for SEA

When the pnpm CLI is a Node.js SEA binary, it requires a dist/ directory
adjacent to the executable at runtime (containing pnpm.mjs and bundled
node_modules). The copyCli function in plugin-commands-setup now copies
dist/ from alongside the current binary into the tools directory so that
the installed pnpm works correctly after `pnpm setup`.


* fix: avoid argument list too long when creating Windows zip archives


* fix: propagate errors in copy-artifacts script

Previously errors in createArtifactTarball were swallowed, causing the
script to exit 0 even when artifact creation failed. Now errors are
re-thrown with a descriptive message, and the top-level IIFE has a
.catch() handler that sets a non-zero exit code.


* refactor: remove reflink-utils.ts from @pnpm/exe

The stripReflinkPackages call in build-artifacts.ts stripped all platform
packages while keeping @reflink/reflink. Instead, just remove the entire
@reflink directory from dist/ — @pnpm/exe already declares @reflink/reflink
as a runtime dependency, so npm installs it (along with the right platform
package via optionalDependencies) automatically.

This eliminates reflink-utils.ts, its tests, and the code duplication with
copy-artifacts.ts.
2026-02-22 12:45:50 +01:00

113 lines
3.9 KiB
TypeScript

import fs from 'node:fs'
import path from 'node:path'
import { execSync } from 'node:child_process'
// Background
// ----------
//
// The published pnpm package contains a bundled node_modules directory at
// dist/node_modules.
//
// .
// ├── dist
// │ ├── node_modules
// │ │ ├── node-gyp
// │ │ ├── v8-compile-cache
// │ │ └── ...
// │ └── pnpm.mjs
// ├── ...
// └── package.json
//
// This is used to include certain dependencies like node-gyp out of the box
// when installing pnpm.
//
// Note that most pnpm dependencies are baked into the large pnpm.mjs file by
// esbuild. This script handles other dependencies the pnpm bundle config
// declares as "external" and resolved at runtime — node-gyp, v8-compile-cache,
// and @reflink/reflink (all platform variants, installed via --force).
//
// Strategy
// --------
//
// To create dist/node_modules, we'll run a pnpm deploy and move the results
// over into the dist dir.
//
// .
// ├── temp-deploy
// │ ├── ...
// │ ├── README.md
// │ ├── node_modules ──────────────┐
// │ ├── package.json │
// │ ├── pnpm-lock.yaml │
// │ └── pnpm-workspace.yaml │
// └── package.json │
// │
// . │
// ├── dist │
// │ ├── node_modules <────────────┘
// │ └── pnpm.mjs
// ├── ...
// └── package.json
//
// The pnpm deploy command should reuse workspace settings, patches, and the
// pnpm-lock.yaml. This is important to ensure settings such as pnpm.overrides
// are carried over since they might be overrides to fix CVE vulnerabilities.
const WORKSPACE_DIR = path.join(import.meta.dirname, '..')
const DEPLOY_DIR = path.join(import.meta.dirname, 'temp-deploy')
const NODE_MODULES_TEMP_DIR = path.join(DEPLOY_DIR, 'node_modules')
const NODE_MODULES_DEST_DIR = path.join(import.meta.dirname, 'dist/node_modules')
/**
* Remove files like CHANGELOG.md, README.md, etc from node_modules to keep the
* final distribution smaller.
*/
function cleanupNodeModules (dir: string) {
const nmPrune = path.join(import.meta.dirname, 'node_modules/.bin/nm-prune')
execSync(`${nmPrune} --force`, { cwd: dir, stdio: 'inherit' })
const pnpmStateFiles = [
// Since we're installing with --node-linker=hoisted, this directory only
// contains a small .lock.yaml file that's not needed in the final
// distribution.
'node_modules/.pnpm',
'node_modules/.modules.yaml',
'node_modules/.pnpm-workspace-state-v1.json',
]
for (const file of pnpmStateFiles) {
fs.rmSync(path.join(dir, file), { recursive: true })
}
}
function createDistNodeModules () {
// Remove the target directory to ensure the results of this script are as
// deterministic as possible and don't carry over old state.
fs.rmSync(DEPLOY_DIR, { recursive: true, force: true })
const pnpmDeploy = [
'pnpm',
'--config.inject-workspace-packages=true',
'--config.node-linker=hoisted',
'--ignore-scripts',
// --force installs all optional dependencies regardless of platform, so that
// all @reflink/reflink-* platform packages end up in dist/node_modules.
'--force',
'--filter=pnpm',
'--prod',
'deploy',
DEPLOY_DIR
].join(' ')
execSync(pnpmDeploy, { cwd: WORKSPACE_DIR, stdio: 'inherit' })
cleanupNodeModules(DEPLOY_DIR)
fs.rmSync(NODE_MODULES_DEST_DIR, { recursive: true, force: true })
fs.mkdirSync(path.dirname(NODE_MODULES_DEST_DIR), { recursive: true })
fs.renameSync(NODE_MODULES_TEMP_DIR, NODE_MODULES_DEST_DIR)
fs.rmSync(DEPLOY_DIR, { recursive: true })
}
createDistNodeModules()