Files
pnpm/__utils__/scripts/src/copy-artifacts.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

122 lines
4.3 KiB
TypeScript

import fs from 'fs'
import * as execa from 'execa'
import path from 'path'
import makeEmptyDir from 'make-empty-dir'
import stream from 'stream'
import * as tar from 'tar'
import { glob } from 'tinyglobby'
const repoRoot = path.join(import.meta.dirname, '../../..')
const dest = path.join(repoRoot, 'dist')
const artifactsDir = path.join(repoRoot, 'pnpm/artifacts')
const pnpmDistDir = path.join(repoRoot, 'pnpm/dist')
;(async () => {
await makeEmptyDir(dest)
if (!fs.existsSync(path.join(artifactsDir, 'linux-x64/pnpm'))) {
execa.sync('pnpm', ['--filter=@pnpm/exe', 'run', 'prepublishOnly'], {
cwd: repoRoot,
stdio: 'inherit',
})
}
await createArtifactTarball('linux-x64', 'pnpm')
await createArtifactTarball('linuxstatic-x64', 'pnpm')
await createArtifactTarball('linuxstatic-arm64', 'pnpm')
await createArtifactTarball('linux-arm64', 'pnpm')
await createArtifactTarball('macos-x64', 'pnpm')
await createArtifactTarball('macos-arm64', 'pnpm')
await createArtifactTarball('win-x64', 'pnpm.exe')
await createArtifactTarball('win-arm64', 'pnpm.exe')
await createSourceMapsArchive()
})().catch((err) => {
console.error(err)
process.exitCode = 1
})
async function createArtifactTarball (target: string, binaryName: string): Promise<void> {
try {
const artifactDir = path.join(artifactsDir, target)
const binaryPath = path.join(artifactDir, binaryName)
if (!fs.existsSync(binaryPath)) {
console.log(`Warning: ${binaryPath} not found, skipping ${target}`)
return
}
// Copy dist/ from the pnpm build output and strip non-target reflink packages.
// Source maps are removed from this copy — they are archived separately via
// createSourceMapsArchive(), which reads from the original pnpmDistDir.
const distDest = path.join(artifactDir, 'dist')
fs.rmSync(distDest, { recursive: true, force: true })
fs.cpSync(pnpmDistDir, distDest, { recursive: true })
stripReflinkPackages(distDest, getReflinkKeepPackages(target))
for (const mapFile of await glob('**/*.map', { cwd: distDest })) {
fs.rmSync(path.join(distDest, mapFile))
}
const isWindows = target.startsWith('win-')
const archiveName = isWindows ? `pnpm-${target}.zip` : `pnpm-${target}.tar.gz`
if (isWindows) {
// Create zip for Windows
const zipPath = path.join(dest, archiveName)
execa.sync('zip', ['-r', zipPath, binaryName, 'dist'], {
cwd: artifactDir,
stdio: 'inherit',
})
} else {
// Create tar.gz for Unix
await stream.promises.pipeline(
tar.create({ gzip: true, cwd: artifactDir }, [binaryName, 'dist']),
fs.createWriteStream(path.join(dest, archiveName))
)
}
console.log(`Created ${archiveName}`)
} catch (err) {
console.error(`Failed to create artifact for target "${target}":`, err)
throw err
}
}
async function createSourceMapsArchive () {
// The tar.create function can accept a filter callback function, but this
// approach ends up adding empty directories to the archive. Using tinyglobby
// instead.
const mapFiles = await glob('**/*.map', { cwd: pnpmDistDir })
await stream.promises.pipeline(
tar.create({ gzip: true, cwd: pnpmDistDir }, mapFiles),
fs.createWriteStream(path.join(dest, 'source-maps.tgz'))
)
}
// Reflink platform package names needed for a build target.
// Target format: 'linux-x64', 'linuxstatic-arm64', 'macos-arm64', 'win-x64'.
function getReflinkKeepPackages (target: string): string[] {
if (target.startsWith('macos-')) {
return [`@reflink/reflink-darwin-${target.slice('macos-'.length)}`]
}
if (target.startsWith('win-')) {
return [`@reflink/reflink-win32-${target.slice('win-'.length)}-msvc`]
}
if (target.startsWith('linux')) {
const arch = target.includes('arm64') ? 'arm64' : 'x64'
return [
`@reflink/reflink-linux-${arch}-gnu`,
`@reflink/reflink-linux-${arch}-musl`,
]
}
return []
}
function stripReflinkPackages (distDir: string, keepPackages: string[]): void {
const reflinkDir = path.join(distDir, 'node_modules', '@reflink')
if (!fs.existsSync(reflinkDir)) return
for (const entry of fs.readdirSync(reflinkDir)) {
if (entry === 'reflink') continue // keep the main package
if (!keepPackages.includes(`@reflink/${entry}`)) {
fs.rmSync(path.join(reflinkDir, entry), { recursive: true })
}
}
}