feat: add support for package maps (#12430)

Generate a Node.js package map at `node_modules/.package-map.json` on every
isolated or hoisted install, including under the global virtual store, so that
third-party tooling can start experimenting with package maps. The file is
serialized compactly.

Two settings control how the map is consumed:

- `node-experimental-package-map` (default: off): inject
  `--experimental-package-map` into `NODE_OPTIONS` for the Node.js scripts pnpm
  runs — dependency lifecycle scripts, `pnpm exec`, and `pnpm run` (including
  recursive runs).
- `node-package-map-type` (`standard` | `loose`): choose between a strict map
  and one that tolerates hoisting-like access.

Covered by both the pnpm CLI and the pacquet (Rust) implementation.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
Maël Nison
2026-06-18 11:51:50 +02:00
committed by GitHub
parent c2414e8236
commit 0474a9c3b1
53 changed files with 3373 additions and 51 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/installing.commands": patch
"pnpm": patch
---
Fixed `pnpm import` for Yarn v2 lockfiles when `js-yaml` v4 is installed.

View File

@@ -0,0 +1,12 @@
---
"@pnpm/config.reader": minor
"@pnpm/exec.commands": minor
"@pnpm/exec.lifecycle": minor
"@pnpm/installing.commands": minor
"@pnpm/installing.deps-installer": minor
"@pnpm/installing.deps-restorer": minor
"@pnpm/lockfile.to-pnp": minor
"pnpm": minor
---
Added support for generating Node.js package maps at `node_modules/.package-map.json` during isolated and hoisted installs. Added the `node-experimental-package-map` setting to inject the generated map into pnpm-managed Node.js script environments, and the `node-package-map-type` setting to choose between `standard` and `loose` package maps.

1
Cargo.lock generated
View File

@@ -4067,6 +4067,7 @@ dependencies = [
"pretty_assertions",
"rayon",
"reflink-copy",
"serde",
"serde-saphyr",
"serde_json",
"sha2",

View File

@@ -265,6 +265,8 @@ export interface Config extends OptionsFromRootManifest {
globalPkgDir: string
lockfile?: boolean
dedupeInjectedDeps?: boolean
nodeExperimentalPackageMap?: boolean
nodePackageMapType?: 'standard' | 'loose'
nodeOptions?: string
pmOnFail?: 'download' | 'error' | 'warn' | 'ignore'
runtime?: boolean

View File

@@ -43,6 +43,8 @@ export const pnpmConfigFileKeys = [
'minimum-release-age-ignore-missing-time',
'minimum-release-age-strict',
'network-concurrency',
'node-experimental-package-map',
'node-package-map-type',
'noproxy',
'npm-path',
'npmrc-auth-file',

View File

@@ -143,6 +143,8 @@ export async function getConfig (opts: {
'dedupe-injected-deps': true,
'disallow-workspace-cycles': false,
'enable-modules-dir': true,
'node-experimental-package-map': false,
'node-package-map-type': 'standard',
'enable-pre-post-scripts': true,
'exclude-links-from-lockfile': false,
'extend-node-path': true,

View File

@@ -75,6 +75,8 @@ export const pnpmTypes = {
'minimum-release-age-strict': Boolean,
'modules-dir': String,
'network-concurrency': Number,
'node-experimental-package-map': Boolean,
'node-package-map-type': ['standard', 'loose'],
'node-linker': ['pnp', 'isolated', 'hoisted'],
noproxy: String,
'npm-path': String,

View File

@@ -6,7 +6,7 @@ import { type Config, type ConfigContext, getWorkspaceConcurrency, types } from
import { lifecycleLogger, type LifecycleMessage } from '@pnpm/core-loggers'
import type { CheckDepsStatusOptions } from '@pnpm/deps.status'
import { PnpmError } from '@pnpm/error'
import { makeNodeRequireOption } from '@pnpm/exec.lifecycle'
import { makeNodePackageMapOption, makeNodeRequireOption } from '@pnpm/exec.lifecycle'
import { logger } from '@pnpm/logger'
import { prependDirsToPath } from '@pnpm/shell.path'
import type { Project, ProjectRootDir, ProjectRootDirRealPath, ProjectsGraph } from '@pnpm/types'
@@ -45,6 +45,8 @@ export function rcOptionsTypes (): Record<string, unknown> {
'unsafe-perm',
'workspace-concurrency',
'reporter-hide-prefix',
'node-experimental-package-map',
'node-package-map-type',
], types),
'shell-mode': Boolean,
'resume-from': String,
@@ -154,6 +156,7 @@ export type ExecOpts = Required<Pick<ConfigContext, 'selectedProjectsGraph'>> &
| 'lockfileDir'
| 'modulesDir'
| 'nodeOptions'
| 'nodeExperimentalPackageMap'
| 'pnpmHomeDir'
| 'recursive'
| 'reporter'
@@ -220,6 +223,8 @@ export async function handler (
const result = createEmptyRecursiveSummary(chunks)
const existsPnp = existsInDir.bind(null, '.pnp.cjs')
const workspacePnpPath = opts.workspaceDir && existsPnp(opts.workspaceDir)
const existsPackageMap = existsInDir.bind(null, path.join(opts.modulesDir ?? 'node_modules', '.package-map.json'))
const workspacePackageMapPath = opts.nodeExperimentalPackageMap && opts.workspaceDir && existsPackageMap(opts.workspaceDir)
let exitCode = 0
const prependPaths = [
@@ -235,15 +240,21 @@ export async function handler (
const startTime = process.hrtime()
try {
const pnpPath = workspacePnpPath ?? existsPnp(prefix)
const extraEnv = {
const packageMapPath = workspacePackageMapPath || (opts.nodeExperimentalPackageMap && existsPackageMap(prefix))
const extraEnv: Record<string, string | undefined> = {
...opts.extraEnv,
...(pnpPath ? makeNodeRequireOption(pnpPath) : {}),
...(opts.nodeOptions ? { NODE_OPTIONS: opts.nodeOptions } : {}),
}
if (pnpPath) {
Object.assign(extraEnv, makeNodeRequireOption(pnpPath, extraEnv))
}
if (packageMapPath) {
Object.assign(extraEnv, makeNodePackageMapOption(packageMapPath, extraEnv))
}
const env = makeEnv({
extraEnv: {
...extraEnv,
PNPM_PACKAGE_NAME: opts.selectedProjectsGraph[prefix]?.package.manifest.name,
...(opts.nodeOptions ? { NODE_OPTIONS: opts.nodeOptions } : {}),
},
prependPaths,
userAgent: opts.userAgent,

View File

@@ -11,6 +11,7 @@ import { type Config, type ConfigContext, getWorkspaceConcurrency, types as allT
import type { CheckDepsStatusOptions } from '@pnpm/deps.status'
import { PnpmError } from '@pnpm/error'
import {
makeNodePackageMapOption,
makeNodeRequireOption,
runLifecycleHook,
type RunLifecycleHookOptions,
@@ -88,6 +89,8 @@ export function rcOptionsTypes (): Record<string, unknown> {
return {
...pick([
'npm-path',
'node-experimental-package-map',
'node-package-map-type',
], allTypes),
}
}
@@ -168,6 +171,7 @@ export type RunOpts =
| 'extraBinPaths'
| 'extraEnv'
| 'nodeOptions'
| 'nodeExperimentalPackageMap'
| 'pnpmHomeDir'
| 'reporter'
| 'scriptShell'
@@ -289,7 +293,17 @@ so you may run "pnpm -w run ${scriptName}"`,
if (pnpPath) {
lifecycleOpts.extraEnv = {
...lifecycleOpts.extraEnv,
...makeNodeRequireOption(pnpPath),
...makeNodeRequireOption(pnpPath, lifecycleOpts.extraEnv),
}
}
const existsPackageMap = existsInDir.bind(null, path.join(opts.modulesDir ?? 'node_modules', '.package-map.json'))
const packageMapPath = opts.nodeExperimentalPackageMap
? (opts.workspaceDir && existsPackageMap(opts.workspaceDir)) ?? existsPackageMap(dir)
: undefined
if (packageMapPath) {
lifecycleOpts.extraEnv = {
...lifecycleOpts.extraEnv,
...makeNodePackageMapOption(packageMapPath, lifecycleOpts.extraEnv),
}
}
const limitRun = pLimit(concurrency)

View File

@@ -6,6 +6,7 @@ import { throwOnCommandFail } from '@pnpm/cli.utils'
import { type Config, type ConfigContext, getWorkspaceConcurrency } from '@pnpm/config.reader'
import { PnpmError } from '@pnpm/error'
import {
makeNodePackageMapOption,
makeNodeRequireOption,
type RunLifecycleHookOptions,
} from '@pnpm/exec.lifecycle'
@@ -34,6 +35,8 @@ export type RecursiveRunOpts = Pick<Config,
| 'stream'
| 'syncInjectedDepsAfterScripts'
| 'workspaceDir'
| 'nodeExperimentalPackageMap'
| 'modulesDir'
> & Pick<ConfigContext, 'rootProjectManifest'> & Required<Pick<ConfigContext, 'allProjects' | 'selectedProjectsGraph'> & Pick<Config, 'workspaceDir' | 'dir'>> &
Partial<Pick<Config, 'extraBinPaths' | 'extraEnv' | 'bail' | 'reporter' | 'reverse' | 'sort' | 'workspaceConcurrency'>> &
{
@@ -74,6 +77,8 @@ export async function runRecursive (
: 'pipe'
const existsPnp = existsInDir.bind(null, '.pnp.cjs')
const workspacePnpPath = opts.workspaceDir && existsPnp(opts.workspaceDir)
const existsPackageMap = existsInDir.bind(null, path.join(opts.modulesDir ?? 'node_modules', '.package-map.json'))
const workspacePackageMapPath = opts.nodeExperimentalPackageMap && opts.workspaceDir && existsPackageMap(opts.workspaceDir)
const requiredScripts = opts.requiredScripts ?? []
if (requiredScripts.includes(scriptName)) {
@@ -135,7 +140,14 @@ export async function runRecursive (
if (pnpPath) {
lifecycleOpts.extraEnv = {
...lifecycleOpts.extraEnv,
...makeNodeRequireOption(pnpPath),
...makeNodeRequireOption(pnpPath, lifecycleOpts.extraEnv),
}
}
const packageMapPath = workspacePackageMapPath || (opts.nodeExperimentalPackageMap && existsPackageMap(prefix))
if (packageMapPath) {
lifecycleOpts.extraEnv = {
...lifecycleOpts.extraEnv,
...makeNodePackageMapOption(packageMapPath, lifecycleOpts.extraEnv),
}
}

View File

@@ -1,3 +1,6 @@
import fs from 'node:fs'
import path from 'node:path'
import { beforeEach, expect, jest, test } from '@jest/globals'
import { prepareEmpty } from '@pnpm/prepare'
@@ -50,6 +53,26 @@ test('exec should set the NODE_OPTIONS env var', async () => {
}))
})
test('exec should merge node options with PnP require option', async () => {
prepareEmpty()
const pnpPath = path.join(process.cwd(), '.pnp.cjs')
fs.writeFileSync(pnpPath, '')
await exec.handler({
...DEFAULT_OPTS,
dir: process.cwd(),
selectedProjectsGraph: {},
workspaceDir: undefined,
nodeOptions: '--max-old-space-size=4096',
}, ['eslint'])
expect(execa).toHaveBeenCalledWith('eslint', [], expect.objectContaining({
env: expect.objectContaining({
NODE_OPTIONS: `--max-old-space-size=4096 --require=${pnpPath}`,
}),
}))
})
test('exec should specify the command', async () => {
prepareEmpty()

View File

@@ -3,12 +3,38 @@ import { safeReadPackageJsonFromDir } from '@pnpm/pkg-manifest.reader'
import { runLifecycleHook, type RunLifecycleHookOptions } from './runLifecycleHook.js'
import { runLifecycleHooksConcurrently, type RunLifecycleHooksConcurrentlyOptions } from './runLifecycleHooksConcurrently.js'
export function makeNodeRequireOption (modulePath: string): { NODE_OPTIONS: string } {
let { NODE_OPTIONS } = process.env
NODE_OPTIONS = `${NODE_OPTIONS ?? ''} --require=${modulePath}`.trim()
export function makeNodeRequireOption (modulePath: string, env?: Record<string, string | undefined>): { NODE_OPTIONS: string } {
let { NODE_OPTIONS } = env ?? process.env
NODE_OPTIONS = `${NODE_OPTIONS ?? process.env.NODE_OPTIONS ?? ''} --require=${quotePathIfNeeded(modulePath)}`.trim()
return { NODE_OPTIONS }
}
export function makeNodePackageMapOption (packageMapPath: string, env?: Record<string, string | undefined>): { NODE_OPTIONS: string } {
let { NODE_OPTIONS } = env ?? process.env
NODE_OPTIONS = `${removeNodePackageMapOption(NODE_OPTIONS ?? process.env.NODE_OPTIONS ?? '')} --experimental-package-map=${quotePathIfNeeded(packageMapPath)}`.trim()
return { NODE_OPTIONS }
}
// Node's NODE_OPTIONS tokenizer splits on whitespace, treats `'` and `"` as
// quote delimiters, and uses `\` as an escape character. A bare path with a
// space, quote, or backslash (e.g. any Windows path) would therefore be
// mis-parsed. Wrap such paths in double quotes and escape `\` and `"` so the
// tokenizer reconstructs the literal path.
function quotePathIfNeeded (path: string): string {
if (!/[\s"'\\]/.test(path)) return path
return `"${path.replace(/(["\\])/g, '\\$1')}"`
}
function removeNodePackageMapOption (nodeOptions: string): string {
// The quoted-value patterns span backslash escapes (`\"`), matching the
// escaping `makeNodePackageMapOption` emits, so an existing flag whose path
// contains a quote is still stripped in full.
return nodeOptions
.replace(/(?:^|\s)--experimental-package-map=(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\S+)/g, '')
.replace(/(?:^|\s)--experimental-package-map\s+(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\S+)/g, '')
.trim()
}
export {
runLifecycleHook,
type RunLifecycleHookOptions,

View File

@@ -4,7 +4,13 @@ import path from 'node:path'
import { expect, test } from '@jest/globals'
import { PnpmError } from '@pnpm/error'
import { runLifecycleHook, runLifecycleHooksConcurrently, runPostinstallHooks } from '@pnpm/exec.lifecycle'
import {
makeNodePackageMapOption,
makeNodeRequireOption,
runLifecycleHook,
runLifecycleHooksConcurrently,
runPostinstallHooks,
} from '@pnpm/exec.lifecycle'
import { tempDir } from '@pnpm/prepare'
import type { StoreController } from '@pnpm/store.controller-types'
import { fixtures } from '@pnpm/test-fixtures'
@@ -17,6 +23,96 @@ const skipOnWindows = isWindows() ? test.skip : test
const f = fixtures(path.join(import.meta.dirname, 'fixtures'))
const rootModulesDir = path.join(import.meta.dirname, '..', 'node_modules')
test('makeNodeRequireOption() preserves existing NODE_OPTIONS', () => {
expect(makeNodeRequireOption('/project/.pnp.cjs', {
NODE_OPTIONS: '--max-old-space-size=4096',
})).toStrictEqual({
NODE_OPTIONS: '--max-old-space-size=4096 --require=/project/.pnp.cjs',
})
})
test('makeNodeRequireOption() quotes and escapes module paths with backslashes or whitespace', () => {
expect(makeNodeRequireOption('C:\\project\\.pnp.cjs', {
NODE_OPTIONS: '',
})).toStrictEqual({
NODE_OPTIONS: '--require="C:\\\\project\\\\.pnp.cjs"',
})
expect(makeNodeRequireOption('/project with space/.pnp.cjs', {
NODE_OPTIONS: '',
})).toStrictEqual({
NODE_OPTIONS: '--require="/project with space/.pnp.cjs"',
})
})
test('makeNodeRequireOption() falls back to NODE_OPTIONS from process.env', () => {
const nodeOptions = process.env.NODE_OPTIONS
process.env.NODE_OPTIONS = '--trace-warnings'
try {
expect(makeNodeRequireOption('/project/.pnp.cjs', {})).toStrictEqual({
NODE_OPTIONS: '--trace-warnings --require=/project/.pnp.cjs',
})
} finally {
if (nodeOptions == null) {
delete process.env.NODE_OPTIONS
} else {
process.env.NODE_OPTIONS = nodeOptions
}
}
})
test('makeNodePackageMapOption() appends to NODE_OPTIONS', () => {
expect(makeNodePackageMapOption('/project/node_modules/.package-map.json', {
NODE_OPTIONS: '--max-old-space-size=4096',
})).toStrictEqual({
NODE_OPTIONS: '--max-old-space-size=4096 --experimental-package-map=/project/node_modules/.package-map.json',
})
})
test('makeNodePackageMapOption() quotes paths with whitespace', () => {
expect(makeNodePackageMapOption('/project with space/node_modules/.package-map.json', {
NODE_OPTIONS: '',
})).toStrictEqual({
NODE_OPTIONS: '--experimental-package-map="/project with space/node_modules/.package-map.json"',
})
})
test('makeNodePackageMapOption() quotes and escapes paths with backslashes or quotes', () => {
expect(makeNodePackageMapOption('C:\\project\\node_modules\\.package-map.json', {
NODE_OPTIONS: '',
})).toStrictEqual({
NODE_OPTIONS: '--experimental-package-map="C:\\\\project\\\\node_modules\\\\.package-map.json"',
})
expect(makeNodePackageMapOption('/quo"te/.package-map.json', {
NODE_OPTIONS: '',
})).toStrictEqual({
NODE_OPTIONS: '--experimental-package-map="/quo\\"te/.package-map.json"',
})
})
test('makeNodePackageMapOption() replaces existing package-map option', () => {
const nodeOptions = [
'--experimental-package-map=/old/node_modules/.package-map.json',
'--max-old-space-size=4096',
'--experimental-package-map="/old project/.package-map.json"',
'--experimental-package-map /other/.package-map.json',
'--inspect',
].join(' ')
expect(makeNodePackageMapOption('/new/node_modules/.package-map.json', {
NODE_OPTIONS: nodeOptions,
})).toStrictEqual({
NODE_OPTIONS: '--max-old-space-size=4096 --inspect --experimental-package-map=/new/node_modules/.package-map.json',
})
})
test('makeNodePackageMapOption() replaces an existing flag whose path contains an escaped quote', () => {
expect(makeNodePackageMapOption('/new/.package-map.json', {
NODE_OPTIONS: '--experimental-package-map="/quo\\"te/old.json" --inspect',
})).toStrictEqual({
NODE_OPTIONS: '--inspect --experimental-package-map=/new/.package-map.json',
})
})
test('runLifecycleHook()', async () => {
const pkgRoot = f.find('simple')
await using server = await createTestIpcServer(path.join(pkgRoot, 'test.sock'))

View File

@@ -85,7 +85,6 @@
"@pnpm/workspace.workspace-manifest-writer": "workspace:*",
"@yarnpkg/core": "catalog:",
"@yarnpkg/lockfile": "catalog:",
"@yarnpkg/parsers": "catalog:",
"@zkochan/rimraf": "catalog:",
"@zkochan/table": "catalog:",
"chalk": "catalog:",
@@ -93,6 +92,7 @@
"detect-libc": "catalog:",
"get-npm-tarball-url": "catalog:",
"is-subdir": "catalog:",
"js-yaml": "catalog:",
"load-json-file": "catalog:",
"normalize-path": "catalog:",
"p-filter": "catalog:",
@@ -119,6 +119,7 @@
"@pnpm/testing.registry-mock": "workspace:*",
"@pnpm/worker": "workspace:*",
"@pnpm/workspace.projects-filter": "workspace:*",
"@types/js-yaml": "catalog:",
"@types/normalize-path": "catalog:",
"@types/proxyquire": "catalog:",
"@types/ramda": "catalog:",

View File

@@ -54,6 +54,8 @@ export function rcOptionsTypes (): Record<string, unknown> {
'lockfile',
'modules-dir',
'network-concurrency',
'node-experimental-package-map',
'node-package-map-type',
'node-linker',
'noproxy',
'npm-path',

View File

@@ -18,8 +18,8 @@ import { findWorkspaceProjects } from '@pnpm/workspace.projects-reader'
import { sequenceGraph } from '@pnpm/workspace.projects-sorter'
import * as structUtils from '@yarnpkg/core/structUtils'
import { type LockFileObject, parse as parseYarnLockfile } from '@yarnpkg/lockfile'
import { parseSyml } from '@yarnpkg/parsers'
import { rimraf } from '@zkochan/rimraf'
import yaml from 'js-yaml'
import { loadJsonFile } from 'load-json-file'
import { map as mapValues } from 'ramda'
import { renderHelp } from 'render-help'
@@ -64,6 +64,8 @@ interface YarnPackageLock {
[name: string]: YarnLockPackage
}
type YarnLockYaml = YarnPackageLock & { __metadata?: unknown }
const YarnLockType = {
yarn: 'yarn',
yarn2: 'yarn2',
@@ -215,7 +217,7 @@ async function readYarnLockFile (dir: string): Promise<LockFileObject> {
}
function parseYarn2Lock (lockFileContents: string): YarnLock2Struct {
const parseYarnLock = parseSyml(lockFileContents)
const parseYarnLock = parseYarn2Yaml(lockFileContents)
delete parseYarnLock.__metadata
const dependencies: YarnPackageLock = {}
@@ -238,6 +240,18 @@ function parseYarn2Lock (lockFileContents: string): YarnLock2Struct {
}
}
function parseYarn2Yaml (lockFileContents: string): YarnLockYaml {
const parseYarnLock = yaml.load(lockFileContents, {
schema: yaml.FAILSAFE_SCHEMA,
json: true,
})
if (parseYarnLock == null) return {}
if (typeof parseYarnLock !== 'object' || Array.isArray(parseYarnLock)) {
throw new PnpmError('YARN_LOCKFILE_PARSE_FAILED', `Expected an indexed object, got ${Array.isArray(parseYarnLock) ? 'an array' : `a ${typeof parseYarnLock}`} instead. Does your file follow YAML's rules?`)
}
return parseYarnLock as YarnLockYaml
}
async function readNpmLockfile (dir: string): Promise<LockedPackage> {
try {
return await loadJsonFile<LockedPackage>(path.join(dir, 'package-lock.json'))

View File

@@ -48,6 +48,8 @@ export function rcOptionsTypes (): Record<string, unknown> {
'merge-git-branch-lockfiles-branch-pattern',
'modules-dir',
'network-concurrency',
'node-experimental-package-map',
'node-package-map-type',
'node-linker',
'noproxy',
'package-import-method',

View File

@@ -54,6 +54,8 @@ export function rcOptionsTypes (): Record<string, unknown> {
'lockfile-dir',
'lockfile-only',
'lockfile',
'node-experimental-package-map',
'node-package-map-type',
'node-linker',
'package-import-method',
'pnpmfile',

View File

@@ -48,6 +48,8 @@ export function rcOptionsTypes (): Record<string, unknown> {
'lockfile',
'lockfile-include-tarball-url',
'network-concurrency',
'node-experimental-package-map',
'node-package-map-type',
'noproxy',
'npm-path',
'offline',

View File

@@ -91,6 +91,8 @@ export interface StrictInstallOptions {
engineStrict: boolean
allowBuilds?: Record<string, boolean | string>
nodeLinker: 'isolated' | 'hoisted' | 'pnp'
nodeExperimentalPackageMap: boolean
nodePackageMapType: 'standard' | 'loose'
nodeVersion?: string
packageExtensions: Record<string, PackageExtension>
ignoredOptionalDependencies: string[]
@@ -327,6 +329,8 @@ const defaults = (opts: InstallOptions): StrictInstallOptions => {
updateChecksums: false,
nodeVersion: opts.nodeVersion,
nodeLinker: 'isolated',
nodeExperimentalPackageMap: false,
nodePackageMapType: 'standard',
overrides: {},
ownLifecycleHooksStdio: 'inherit',
ignoreCompatibilityDb: false,

View File

@@ -24,6 +24,7 @@ import { hashObjectNullableWithPrefix } from '@pnpm/crypto.object-hasher'
import * as dp from '@pnpm/deps.path'
import { PnpmError } from '@pnpm/error'
import {
makeNodePackageMapOption,
makeNodeRequireOption,
runLifecycleHook,
runLifecycleHooksConcurrently,
@@ -62,7 +63,7 @@ import {
createOverridesMapFromParsed,
getOutdatedLockfileSetting,
} from '@pnpm/lockfile.settings-checker'
import { writePnpFile } from '@pnpm/lockfile.to-pnp'
import { PACKAGE_MAP_FILENAME, writePackageMap, writePnpFile } from '@pnpm/lockfile.to-pnp'
import { allProjectsAreUpToDate, satisfiesPackageManifest } from '@pnpm/lockfile.verification'
import { globalInfo, logger, streamParser } from '@pnpm/logger'
import { groupPatchedDependencies, type PatchGroupRecord } from '@pnpm/patching.config'
@@ -1684,6 +1685,7 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => {
}
let stats: InstallationResultStats | undefined
let ignoredBuilds: IgnoredBuilds | undefined
const shouldWritePackageMap = opts.enableModulesDir !== false && opts.nodeLinker === 'isolated' && !opts.virtualStoreOnly
if (!opts.lockfileOnly && !isInstallationOnlyForLockfileCheck && opts.enableModulesDir) {
const result = await linkPackages(
projects,
@@ -1727,6 +1729,24 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => {
}
)
stats = result.stats
if (shouldWritePackageMap) {
// Omit the importer self-mapping when a project has no name (see the
// deps-restorer write): a non-package-name key like `.` would be invalid.
const importerNames = Object.fromEntries(
projects.map(({ manifest, id }) => [id, manifest.name])
)
await writePackageMap(result.currentLockfile, {
importerNames,
lockfileDir: ctx.lockfileDir,
locationByDepPath: Object.fromEntries(
Object.values(dependenciesGraph).map((node) => [node.depPath, node.dir])
),
packageMapType: opts.nodePackageMapType,
rootModulesDir: ctx.rootModulesDir,
virtualStoreDir: ctx.virtualStoreDir,
virtualStoreDirMaxLength: ctx.virtualStoreDirMaxLength,
})
}
if (opts.enablePnp) {
const importerNames = Object.fromEntries(
projects.map(({ manifest, id }) => [id, manifest.name ?? id])
@@ -1760,7 +1780,13 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => {
if (opts.enablePnp) {
extraEnv = {
...extraEnv,
...makeNodeRequireOption(path.join(opts.lockfileDir, '.pnp.cjs')),
...makeNodeRequireOption(path.join(opts.lockfileDir, '.pnp.cjs'), extraEnv),
}
}
if (opts.nodeExperimentalPackageMap && shouldWritePackageMap) {
extraEnv = {
...extraEnv,
...makeNodePackageMapOption(path.join(ctx.rootModulesDir, PACKAGE_MAP_FILENAME), extraEnv),
}
}
// Dependency lifecycle scripts must not run on an unverified lockfile.
@@ -1917,7 +1943,13 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => {
if (opts.enablePnp) {
opts.scriptsOpts.extraEnv = {
...opts.scriptsOpts.extraEnv,
...makeNodeRequireOption(path.join(opts.lockfileDir, '.pnp.cjs')),
...makeNodeRequireOption(path.join(opts.lockfileDir, '.pnp.cjs'), opts.scriptsOpts.extraEnv),
}
}
if (opts.nodeExperimentalPackageMap && shouldWritePackageMap) {
opts.scriptsOpts.extraEnv = {
...opts.scriptsOpts.extraEnv,
...makeNodePackageMapOption(path.join(ctx.rootModulesDir, PACKAGE_MAP_FILENAME), opts.scriptsOpts.extraEnv),
}
}
const projectsToBeBuilt = projectsWithTargetDirs.filter(({ mutation }) => mutation === 'install') as ProjectToBeInstalled[]

View File

@@ -92,6 +92,7 @@ test('dedupe direct dependencies', async () => {
expect(Array.from(fs.readdirSync('node_modules').sort())).toEqual([
'.bin',
'.modules.yaml',
'.package-map.json',
'.pnpm',
'@pnpm.e2e',
'foo',

View File

@@ -28,6 +28,7 @@ const f = fixtures(import.meta.dirname)
const IS_WINDOWS = isWindows()
const testOnNonWindows = IS_WINDOWS ? test.skip : test
const testOnNode27Plus = semver.major(process.versions.node) >= 27 ? test : test.skip
test('spec not specified in package.json.dependencies', async () => {
const project = prepareEmpty()
@@ -54,6 +55,308 @@ test.skip('ignoring some files in the dependency', async () => {
expect(fs.existsSync(path.resolve('node_modules', 'is-positive', 'readme.md'))).toBeFalsy()
})
test('writes a package map for Node.js package-map resolution', async () => {
const project = prepareEmpty()
const { updatedManifest: manifest } = await addDependenciesToPackage({}, ['@pnpm.e2e/pkg-with-1-dep@100.0.0'], testDefaults({ fastUnpack: false }))
const packageMap = JSON.parse(fs.readFileSync(path.resolve('node_modules/.package-map.json'), 'utf8'))
const rootDependencyId = packageMap.packages['.'].dependencies['@pnpm.e2e/pkg-with-1-dep']
expect(rootDependencyId).toBe('@pnpm.e2e/pkg-with-1-dep@100.0.0')
expect(packageMap.packages[rootDependencyId]).toMatchObject({
url: './.pnpm/@pnpm.e2e+pkg-with-1-dep@100.0.0/node_modules/@pnpm.e2e/pkg-with-1-dep',
dependencies: {
'@pnpm.e2e/pkg-with-1-dep': '@pnpm.e2e/pkg-with-1-dep@100.0.0',
'@pnpm.e2e/dep-of-pkg-with-1-dep': expect.stringMatching(/^@pnpm\.e2e\/dep-of-pkg-with-1-dep@100\./),
},
})
project.has('.package-map.json')
fs.rmSync(path.resolve('node_modules/.package-map.json'))
await install(manifest, testDefaults({ fastUnpack: false, frozenLockfile: true }))
project.has('.package-map.json')
})
test('writes a package map that resolves against the global virtual store layout', async () => {
prepareEmpty()
const globalVirtualStoreDir = path.resolve('links')
const { updatedManifest: manifest } = await addDependenciesToPackage({}, ['@pnpm.e2e/pkg-with-1-dep@100.0.0'], testDefaults({
fastUnpack: false,
enableGlobalVirtualStore: true,
virtualStoreDir: globalVirtualStoreDir,
}))
const expectMapResolvesOnDisk = (): void => {
const packageMap = JSON.parse(fs.readFileSync(path.resolve('node_modules/.package-map.json'), 'utf8'))
const rootDependencyId = packageMap.packages['.'].dependencies['@pnpm.e2e/pkg-with-1-dep']
// The global virtual store nests by name/version/content-hash, unlike the
// flat local `.pnpm` layout, so the url must come from the real location.
expect(packageMap.packages[rootDependencyId].url).toContain('links/@pnpm.e2e/pkg-with-1-dep/100.0.0/')
for (const entry of Object.values<{ url: string }>(packageMap.packages)) {
expect(fs.existsSync(path.resolve('node_modules', entry.url))).toBe(true)
}
}
expectMapResolvesOnDisk()
rimrafSync('node_modules')
await install(manifest, testDefaults({
fastUnpack: false,
enableGlobalVirtualStore: true,
virtualStoreDir: globalVirtualStoreDir,
frozenLockfile: true,
}))
expectMapResolvesOnDisk()
})
test('writes a loose package map for Node.js package-map resolution', async () => {
prepareEmpty()
await addDependenciesToPackage({}, [
'@pnpm.e2e/foo@100.0.0',
'@pnpm.e2e/pkg-with-1-dep@100.0.0',
], testDefaults({
fastUnpack: false,
nodePackageMapType: 'loose',
}))
const packageMap = JSON.parse(fs.readFileSync(path.resolve('node_modules/.package-map.json'), 'utf8'))
const rootDependencyId = packageMap.packages['.'].dependencies['@pnpm.e2e/pkg-with-1-dep']
expect(packageMap.packages[rootDependencyId].dependencies).toMatchObject({
'@pnpm.e2e/dep-of-pkg-with-1-dep': expect.stringMatching(/^@pnpm\.e2e\/dep-of-pkg-with-1-dep@100\./),
'@pnpm.e2e/foo': '@pnpm.e2e/foo@100.0.0',
})
})
test('writes a package map for hoisted node linker from the real layout', async () => {
const project = prepareEmpty()
await addDependenciesToPackage({}, ['@pnpm.e2e/pkg-with-1-dep@100.0.0'], testDefaults({
fastUnpack: false,
nodeLinker: 'hoisted',
}))
const packageMap = JSON.parse(fs.readFileSync(path.resolve('node_modules/.package-map.json'), 'utf8'))
const rootDependencyId = packageMap.packages['.'].dependencies['@pnpm.e2e/pkg-with-1-dep']
expect(rootDependencyId).toBe('@pnpm.e2e/pkg-with-1-dep')
expect(packageMap.packages[rootDependencyId]).toMatchObject({
url: './@pnpm.e2e/pkg-with-1-dep',
dependencies: {
'@pnpm.e2e/pkg-with-1-dep': '@pnpm.e2e/pkg-with-1-dep',
'@pnpm.e2e/dep-of-pkg-with-1-dep': expect.stringMatching(/^@pnpm\.e2e\/dep-of-pkg-with-1-dep/),
},
})
project.has('.package-map.json')
})
test('writes a loose package map for hoisted node linker', async () => {
prepareEmpty()
await addDependenciesToPackage({}, [
'@pnpm.e2e/foo@100.0.0',
'@pnpm.e2e/pkg-with-1-dep@100.0.0',
], testDefaults({
fastUnpack: false,
nodeLinker: 'hoisted',
nodePackageMapType: 'loose',
}))
const packageMap = JSON.parse(fs.readFileSync(path.resolve('node_modules/.package-map.json'), 'utf8'))
const rootDependencyId = packageMap.packages['.'].dependencies['@pnpm.e2e/pkg-with-1-dep']
expect(packageMap.packages[rootDependencyId].dependencies).toMatchObject({
'@pnpm.e2e/dep-of-pkg-with-1-dep': expect.stringMatching(/^@pnpm\.e2e\/dep-of-pkg-with-1-dep/),
'@pnpm.e2e/foo': '@pnpm.e2e/foo',
})
})
test('does not inject a package map into lifecycle scripts when virtualStoreOnly skips map materialization', async () => {
prepareEmpty()
fs.mkdirSync('pkg')
const marker = path.resolve('package-map-env-ok')
fs.writeFileSync('pkg/package.json', JSON.stringify({
name: 'pkg',
version: '1.0.0',
scripts: {
install: makeAssertNoPackageMapNodeOptionsScript(marker),
},
}), 'utf8')
await addDependenciesToPackage({}, ['file:./pkg'], testDefaults({
allowBuilds: { 'pkg@file:pkg': true },
nodeExperimentalPackageMap: true,
virtualStoreOnly: true,
}))
expect(fs.existsSync(path.resolve('node_modules/.package-map.json'))).toBeFalsy()
expect(fs.existsSync(marker)).toBeTruthy()
})
test('does not write or inject a package map when modules directory creation is disabled', async () => {
prepareEmpty()
const marker = path.resolve('package-map-env-ok')
const manifest: ProjectManifest = {
name: 'project',
version: '1.0.0',
scripts: {
install: makeAssertNoPackageMapNodeOptionsScript(marker),
},
dependencies: {
'is-positive': '1.0.0',
},
}
await install(manifest, testDefaults({ ignoreScripts: true }))
rimrafSync('node_modules')
await install(manifest, testDefaults({
enableModulesDir: false,
frozenLockfile: true,
nodeExperimentalPackageMap: true,
}))
expect(fs.existsSync(path.resolve('node_modules'))).toBeFalsy()
expect(fs.existsSync(marker)).toBeTruthy()
})
testOnNode27Plus('package map can resolve package dependencies at runtime with Node.js', async () => {
prepareEmpty()
await addDependenciesToPackage({}, ['@pnpm.e2e/pkg-with-1-dep@100.0.0'], testDefaults({ fastUnpack: false }))
const packageMap = JSON.parse(fs.readFileSync(path.resolve('node_modules/.package-map.json'), 'utf8'))
const rootDependencyId = packageMap.packages['.'].dependencies['@pnpm.e2e/pkg-with-1-dep']
const rootDependencyDir = path.resolve('node_modules', packageMap.packages[rootDependencyId].url)
const dependencyId = packageMap.packages[rootDependencyId].dependencies['@pnpm.e2e/dep-of-pkg-with-1-dep']
rimrafSync(path.join(rootDependencyDir, 'node_modules/@pnpm.e2e/dep-of-pkg-with-1-dep'))
fs.writeFileSync(
path.join(rootDependencyDir, 'package-map-smoke.cjs'),
`
const dep = require('@pnpm.e2e/dep-of-pkg-with-1-dep')()
if (dep.name !== '@pnpm.e2e/dep-of-pkg-with-1-dep') {
throw new Error(\`unexpected dependency name: \${dep.name}\`)
}
if (!dep.version.startsWith('100.')) {
throw new Error(\`unexpected dependency version: \${dep.version}\`)
}
`,
'utf8'
)
await execa('node', [
`--experimental-package-map=${path.resolve('node_modules/.package-map.json')}`,
path.join(rootDependencyDir, 'package-map-smoke.cjs'),
])
expect(dependencyId).toMatch(/^@pnpm\.e2e\/dep-of-pkg-with-1-dep@100\./)
})
testOnNode27Plus('hoisted package map can resolve package dependencies at runtime with Node.js', async () => {
prepareEmpty()
await addDependenciesToPackage({}, ['@pnpm.e2e/pkg-with-1-dep@100.0.0'], testDefaults({
fastUnpack: false,
nodeLinker: 'hoisted',
}))
const packageMap = JSON.parse(fs.readFileSync(path.resolve('node_modules/.package-map.json'), 'utf8'))
const rootDependencyId = packageMap.packages['.'].dependencies['@pnpm.e2e/pkg-with-1-dep']
const rootDependencyDir = path.resolve('node_modules', packageMap.packages[rootDependencyId].url)
const dependencyId = packageMap.packages[rootDependencyId].dependencies['@pnpm.e2e/dep-of-pkg-with-1-dep']
fs.writeFileSync(
path.join(rootDependencyDir, 'package-map-smoke.cjs'),
`
const dep = require('@pnpm.e2e/dep-of-pkg-with-1-dep')()
if (dep.name !== '@pnpm.e2e/dep-of-pkg-with-1-dep') {
throw new Error(\`unexpected dependency name: \${dep.name}\`)
}
if (!dep.version.startsWith('100.')) {
throw new Error(\`unexpected dependency version: \${dep.version}\`)
}
`,
'utf8'
)
await execa('node', [
`--experimental-package-map=${path.resolve('node_modules/.package-map.json')}`,
path.join(rootDependencyDir, 'package-map-smoke.cjs'),
])
expect(dependencyId).toMatch(/^@pnpm\.e2e\/dep-of-pkg-with-1-dep/)
})
testOnNode27Plus('hoisted package map blocks undeclared hoisted dependencies at runtime with Node.js', async () => {
prepareEmpty()
await addDependenciesToPackage({}, [
'@pnpm.e2e/foo@100.0.0',
'@pnpm.e2e/pkg-with-1-dep@100.0.0',
], testDefaults({
fastUnpack: false,
nodeLinker: 'hoisted',
}))
const packageMap = JSON.parse(fs.readFileSync(path.resolve('node_modules/.package-map.json'), 'utf8'))
const rootDependencyId = packageMap.packages['.'].dependencies['@pnpm.e2e/pkg-with-1-dep']
const rootDependencyDir = path.resolve('node_modules', packageMap.packages[rootDependencyId].url)
expect(packageMap.packages[rootDependencyId].dependencies['@pnpm.e2e/foo']).toBeUndefined()
const smokeFile = path.join(rootDependencyDir, 'package-map-undeclared-smoke.cjs')
fs.writeFileSync(
smokeFile,
`
require('@pnpm.e2e/foo/package.json')
`,
'utf8'
)
await execa('node', [smokeFile])
await expect(execa('node', [
`--experimental-package-map=${path.resolve('node_modules/.package-map.json')}`,
smokeFile,
])).rejects.toMatchObject({
exitCode: 1,
stderr: expect.stringContaining('MODULE_NOT_FOUND'),
})
})
testOnNode27Plus('loose hoisted package map allows undeclared hoisted dependencies at runtime with Node.js', async () => {
prepareEmpty()
await addDependenciesToPackage({}, [
'@pnpm.e2e/foo@100.0.0',
'@pnpm.e2e/pkg-with-1-dep@100.0.0',
], testDefaults({
fastUnpack: false,
nodeLinker: 'hoisted',
nodePackageMapType: 'loose',
}))
const packageMap = JSON.parse(fs.readFileSync(path.resolve('node_modules/.package-map.json'), 'utf8'))
const rootDependencyId = packageMap.packages['.'].dependencies['@pnpm.e2e/pkg-with-1-dep']
const rootDependencyDir = path.resolve('node_modules', packageMap.packages[rootDependencyId].url)
expect(packageMap.packages[rootDependencyId].dependencies['@pnpm.e2e/foo']).toBe('@pnpm.e2e/foo')
const smokeFile = path.join(rootDependencyDir, 'package-map-loose-smoke.cjs')
fs.writeFileSync(
smokeFile,
`
const foo = require('@pnpm.e2e/foo/package.json')
if (foo.name !== '@pnpm.e2e/foo') {
throw new Error(\`unexpected package name: \${foo.name}\`)
}
`,
'utf8'
)
await execa('node', [
`--experimental-package-map=${path.resolve('node_modules/.package-map.json')}`,
smokeFile,
])
})
test('no dependencies (lodash)', async () => {
const project = prepareEmpty()
const reporter = jest.fn()
@@ -1143,7 +1446,7 @@ test('installing with no symlinks with PnP', async () => {
})
)
expect([...fs.readdirSync(path.resolve('node_modules'))]).toStrictEqual(['.bin', '.modules.yaml', '.pnpm'])
expect([...fs.readdirSync(path.resolve('node_modules')).sort()]).toStrictEqual(['.bin', '.modules.yaml', '.package-map.json', '.pnpm'])
expect([...fs.readdirSync(path.resolve('node_modules/.pnpm/rimraf@2.7.1/node_modules'))]).toStrictEqual(['rimraf'])
expect(project.readCurrentLockfile()).toBeTruthy()
@@ -1250,3 +1553,16 @@ test('install should not hang on circular peer dependencies', async () => {
// cspell:disable-next-line
await addDependenciesToPackage({}, ['@medusajs/medusa-js@6.1.7'], testDefaults())
})
function makeAssertNoPackageMapNodeOptionsScript (marker: string): string {
const scriptPath = path.resolve('assert-no-package-map-node-options.cjs')
fs.writeFileSync(scriptPath, `
const fs = require('node:fs')
if ((process.env.NODE_OPTIONS || '').includes('--experimental-package-map')) {
throw new Error('unexpected package map NODE_OPTIONS')
}
fs.writeFileSync(process.argv[2], 'ok')
`, 'utf8')
return `node ${JSON.stringify(scriptPath)} ${JSON.stringify(marker)}`
}

View File

@@ -166,7 +166,7 @@ test('a lockfile created even when there are no deps in package.json', async ()
await install({}, testDefaults())
expect(project.readLockfile()).toBeTruthy()
expect(fs.existsSync('node_modules')).toBeFalsy()
expect(fs.readdirSync('node_modules')).toStrictEqual(['.package-map.json'])
})
test('current lockfile removed when no deps in package.json', async () => {
@@ -192,7 +192,7 @@ test('current lockfile removed when no deps in package.json', async () => {
await install({}, testDefaults())
expect(project.readLockfile()).toBeTruthy()
expect(fs.existsSync('node_modules')).toBeFalsy()
expect(fs.readdirSync('node_modules')).toStrictEqual(['.package-map.json'])
})
test('lockfile is fixed when it does not match package.json', async () => {

View File

@@ -26,6 +26,7 @@ import { calcDepState, type DepsStateCache, findRuntimeNodeVersion } from '@pnpm
import * as dp from '@pnpm/deps.path'
import { PnpmError } from '@pnpm/error'
import {
makeNodePackageMapOption,
makeNodeRequireOption,
runLifecycleHooksConcurrently,
} from '@pnpm/exec.lifecycle'
@@ -51,7 +52,7 @@ import {
writeCurrentLockfile,
writeLockfiles,
} from '@pnpm/lockfile.fs'
import { writePnpFile } from '@pnpm/lockfile.to-pnp'
import { PACKAGE_MAP_FILENAME, writePackageMap, writePackageMapFromDependenciesGraph, writePnpFile } from '@pnpm/lockfile.to-pnp'
import {
nameVerFromPkgSnapshot,
} from '@pnpm/lockfile.utils'
@@ -179,6 +180,8 @@ export interface HeadlessOptions {
skipRuntimes?: boolean
enableModulesDir?: boolean
virtualStoreOnly?: boolean
nodeExperimentalPackageMap?: boolean
nodePackageMapType?: 'standard' | 'loose'
nodeLinker?: 'isolated' | 'hoisted' | 'pnp'
useGitBranchLockfile?: boolean
useLockfile?: boolean
@@ -539,6 +542,40 @@ export async function headlessInstall (opts: HeadlessOptions): Promise<Installat
}
}
const shouldWritePackageMap = opts.enableModulesDir !== false && opts.nodeLinker !== 'pnp' && !opts.virtualStoreOnly
if (shouldWritePackageMap) {
// Omit the importer self-mapping when a project has no name: the map keys
// dependencies by package name, so falling back to the importer id (`.` or
// a path) would emit a non-package-name key. Matches pacquet.
const importerNames = Object.fromEntries(
selectedProjects.map(({ manifest, id }) => [id, manifest.name])
)
if (opts.nodeLinker === 'hoisted') {
await writePackageMapFromDependenciesGraph({
directDependenciesByImporterId,
graph,
importerNames,
lockfile: filteredLockfile,
lockfileDir,
packageMapType: opts.nodePackageMapType,
packageIdStrategy: 'path',
rootModulesDir,
})
} else {
await writePackageMap(filteredLockfile, {
importerNames,
lockfileDir,
locationByDepPath: Object.fromEntries(
Object.values(graph).map((node) => [node.depPath, node.dir])
),
packageMapType: opts.nodePackageMapType,
rootModulesDir,
virtualStoreDir,
virtualStoreDirMaxLength: opts.virtualStoreDirMaxLength,
})
}
}
if (opts.ignoreScripts) {
for (const { id, manifest } of selectedProjects) {
if (opts.ignoreScripts && ((manifest?.scripts) != null) &&
@@ -577,7 +614,13 @@ export async function headlessInstall (opts: HeadlessOptions): Promise<Installat
if (opts.enablePnp) {
extraEnv = {
...extraEnv,
...makeNodeRequireOption(path.join(opts.lockfileDir, '.pnp.cjs')),
...makeNodeRequireOption(path.join(opts.lockfileDir, '.pnp.cjs'), extraEnv),
}
}
if (opts.nodeExperimentalPackageMap && shouldWritePackageMap) {
extraEnv = {
...extraEnv,
...makeNodePackageMapOption(path.join(rootModulesDir, PACKAGE_MAP_FILENAME), extraEnv),
}
}
// Dependency lifecycle scripts must not run on an unverified lockfile.
@@ -715,6 +758,12 @@ export async function headlessInstall (opts: HeadlessOptions): Promise<Installat
summaryLogger.debug({ prefix: lockfileDir })
if (!opts.ignoreScripts && !opts.ignorePackageManifest && !skipPostImportLinking) {
if (opts.nodeExperimentalPackageMap && shouldWritePackageMap) {
scriptsOpts.extraEnv = {
...scriptsOpts.extraEnv,
...makeNodePackageMapOption(path.join(rootModulesDir, PACKAGE_MAP_FILENAME), scriptsOpts.extraEnv),
}
}
// The projects' own lifecycle scripts import dependency code linked from
// the lockfile, so they are held to the same gate as dependency builds —
// also on the `enableModulesDir: false` path that skips buildModules.

View File

@@ -825,7 +825,7 @@ test('installing with no symlinks but with PnP', async () => {
symlink: false,
}))
expect([...fs.readdirSync(path.join(prefix, 'node_modules'))]).toStrictEqual(['.bin', '.modules.yaml', '.pnpm'])
expect([...fs.readdirSync(path.join(prefix, 'node_modules')).sort()]).toStrictEqual(['.bin', '.modules.yaml', '.package-map.json', '.pnpm'])
expect([...fs.readdirSync(path.join(prefix, 'node_modules/.pnpm/rimraf@2.7.1/node_modules'))]).toStrictEqual(['rimraf'])
const project = assertProject(prefix)

View File

@@ -10,6 +10,20 @@ import type { Registries } from '@pnpm/types'
import { generateInlinedScript, type PackageRegistry } from '@yarnpkg/pnp'
import normalizePath from 'normalize-path'
export {
type DependenciesGraphPackageMapOptions,
dependenciesGraphToPackageMap,
lockfileToPackageMap,
PACKAGE_MAP_FILENAME,
type PackageMap,
type PackageMapGraphNode,
type PackageMapOptions,
type PackageMapPackage,
type PackageMapType,
writePackageMap,
writePackageMapFromDependenciesGraph,
} from './packageMap.js'
export async function writePnpFile (
lockfile: LockfileObject,
opts: {

View File

@@ -0,0 +1,481 @@
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { depPathToFilename, refToRelative } from '@pnpm/deps.path'
import type { LockfileObject } from '@pnpm/lockfile.fs'
import { nameVerFromPkgSnapshot } from '@pnpm/lockfile.utils'
import type { DepPath } from '@pnpm/types'
import normalizePath from 'normalize-path'
export const PACKAGE_MAP_FILENAME = '.package-map.json'
export interface PackageMap {
packages: Record<string, PackageMapPackage>
}
export interface PackageMapPackage {
url: string
dependencies: Record<string, string>
}
export type PackageMapType = 'standard' | 'loose'
export interface PackageMapOptions {
importerNames: Record<string, string | undefined>
lockfileDir: string
packageMapType?: PackageMapType
rootModulesDir: string
virtualStoreDir: string
virtualStoreDirMaxLength: number
/**
* Real on-disk directory of each package, keyed by depPath. Required for the
* global virtual store, where packages live at a content-hashed path that
* cannot be derived from the depPath alone. Falls back to the local
* `<virtualStoreDir>/<depPathToFilename>` layout when a depPath is absent.
*/
locationByDepPath?: Record<string, string>
}
export interface DependenciesGraphPackageMapOptions {
directDependenciesByImporterId: Record<string, Record<string, string>>
graph: Record<string, PackageMapGraphNode>
importerNames: Record<string, string | undefined>
lockfile: LockfileObject
lockfileDir: string
packageMapType?: PackageMapType
rootModulesDir: string
packageIdStrategy: 'depPath' | 'path'
}
export interface PackageMapGraphNode {
children: Record<string, string>
depPath: DepPath
dir: string
name: string
}
export async function writePackageMap (
lockfile: LockfileObject,
opts: PackageMapOptions
): Promise<void> {
await fs.mkdir(opts.rootModulesDir, { recursive: true })
// Serialized compact, not pretty-printed: it lives in `node_modules` and is
// read by tooling, not humans, so the formatting only adds CPU and bytes on
// the install path.
await fs.writeFile(
path.join(opts.rootModulesDir, PACKAGE_MAP_FILENAME),
`${JSON.stringify(lockfileToPackageMap(lockfile, opts))}\n`,
'utf8'
)
}
export async function writePackageMapFromDependenciesGraph (
opts: DependenciesGraphPackageMapOptions
): Promise<void> {
await fs.mkdir(opts.rootModulesDir, { recursive: true })
// Compact serialization, like `writePackageMap` above.
await fs.writeFile(
path.join(opts.rootModulesDir, PACKAGE_MAP_FILENAME),
`${JSON.stringify(dependenciesGraphToPackageMap(opts))}\n`,
'utf8'
)
}
export function lockfileToPackageMap (
lockfile: LockfileObject,
opts: PackageMapOptions
): PackageMap {
const isLoose = opts.packageMapType === 'loose'
// Keyed by filesystem-derived IDs (importer ids, `link:` targets), so a
// dependency or project literally named `__proto__` must not reach the
// object prototype. A null-prototype map keeps those keys as plain entries.
const packages: PackageMap['packages'] = Object.create(null)
const packageLocationsByModulesDir = isLoose ? new Map<string, Map<string, string>>() : undefined
const packageDirsById = isLoose ? new Map<string, string>() : undefined
const addPackage = (id: string, packageDir: string, dependencies: Map<string, string>) => {
packageDirsById?.set(id, packageDir)
packages[id] = {
url: toRelativeUrl(opts.rootModulesDir, packageDir),
dependencies: Object.fromEntries(Array.from(dependencies).sort(([a], [b]) => compareStrings(a, b))),
}
}
const addExternalLinkPackage = (target: LinkTarget) => {
packages[target.id] ??= {
url: toRelativeUrl(opts.rootModulesDir, target.dir),
dependencies: {},
}
}
const addPackageLocation = (packageName: string, packageLocation: string, packageId: string) => {
if (packageLocationsByModulesDir == null) return
const modulesDir = getNodeModulesPath(packageLocation)
if (modulesDir == null) return
addPackageToModulesDir(packageLocationsByModulesDir, modulesDir, packageName, packageId)
}
const addDependencyLocation = (modulesDir: string, dependencyName: string, dependencyId: string) => {
if (packageLocationsByModulesDir == null) return
addPackageToModulesDir(packageLocationsByModulesDir, modulesDir, dependencyName, dependencyId)
}
for (const [importerId, importer] of Object.entries(lockfile.importers).sort(([a], [b]) => compareStrings(a, b))) {
const dependencies = new Map<string, string>()
const importerName = opts.importerNames[importerId]
if (importerName) {
dependencies.set(importerName, importerId)
}
addDependencies(dependencies, importer.dependencies, { importerId })
addDependencies(dependencies, importer.optionalDependencies, { importerId })
addDependencies(dependencies, importer.devDependencies, { importerId })
addPackage(importerId, resolvePath(opts.lockfileDir, importerId), dependencies)
if (isLoose) {
const importerModulesDir = resolvePath(opts.lockfileDir, importerId, 'node_modules')
addPhysicalDependencyLocations(importerModulesDir, importer.dependencies, { importerId })
addPhysicalDependencyLocations(importerModulesDir, importer.optionalDependencies, { importerId })
addPhysicalDependencyLocations(importerModulesDir, importer.devDependencies, { importerId })
}
}
for (const [depPath, pkgSnapshot] of Object.entries(lockfile.packages ?? {}).sort(([a], [b]) => compareStrings(a, b))) {
const { name } = nameVerFromPkgSnapshot(depPath, pkgSnapshot)
const packageDir = opts.locationByDepPath?.[depPath] ?? joinPath(
opts.virtualStoreDir,
depPathToFilename(depPath as DepPath, opts.virtualStoreDirMaxLength),
'node_modules',
name
)
const dependencies = new Map<string, string>([[name, depPath]])
addDependencies(dependencies, pkgSnapshot.dependencies)
addDependencies(dependencies, pkgSnapshot.optionalDependencies)
addPackage(depPath, packageDir, dependencies)
if (isLoose) {
addPackageLocation(name, packageDir, depPath)
const packageModulesDir = joinPath(packageDir, 'node_modules')
addPhysicalDependencyLocations(packageModulesDir, pkgSnapshot.dependencies)
addPhysicalDependencyLocations(packageModulesDir, pkgSnapshot.optionalDependencies)
}
}
if (isLoose) {
for (const [id, packageDir] of packageDirsById!) {
packages[id].dependencies = serializeDependencies(new Map([
...Object.entries(packages[id].dependencies),
...physicalDependencies(packageDir, packageLocationsByModulesDir!),
]))
}
}
return {
packages: Object.fromEntries(Object.entries(packages).sort(([a], [b]) => compareStrings(a, b))),
}
function addDependencies (
dependencies: Map<string, string>,
deps: Record<string, string> | undefined,
opts?: { importerId: string }
) {
for (const [alias, ref] of Object.entries(deps ?? {}).sort(([a], [b]) => compareStrings(a, b))) {
const dependencyId = resolveDependencyId(alias, ref, opts)
if (dependencyId == null) continue
dependencies.set(alias, dependencyId)
}
}
function resolveDependencyId (
alias: string,
ref: string,
depOpts: { importerId: string } | undefined
): string | undefined {
if (ref.startsWith('link:')) {
const target = resolveLinkTarget(opts.lockfileDir, depOpts?.importerId, ref)
addExternalLinkPackage(target)
return target.id
}
const relDepPath = refToRelative(ref, alias)
if (relDepPath == null || lockfile.packages?.[relDepPath] == null) return undefined
return relDepPath
}
function addPhysicalDependencyLocations (
modulesDir: string,
deps: Record<string, string> | undefined,
physicalOpts?: { importerId: string }
) {
for (const [alias, ref] of Object.entries(deps ?? {})) {
if (ref.startsWith('link:')) {
const target = resolveLinkTarget(opts.lockfileDir, physicalOpts?.importerId, ref)
addExternalLinkPackage(target)
addDependencyLocation(modulesDir, alias, target.id)
continue
}
const relDepPath = refToRelative(ref, alias)
if (relDepPath == null || lockfile.packages?.[relDepPath] == null) continue
addDependencyLocation(modulesDir, alias, relDepPath)
}
}
}
export function dependenciesGraphToPackageMap (
opts: DependenciesGraphPackageMapOptions
): PackageMap {
const isLoose = opts.packageMapType === 'loose'
// See `lockfileToPackageMap`: null-prototype guard against `__proto__` ids.
const packages: PackageMap['packages'] = Object.create(null)
const packageIdsByGraphKey = new Map<string, string>()
const packageDirsById = isLoose ? new Map<string, string>() : undefined
const packageLocationsByModulesDir = isLoose ? new Map<string, Map<string, string>>() : undefined
const addPackage = (id: string, packageDir: string, dependencies: Map<string, string>) => {
packageDirsById?.set(id, packageDir)
packages[id] = {
url: toRelativeUrl(opts.rootModulesDir, packageDir),
dependencies: Object.fromEntries(Array.from(dependencies).sort(([a], [b]) => compareStrings(a, b))),
}
}
const addExternalLinkPackage = (target: LinkTarget) => {
packages[target.id] ??= {
url: toRelativeUrl(opts.rootModulesDir, target.dir),
dependencies: {},
}
}
const addDependencyLocation = (modulesDir: string, dependencyName: string, dependencyId: string) => {
if (packageLocationsByModulesDir == null) return
addPackageToModulesDir(packageLocationsByModulesDir, modulesDir, dependencyName, dependencyId)
}
for (const [graphKey, node] of Object.entries(opts.graph).sort(([a], [b]) => compareStrings(a, b))) {
packageIdsByGraphKey.set(graphKey, graphNodePackageId(node, opts))
const modulesDir = isLoose ? getNodeModulesPath(node.dir) : undefined
if (modulesDir && packageLocationsByModulesDir != null) {
addPackageToModulesDir(packageLocationsByModulesDir, modulesDir, node.name, graphNodePackageId(node, opts))
}
}
for (const [importerId, importer] of Object.entries(opts.lockfile.importers).sort(([a], [b]) => compareStrings(a, b))) {
const importerDir = resolvePath(opts.lockfileDir, importerId)
const importerPackageId = graphPackageId(importerDir, opts)
const dependencies = new Map<string, string>()
const importerName = opts.importerNames[importerId]
if (importerName) {
dependencies.set(importerName, importerPackageId)
}
addDirectDependencies(dependencies, opts.directDependenciesByImporterId[importerId])
const importerModulesDir = isLoose ? joinPath(importerDir, 'node_modules') : undefined
addLinkedDependencies(dependencies, importer.dependencies, { importerId, modulesDir: importerModulesDir })
addLinkedDependencies(dependencies, importer.optionalDependencies, { importerId, modulesDir: importerModulesDir })
addLinkedDependencies(dependencies, importer.devDependencies, { importerId, modulesDir: importerModulesDir })
addPackage(importerPackageId, importerDir, dependencies)
}
for (const [graphKey, node] of Object.entries(opts.graph).sort(([a], [b]) => compareStrings(a, b))) {
const dependencies = new Map<string, string>([[node.name, packageIdsByGraphKey.get(graphKey)!]])
addGraphDependencies(dependencies, node.children)
const pkgSnapshot = opts.lockfile.packages?.[node.depPath]
if (pkgSnapshot) {
const packageModulesDir = isLoose ? joinPath(node.dir, 'node_modules') : undefined
addLinkedDependencies(dependencies, pkgSnapshot.dependencies, { modulesDir: packageModulesDir })
addLinkedDependencies(dependencies, pkgSnapshot.optionalDependencies, { modulesDir: packageModulesDir })
}
addPackage(packageIdsByGraphKey.get(graphKey)!, node.dir, dependencies)
}
if (isLoose) {
for (const [id, packageDir] of packageDirsById!) {
packages[id].dependencies = serializeDependencies(new Map([
...Object.entries(packages[id].dependencies),
...physicalDependencies(packageDir, packageLocationsByModulesDir!),
]))
}
}
return {
packages: Object.fromEntries(Object.entries(packages).sort(([a], [b]) => compareStrings(a, b))),
}
function addDirectDependencies (
dependencies: Map<string, string>,
deps: Record<string, string> | undefined
) {
for (const [alias, graphKey] of Object.entries(deps ?? {}).sort(([a], [b]) => compareStrings(a, b))) {
const packageId = packageIdsByGraphKey.get(graphKey)
if (packageId) dependencies.set(alias, packageId)
}
}
function addGraphDependencies (
dependencies: Map<string, string>,
deps: Record<string, string> | undefined
) {
for (const [alias, graphKey] of Object.entries(deps ?? {}).sort(([a], [b]) => compareStrings(a, b))) {
const packageId = packageIdsByGraphKey.get(graphKey)
if (packageId) dependencies.set(alias, packageId)
}
}
function addLinkedDependencies (
dependencies: Map<string, string>,
deps: Record<string, string> | undefined,
linkedOpts: {
importerId?: string
modulesDir?: string
} = {}
) {
for (const [alias, ref] of Object.entries(deps ?? {}).sort(([a], [b]) => compareStrings(a, b))) {
if (!ref.startsWith('link:')) continue
const target = resolveLinkTarget(opts.lockfileDir, linkedOpts.importerId, ref)
const targetId = opts.packageIdStrategy === 'path'
? graphPackageId(target.dir, opts)
: target.id
addExternalLinkPackage({
...target,
id: targetId,
})
dependencies.set(alias, targetId)
if (linkedOpts.modulesDir) {
addDependencyLocation(linkedOpts.modulesDir, alias, targetId)
}
}
}
}
interface LinkTarget {
id: string
dir: string
}
function resolveLinkTarget (lockfileDir: string, importerId: string | undefined, ref: string): LinkTarget {
const linkPath = ref.slice(5)
// Detect the path flavor from `linkPath`, not the raw `ref`: the `link:`
// prefix would hide a Windows-absolute target (e.g. `link:C:\x`) from the
// drive-letter check, making it look relative on POSIX.
const pathUtils = getPathUtils(lockfileDir, linkPath)
const importerDir = pathUtils.resolve(lockfileDir, importerId ?? '.')
const dir = pathUtils.isAbsolute(linkPath)
? linkPath
: pathUtils.resolve(importerDir, linkPath)
const relativeId = relativePath(lockfileDir, dir)
return {
id: relativeId == null || relativeId.startsWith('..') ? `link:${normalizePath(dir)}` : relativeId,
dir,
}
}
function toRelativeUrl (from: string, to: string): string {
// No meaningful relative path exists between a POSIX dir and a
// Windows-absolute target (or vice versa), so emit an absolute file URL
// rather than letting `path.win32.relative` produce a bogus relative string.
const toIsWindows = isWindowsAbsolutePath(to)
if (toIsWindows !== isWindowsAbsolutePath(from)) {
return pathToFileURL(to, { windows: toIsWindows }).href
}
const pathUtils = getPathUtils(from, to)
const relative = pathUtils.relative(from, to)
if (pathUtils.isAbsolute(relative)) {
return pathToFileURL(to, { windows: pathUtils === path.win32 }).href
}
const normalizedRelativePath = normalizePath(relative) || '.'
if (normalizedRelativePath === '.' || normalizedRelativePath === '..' || normalizedRelativePath.startsWith('./') || normalizedRelativePath.startsWith('../')) {
return normalizedRelativePath
}
return `./${normalizedRelativePath}`
}
function getNodeModulesPath (packageLocation: string): string | undefined {
const segments = normalizePath(packageLocation).split('/')
const nodeModulesIndex = segments.lastIndexOf('node_modules')
if (nodeModulesIndex === -1) return undefined
return segments.slice(0, nodeModulesIndex + 1).join('/')
}
function addPackageToModulesDir (
packageLocationsByModulesDir: Map<string, Map<string, string>>,
modulesDir: string,
packageName: string,
packageId: string
) {
const normalizedModulesDir = normalizePath(modulesDir)
let packageLocations = packageLocationsByModulesDir.get(normalizedModulesDir)
if (packageLocations == null) {
packageLocations = new Map()
packageLocationsByModulesDir.set(normalizedModulesDir, packageLocations)
}
packageLocations.set(packageName, packageId)
}
function physicalDependencies (
packageDir: string,
packageLocationsByModulesDir: Map<string, Map<string, string>>
): Map<string, string> {
const dependencies = new Map<string, string>()
const pathUtils = getPathUtils(packageDir)
let currentPath = packageDir
while (true) {
const modulesDir = normalizePath(pathUtils.join(currentPath, 'node_modules'))
const packageLocations = packageLocationsByModulesDir.get(modulesDir)
if (packageLocations) {
for (const [dependencyName, packageId] of Array.from(packageLocations).sort(([a], [b]) => compareStrings(a, b))) {
if (!dependencies.has(dependencyName)) {
dependencies.set(dependencyName, packageId)
}
}
}
const parentPath = pathUtils.dirname(currentPath)
if (parentPath === currentPath) break
currentPath = parentPath
}
return dependencies
}
function serializeDependencies (dependencies: Map<string, string>): Record<string, string> {
return Object.fromEntries(Array.from(dependencies).sort(([a], [b]) => compareStrings(a, b)))
}
function graphNodePackageId (node: PackageMapGraphNode, opts: DependenciesGraphPackageMapOptions): string {
if (opts.packageIdStrategy === 'depPath') return node.depPath
return graphPackageId(node.dir, opts)
}
function graphPackageId (packageDir: string, opts: Pick<DependenciesGraphPackageMapOptions, 'rootModulesDir'>): string {
const relativeId = relativePath(opts.rootModulesDir, packageDir)
if (relativeId == null) return `link:${normalizePath(packageDir)}`
return relativeId === '..' ? '.' : relativeId
}
type PathUtils = typeof path.posix
const WINDOWS_ABSOLUTE_PATH_REGEXP = /^(?:[a-z]:[\\/]|[/\\]{2}[^/\\])/i
function resolvePath (from: string, ...segments: string[]): string {
return getPathUtils(from, ...segments).resolve(from, ...segments)
}
function joinPath (from: string, ...segments: string[]): string {
return getPathUtils(from, ...segments).join(from, ...segments)
}
function relativePath (from: string, to: string): string | undefined {
const pathUtils = getPathUtils(from, to)
const relative = pathUtils.relative(from, to)
if (pathUtils.isAbsolute(relative)) return undefined
return normalizePath(relative) || '.'
}
function getPathUtils (...paths: string[]): PathUtils {
return paths.some(isWindowsAbsolutePath) ? path.win32 : path
}
function isWindowsAbsolutePath (pathLike: string): boolean {
return WINDOWS_ABSOLUTE_PATH_REGEXP.test(pathLike)
}
function compareStrings (a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0
}

View File

@@ -2,7 +2,7 @@
import path from 'node:path'
import { expect, test } from '@jest/globals'
import { lockfileToPackageRegistry } from '@pnpm/lockfile.to-pnp'
import { dependenciesGraphToPackageMap, lockfileToPackageMap, lockfileToPackageRegistry } from '@pnpm/lockfile.to-pnp'
import type { DepPath, ProjectId } from '@pnpm/types'
test('lockfileToPackageRegistry', () => {
@@ -177,6 +177,419 @@ test('lockfileToPackageRegistry', () => {
])
})
test('lockfileToPackageMap', () => {
const packageMap = lockfileToPackageMap({
importers: {
['.' as ProjectId]: {
dependencies: {
dep1: '1.0.0',
dep2Alias: 'foo@2.0.0',
linked: 'link:packages/linked',
},
specifiers: {},
},
['packages/app' as ProjectId]: {
dependencies: {
dep1: '1.0.0',
linked: 'link:../linked',
},
devDependencies: {
dep2Alias: 'foo@2.0.0',
},
specifiers: {},
},
['packages/linked' as ProjectId]: {
dependencies: {
qar: '3.0.0',
},
specifiers: {},
},
},
lockfileVersion: '5',
packages: {
['dep1@1.0.0' as DepPath]: {
dependencies: {
dep2Alias: 'foo@2.0.0',
},
resolution: {
integrity: '',
},
},
['foo@2.0.0' as DepPath]: {
optionalDependencies: {
qar: '3.0.0',
},
resolution: {
integrity: '',
},
},
['qar@3.0.0' as DepPath]: {
resolution: {
integrity: '',
},
},
},
}, {
importerNames: {
'.': 'root',
'packages/app': 'app',
'packages/linked': 'linked',
},
lockfileDir: process.cwd(),
rootModulesDir: path.resolve('node_modules'),
virtualStoreDir: path.resolve('node_modules/.pnpm'),
virtualStoreDirMaxLength: 120,
})
expect(packageMap).toStrictEqual({
packages: {
'.': {
url: '..',
dependencies: {
dep1: 'dep1@1.0.0',
dep2Alias: 'foo@2.0.0',
linked: 'packages/linked',
root: '.',
},
},
'dep1@1.0.0': {
url: './.pnpm/dep1@1.0.0/node_modules/dep1',
dependencies: {
dep1: 'dep1@1.0.0',
dep2Alias: 'foo@2.0.0',
},
},
'foo@2.0.0': {
url: './.pnpm/foo@2.0.0/node_modules/foo',
dependencies: {
foo: 'foo@2.0.0',
qar: 'qar@3.0.0',
},
},
'packages/app': {
url: '../packages/app',
dependencies: {
app: 'packages/app',
dep1: 'dep1@1.0.0',
dep2Alias: 'foo@2.0.0',
linked: 'packages/linked',
},
},
'packages/linked': {
url: '../packages/linked',
dependencies: {
linked: 'packages/linked',
qar: 'qar@3.0.0',
},
},
'qar@3.0.0': {
url: './.pnpm/qar@3.0.0/node_modules/qar',
dependencies: {
qar: 'qar@3.0.0',
},
},
},
})
})
test('lockfileToPackageMap loose mode includes linked dependencies from physical ancestors', () => {
const lockfile = {
importers: {
['.' as ProjectId]: {
dependencies: {
dep1: '1.0.0',
linked: 'link:packages/linked',
},
specifiers: {},
},
},
lockfileVersion: '5',
packages: {
['dep1@1.0.0' as DepPath]: {
resolution: {
integrity: '',
},
},
},
}
const opts = {
importerNames: {
'.': 'root',
},
lockfileDir: process.cwd(),
rootModulesDir: path.resolve('node_modules'),
virtualStoreDir: path.resolve('node_modules/.pnpm'),
virtualStoreDirMaxLength: 120,
}
const standardPackageMap = lockfileToPackageMap(lockfile, opts)
const loosePackageMap = lockfileToPackageMap(lockfile, {
...opts,
packageMapType: 'loose',
})
expect(standardPackageMap.packages['dep1@1.0.0'].dependencies).toStrictEqual({
dep1: 'dep1@1.0.0',
})
expect(loosePackageMap.packages['dep1@1.0.0'].dependencies).toStrictEqual({
dep1: 'dep1@1.0.0',
linked: 'packages/linked',
})
})
test('lockfileToPackageMap uses file urls and link ids for Windows cross-drive links', () => {
const packageMap = lockfileToPackageMap({
importers: {
['.' as ProjectId]: {
dependencies: {
linked: 'link:D:\\external\\linked',
},
specifiers: {},
},
},
lockfileVersion: '5',
packages: {},
}, {
importerNames: {},
lockfileDir: 'C:\\repo',
rootModulesDir: 'C:\\repo\\node_modules',
virtualStoreDir: 'C:\\repo\\node_modules\\.pnpm',
virtualStoreDirMaxLength: 120,
})
expect(packageMap.packages['.'].dependencies).toStrictEqual({
linked: 'link:D:/external/linked',
})
expect(packageMap.packages['link:D:/external/linked'].url).toBe('file:///D:/external/linked')
})
test('lockfileToPackageMap detects a Windows-absolute link target on a POSIX lockfile dir', () => {
const packageMap = lockfileToPackageMap({
importers: {
['.' as ProjectId]: {
dependencies: {
linked: 'link:C:\\external\\linked',
},
specifiers: {},
},
},
lockfileVersion: '5',
packages: {},
}, {
importerNames: {},
lockfileDir: '/repo',
rootModulesDir: '/repo/node_modules',
virtualStoreDir: '/repo/node_modules/.pnpm',
virtualStoreDirMaxLength: 120,
})
// Without stripping `link:` before picking the path flavor, the target is
// misread as a relative path and resolved under the importer dir.
expect(packageMap.packages['.'].dependencies).toStrictEqual({
linked: 'link:C:/external/linked',
})
expect(packageMap.packages['link:C:/external/linked'].url).toBe('file:///C:/external/linked')
})
test('dependenciesGraphToPackageMap uses file urls and link ids for Windows cross-drive links', () => {
const packageMap = dependenciesGraphToPackageMap({
directDependenciesByImporterId: {
'.': {},
},
graph: {},
importerNames: {
'.': 'root',
},
lockfile: {
importers: {
['.' as ProjectId]: {
dependencies: {
linked: 'link:D:\\external\\linked',
},
specifiers: {},
},
},
lockfileVersion: '5',
packages: {},
},
lockfileDir: 'C:\\repo',
packageIdStrategy: 'path',
rootModulesDir: 'C:\\repo\\node_modules',
})
expect(packageMap.packages['.'].dependencies).toStrictEqual({
linked: 'link:D:/external/linked',
root: '.',
})
expect(packageMap.packages['link:D:/external/linked'].url).toBe('file:///D:/external/linked')
})
test('dependenciesGraphToPackageMap loose mode includes linked dependencies from physical ancestors', () => {
const rootModulesDir = path.resolve('node_modules')
const dep1Dir = path.join(rootModulesDir, 'dep1')
const packageMap = dependenciesGraphToPackageMap({
directDependenciesByImporterId: {
'.': {
dep1: dep1Dir,
},
},
graph: {
[dep1Dir]: {
children: {},
depPath: 'dep1@1.0.0' as DepPath,
dir: dep1Dir,
name: 'dep1',
},
},
importerNames: {
'.': 'root',
},
lockfile: {
importers: {
['.' as ProjectId]: {
dependencies: {
dep1: '1.0.0',
linked: 'link:packages/linked',
},
specifiers: {},
},
},
lockfileVersion: '5',
packages: {
['dep1@1.0.0' as DepPath]: {
resolution: {
integrity: '',
},
},
},
},
lockfileDir: process.cwd(),
packageIdStrategy: 'path',
packageMapType: 'loose',
rootModulesDir,
})
expect(packageMap.packages.dep1.dependencies).toStrictEqual({
dep1: 'dep1',
linked: '../packages/linked',
})
})
test('dependenciesGraphToPackageMap with path package ids', () => {
const packageMap = dependenciesGraphToPackageMap({
directDependenciesByImporterId: {
'.': {
dep1: path.resolve('node_modules/dep1'),
},
'packages/app': {
dep1: path.resolve('packages/app/node_modules/dep1'),
},
},
graph: {
[path.resolve('node_modules/dep1')]: {
children: {
dep2Alias: path.resolve('node_modules/foo'),
},
depPath: 'dep1@1.0.0' as DepPath,
dir: path.resolve('node_modules/dep1'),
name: 'dep1',
},
[path.resolve('node_modules/foo')]: {
children: {},
depPath: 'foo@2.0.0' as DepPath,
dir: path.resolve('node_modules/foo'),
name: 'foo',
},
[path.resolve('packages/app/node_modules/dep1')]: {
children: {
dep2Alias: path.resolve('node_modules/foo'),
},
depPath: 'dep1@1.0.0' as DepPath,
dir: path.resolve('packages/app/node_modules/dep1'),
name: 'dep1',
},
},
importerNames: {
'.': 'root',
'packages/app': 'app',
},
lockfile: {
importers: {
['.' as ProjectId]: {
dependencies: {
dep1: '1.0.0',
},
specifiers: {},
},
['packages/app' as ProjectId]: {
dependencies: {
dep1: '1.0.0',
},
specifiers: {},
},
},
lockfileVersion: '5',
packages: {
['dep1@1.0.0' as DepPath]: {
dependencies: {
dep2Alias: 'foo@2.0.0',
},
resolution: {
integrity: '',
},
},
['foo@2.0.0' as DepPath]: {
resolution: {
integrity: '',
},
},
},
},
lockfileDir: process.cwd(),
packageIdStrategy: 'path',
rootModulesDir: path.resolve('node_modules'),
})
expect(packageMap).toStrictEqual({
packages: {
'.': {
url: '..',
dependencies: {
dep1: 'dep1',
root: '.',
},
},
dep1: {
url: './dep1',
dependencies: {
dep1: 'dep1',
dep2Alias: 'foo',
},
},
foo: {
url: './foo',
dependencies: {
foo: 'foo',
},
},
'../packages/app': {
url: '../packages/app',
dependencies: {
app: '../packages/app',
dep1: '../packages/app/node_modules/dep1',
},
},
'../packages/app/node_modules/dep1': {
url: '../packages/app/node_modules/dep1',
dependencies: {
dep1: '../packages/app/node_modules/dep1',
dep2Alias: 'foo',
},
},
},
})
})
test('lockfileToPackageRegistry packages that have peer deps', () => {
const packageRegistry = lockfileToPackageRegistry({
importers: {

View File

@@ -5,6 +5,7 @@ use derive_more::{Display, Error};
use miette::Diagnostic;
use pacquet_config::Config;
use pacquet_executor::{push_script_arg, select_shell};
use pacquet_package_manager::{make_node_package_map_option, package_map_path_for_execution};
use pacquet_package_manifest::PackageManifest;
use std::{
ffi::{OsStr, OsString},
@@ -183,9 +184,14 @@ pub(super) fn spawn_in_dir(
if let Some(name) = read_package_name(dir) {
cmd.env("PNPM_PACKAGE_NAME", name);
}
let mut node_options = config.node_options.clone();
if let Some(package_map_path) = package_map_path_for_execution(config, dir) {
node_options =
Some(make_node_package_map_option(&package_map_path, node_options.as_deref()));
}
// pnpm forwards `nodeOptions` as `NODE_OPTIONS` to the child.
// See exec.ts:246.
if let Some(node_options) = &config.node_options {
if let Some(node_options) = node_options {
cmd.env("NODE_OPTIONS", node_options);
}

View File

@@ -3,6 +3,7 @@ use derive_more::{Display, Error};
use miette::Diagnostic;
use pacquet_config::Config;
use pacquet_executor::{RunScript, ScriptsPrependNodePath, run_script};
use pacquet_package_manager::{make_node_package_map_option, package_map_path_for_execution};
use pacquet_package_manifest::{PackageManifest, PackageManifestError};
use serde_json::Value;
use std::{
@@ -129,6 +130,13 @@ impl RunArgs {
if let Some(node_options) = &config.node_options {
extra_env.insert("NODE_OPTIONS".to_string(), node_options.clone());
}
if let Some(package_map_path) = package_map_path_for_execution(config, dir) {
let node_options = extra_env.get("NODE_OPTIONS").map(String::as_str);
extra_env.insert(
"NODE_OPTIONS".to_string(),
make_node_package_map_option(&package_map_path, node_options),
);
}
let init_cwd: PathBuf = env::current_dir().unwrap_or_else(|_| dir.to_path_buf());
let ctx = RunContext {

View File

@@ -25,6 +25,7 @@ use derive_more::{Display, Error};
use indexmap::IndexMap;
use miette::{Context, Diagnostic, IntoDiagnostic};
use pacquet_config::Config;
use pacquet_package_manager::{make_node_package_map_option, package_map_path_for_execution};
use pacquet_workspace::{
FindWorkspaceProjectsOpts, find_workspace_projects, read_workspace_manifest,
workspace_package_patterns,
@@ -115,6 +116,13 @@ pub fn run_recursive(args: &RunArgs, config: &Config, dir: &Path) -> miette::Res
if let Some(node_options) = &config.node_options {
extra_env.insert("NODE_OPTIONS".to_string(), node_options.clone());
}
if let Some(package_map_path) = package_map_path_for_execution(config, dir) {
let node_options = extra_env.get("NODE_OPTIONS").map(String::as_str);
extra_env.insert(
"NODE_OPTIONS".to_string(),
make_node_package_map_option(&package_map_path, node_options),
);
}
for chunk in &chunks {
for root in chunk {

View File

@@ -30,7 +30,7 @@ use pacquet_testing_utils::{
bin::{AddMockedRegistry, CommandTempCwd},
fs::is_symlink_or_junction,
};
use std::{fs, path::Path};
use std::{fs, path::Path, process::Command};
/// Replace the `pnpm-workspace.yaml` written by `add_mocked_registry`
/// with one that keeps the mock's `storeDir` / `cacheDir` and appends
@@ -214,6 +214,146 @@ fn peer_dependencies_installed_with_auto_install_peers() {
drop((root, mock_instance));
}
#[test]
fn package_map_resolves_declared_hoisted_dependencies_at_runtime() {
if node_major() < 27 {
eprintln!("skipping package-map runtime smoke: Node.js major is below 27");
return;
}
let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } =
CommandTempCwd::init().add_mocked_registry();
let AddMockedRegistry { mock_instance, .. } = npmrc_info;
write_manifest(&workspace, serde_json::json!({ "@pnpm.e2e/pkg-with-1-dep": "100.0.0" }));
write_workspace_yaml(&workspace, "nodeLinker: hoisted\n");
pacquet.with_args(["install"]).assert().success();
let root_dependency_dir = root_dependency_dir(&workspace, "@pnpm.e2e/pkg-with-1-dep");
let smoke = root_dependency_dir.join("package-map-smoke.cjs");
fs::write(&smoke, "require('@pnpm.e2e/dep-of-pkg-with-1-dep')\n").expect("write smoke file");
let output = run_node_with_package_map(&workspace, &smoke);
assert!(
output.status.success(),
"declared package should resolve with package map\nstdout:\n{}\nstderr:\n{}\npackage map:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
package_map_contents(&workspace),
);
drop((root, mock_instance));
}
#[test]
fn standard_package_map_blocks_undeclared_hoisted_dependencies_at_runtime() {
if node_major() < 27 {
eprintln!("skipping package-map runtime smoke: Node.js major is below 27");
return;
}
let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } =
CommandTempCwd::init().add_mocked_registry();
let AddMockedRegistry { mock_instance, .. } = npmrc_info;
write_manifest(
&workspace,
serde_json::json!({
"@pnpm.e2e/foo": "100.0.0",
"@pnpm.e2e/pkg-with-1-dep": "100.0.0",
}),
);
write_workspace_yaml(&workspace, "nodeLinker: hoisted\n");
pacquet.with_args(["install"]).assert().success();
let root_dependency_dir = root_dependency_dir(&workspace, "@pnpm.e2e/pkg-with-1-dep");
let smoke = root_dependency_dir.join("package-map-block-smoke.cjs");
fs::write(&smoke, "require('@pnpm.e2e/foo/package.json')\n").expect("write smoke file");
let output = run_node_with_package_map(&workspace, &smoke);
assert!(
!output.status.success(),
"undeclared hoisted package should not resolve in standard package-map mode",
);
drop((root, mock_instance));
}
#[test]
fn loose_package_map_allows_undeclared_hoisted_dependencies_at_runtime() {
if node_major() < 27 {
eprintln!("skipping package-map runtime smoke: Node.js major is below 27");
return;
}
let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } =
CommandTempCwd::init().add_mocked_registry();
let AddMockedRegistry { mock_instance, .. } = npmrc_info;
write_manifest(
&workspace,
serde_json::json!({
"@pnpm.e2e/foo": "100.0.0",
"@pnpm.e2e/pkg-with-1-dep": "100.0.0",
}),
);
write_workspace_yaml(&workspace, "nodeLinker: hoisted\nnodePackageMapType: loose\n");
pacquet.with_args(["install"]).assert().success();
let root_dependency_dir = root_dependency_dir(&workspace, "@pnpm.e2e/pkg-with-1-dep");
let smoke = root_dependency_dir.join("package-map-loose-smoke.cjs");
fs::write(&smoke, "require('@pnpm.e2e/foo/package.json')\n").expect("write smoke file");
let output = run_node_with_package_map(&workspace, &smoke);
assert!(
output.status.success(),
"undeclared hoisted package should resolve in loose package-map mode\nstdout:\n{}\nstderr:\n{}\npackage map:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
package_map_contents(&workspace),
);
drop((root, mock_instance));
}
fn run_node_with_package_map(workspace: &Path, script: &Path) -> std::process::Output {
Command::new("node")
.arg(format!(
"--experimental-package-map={}",
workspace.join("node_modules/.package-map.json").display(),
))
.arg(script)
.current_dir(workspace)
.output()
.expect("run Node.js")
}
fn package_map_contents(workspace: &Path) -> String {
fs::read_to_string(workspace.join("node_modules/.package-map.json"))
.unwrap_or_else(|error| format!("failed to read package map: {error}"))
}
fn root_dependency_dir(workspace: &Path, name: &str) -> std::path::PathBuf {
let package_map: serde_json::Value =
serde_json::from_str(&package_map_contents(workspace)).expect("parse package map");
let dependency_id =
package_map["packages"]["."]["dependencies"][name].as_str().expect("root dependency id");
let url = package_map["packages"][dependency_id]["url"].as_str().expect("dependency url");
workspace.join("node_modules").join(url)
}
fn node_major() -> u32 {
let output = Command::new("node").arg("--version").output().expect("run node --version");
assert!(output.status.success(), "node --version should succeed");
let version = String::from_utf8(output.stdout).expect("node version is utf8");
version
.trim()
.strip_prefix('v')
.unwrap_or_else(|| version.trim())
.split('.')
.next()
.expect("node version has a major")
.parse()
.expect("node major is numeric")
}
mod known_failures {
//! Ports of upstream `hoistedNodeLinker/install.ts` cases blocked
//! on features pacquet hasn't built yet. Each stubs the

View File

@@ -17,8 +17,8 @@
//! [`config/reader/src/index.ts:719-722`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/config/reader/src/index.ts#L719-L722).
use crate::{
CatalogMode, HoistingLimits, NodeLinker, PackageImportMethod, ResolutionMode,
ScriptsPrependNodePath, TrustPolicy, WorkspaceSettings, api::EnvVar,
CatalogMode, HoistingLimits, NodeLinker, NodePackageMapType, PackageImportMethod,
ResolutionMode, ScriptsPrependNodePath, TrustPolicy, WorkspaceSettings, api::EnvVar,
};
use serde::de::DeserializeOwned;
@@ -135,6 +135,8 @@ impl WorkspaceSettings {
string_field!(store_dir, "STORE_DIR");
string_field!(modules_dir, "MODULES_DIR");
enum_field!(node_linker, "NODE_LINKER", NodeLinker);
json_field!(node_experimental_package_map, "NODE_EXPERIMENTAL_PACKAGE_MAP");
enum_field!(node_package_map_type, "NODE_PACKAGE_MAP_TYPE", NodePackageMapType);
json_field!(symlink, "SYMLINK");
string_field!(virtual_store_dir, "VIRTUAL_STORE_DIR");
json_field!(enable_global_virtual_store, "ENABLE_GLOBAL_VIRTUAL_STORE");

View File

@@ -1,5 +1,5 @@
use super::{WorkspaceSettings, parse_json_or_string, parse_tri_array};
use crate::{NodeLinker, ScriptsPrependNodePath, TrustPolicy, api::EnvVar};
use crate::{NodeLinker, NodePackageMapType, ScriptsPrependNodePath, TrustPolicy, api::EnvVar};
use pretty_assertions::assert_eq;
#[test]
@@ -32,6 +32,10 @@ fn empty_env_var_is_treated_as_unset() {
fn enum_env_var_accepts_bare_identifier() {
assert_eq!(parse_json_or_string::<NodeLinker>("hoisted"), Some(NodeLinker::Hoisted));
assert_eq!(parse_json_or_string::<TrustPolicy>("no-downgrade"), Some(TrustPolicy::NoDowngrade));
assert_eq!(
parse_json_or_string::<NodePackageMapType>("loose"),
Some(NodePackageMapType::Loose),
);
}
#[test]

View File

@@ -61,6 +61,14 @@ pub enum NodeLinker {
Pnp,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum NodePackageMapType {
#[default]
Standard,
Loose,
}
/// Controls how far dependencies are hoisted under
/// `nodeLinker: hoisted`, mirroring yarn's `nmHoistingLimits`.
///
@@ -425,6 +433,16 @@ pub struct Config {
/// Defines what linker should be used for installing Node packages.
pub node_linker: NodeLinker,
/// When true, pacquet writes `node_modules/.package-map.json` for
/// Node's `--experimental-package-map` loader flag. Default
/// `false`, matching pnpm's opt-in setting.
pub node_experimental_package_map: bool,
/// Selects the package-map dependency surface. Pacquet currently
/// materializes only the standard map for isolated installs; loose
/// and hoisted maps require layout-aware writers.
pub node_package_map_type: NodePackageMapType,
/// When symlink is set to false, pnpm creates a virtual store directory without any symlinks.
/// It is a useful setting together with node-linker=pnp.
#[default = true]

View File

@@ -26,7 +26,8 @@
//! keeps catching the next default that needs porting.
use crate::{
CatalogMode, Config, LinkWorkspacePackages, NodeLinker, ResolutionMode, ScriptsPrependNodePath,
CatalogMode, Config, LinkWorkspacePackages, NodeLinker, NodePackageMapType, ResolutionMode,
ScriptsPrependNodePath,
};
use std::collections::BTreeSet;
@@ -139,6 +140,8 @@ fn mapped_rows(cfg: &Config) -> Vec<(&'static str, Scalar)> {
scripts_prepend_node_path_scalar(cfg.scripts_prepend_node_path),
),
("node-linker", node_linker_scalar(cfg.node_linker)),
("node-experimental-package-map", Bool(cfg.node_experimental_package_map)),
("node-package-map-type", node_package_map_type_scalar(cfg.node_package_map_type)),
("resolution-mode", resolution_mode_scalar(cfg.resolution_mode)),
("catalog-mode", catalog_mode_scalar(cfg.catalog_mode)),
("save-catalog-name", save_catalog_name_scalar(cfg.save_catalog_name.as_deref())),
@@ -176,6 +179,13 @@ fn node_linker_scalar(value: NodeLinker) -> Scalar {
}
}
fn node_package_map_type_scalar(value: NodePackageMapType) -> Scalar {
match value {
NodePackageMapType::Standard => s("standard"),
NodePackageMapType::Loose => s("loose"),
}
}
fn resolution_mode_scalar(value: ResolutionMode) -> Scalar {
match value {
ResolutionMode::Highest => s("highest"),

View File

@@ -1,6 +1,6 @@
use super::{
Config, EnvVar, EnvVarOs, GetCurrentDir, GetHomeDir, Host, LinkProbe, NodeLinker,
PackageImportMethod, fs,
NodePackageMapType, PackageImportMethod, fs,
};
use crate::defaults::default_store_dir;
use pacquet_store_dir::StoreDir;
@@ -96,6 +96,8 @@ inert_link_probe!(HostNoHome);
pub fn have_default_values() {
let value = Config::new();
assert_eq!(value.node_linker, NodeLinker::default());
assert!(!value.node_experimental_package_map);
assert_eq!(value.node_package_map_type, NodePackageMapType::Standard);
assert_eq!(value.package_import_method, PackageImportMethod::default());
assert!(value.prefer_frozen_lockfile);
assert!(value.symlink);
@@ -1448,6 +1450,49 @@ pub fn virtual_store_dir_max_length_env_var_overrides_yaml() {
);
}
#[test]
pub fn package_map_settings_load_from_workspace_yaml() {
let tmp = tempdir().unwrap();
fs::write(
tmp.path().join("pnpm-workspace.yaml"),
"nodeExperimentalPackageMap: true\nnodePackageMapType: loose\n",
)
.expect("write to pnpm-workspace.yaml");
let config = Config::new().current::<HostNoHome>(tmp.path()).expect("yaml is valid");
assert!(config.node_experimental_package_map);
assert_eq!(config.node_package_map_type, NodePackageMapType::Loose);
}
#[test]
pub fn package_map_settings_load_from_env() {
struct HostWithPackageMapEnv;
impl EnvVar for HostWithPackageMapEnv {
fn var(name: &str) -> Option<String> {
match name {
"PNPM_CONFIG_NODE_EXPERIMENTAL_PACKAGE_MAP" => Some("true".to_owned()),
"PNPM_CONFIG_NODE_PACKAGE_MAP_TYPE" => Some("loose".to_owned()),
_ => safe_host_var(name),
}
}
}
impl EnvVarOs for HostWithPackageMapEnv {
fn var_os(_: &str) -> Option<OsString> {
None
}
}
impl GetHomeDir for HostWithPackageMapEnv {
fn home_dir() -> Option<PathBuf> {
None
}
}
inert_link_probe!(HostWithPackageMapEnv);
let tmp = tempdir().unwrap();
let config = Config::new().current::<HostWithPackageMapEnv>(tmp.path()).expect("loads");
assert!(config.node_experimental_package_map);
assert_eq!(config.node_package_map_type, NodePackageMapType::Loose);
}
#[test]
pub fn peers_suffix_max_length_defaults_to_1000() {
let tmp = tempdir().unwrap();

View File

@@ -1,6 +1,7 @@
use crate::{
CatalogMode, Config, HoistingLimits, LinkWorkspacePackages, NodeLinker, PackageImportMethod,
ResolutionMode, ScriptsPrependNodePath, TrustPolicy, api::EnvVar, resolve_child_concurrency,
CatalogMode, Config, HoistingLimits, LinkWorkspacePackages, NodeLinker, NodePackageMapType,
PackageImportMethod, ResolutionMode, ScriptsPrependNodePath, TrustPolicy, api::EnvVar,
resolve_child_concurrency,
};
use derive_more::{Display, Error};
use indexmap::IndexMap;
@@ -77,6 +78,8 @@ pub struct WorkspaceSettings {
pub store_dir: Option<String>,
pub modules_dir: Option<String>,
pub node_linker: Option<NodeLinker>,
pub node_experimental_package_map: Option<bool>,
pub node_package_map_type: Option<NodePackageMapType>,
pub symlink: Option<bool>,
pub virtual_store_dir: Option<String>,
/// `enableGlobalVirtualStore` from `pnpm-workspace.yaml`. Default
@@ -703,7 +706,8 @@ impl WorkspaceSettings {
apply! {
hoist, shamefully_hoist,
node_linker, symlink, package_import_method, modules_cache_max_age,
node_linker, node_experimental_package_map, node_package_map_type,
symlink, package_import_method, modules_cache_max_age,
virtual_store_dir_max_length,
peers_suffix_max_length,
lockfile, prefer_frozen_lockfile, offline, prefer_offline,

View File

@@ -1,7 +1,7 @@
use super::{LoadWorkspaceYamlError, WORKSPACE_MANIFEST_FILENAME, WorkspaceSettings};
use crate::{
CatalogMode, Config, HoistingLimits, LinkWorkspacePackages, NodeLinker, ResolutionMode,
ScriptsPrependNodePath, TrustPolicy, api::EnvVar,
CatalogMode, Config, HoistingLimits, LinkWorkspacePackages, NodeLinker, NodePackageMapType,
ResolutionMode, ScriptsPrependNodePath, TrustPolicy, api::EnvVar,
};
use pacquet_store_dir::StoreDir;
use pacquet_workspace_state::{ConfigDependency, ConfigDependencyDetail};
@@ -19,6 +19,8 @@ autoInstallPeers: true
dedupePeers: true
preferWorkspacePackages: true
nodeLinker: hoisted
nodeExperimentalPackageMap: true
nodePackageMapType: loose
packages:
- packages/*
";
@@ -30,6 +32,8 @@ packages:
assert_eq!(settings.dedupe_peers, Some(true));
assert_eq!(settings.prefer_workspace_packages, Some(true));
assert!(matches!(settings.node_linker, Some(NodeLinker::Hoisted)));
assert_eq!(settings.node_experimental_package_map, Some(true));
assert_eq!(settings.node_package_map_type, Some(NodePackageMapType::Loose));
}
#[test]

View File

@@ -66,6 +66,7 @@ pathdiff = { workspace = true }
pipe-trait = { workspace = true }
rayon = { workspace = true }
reflink-copy = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
ssri = { workspace = true }
tokio = { workspace = true }

View File

@@ -329,6 +329,7 @@ pub struct BuildModules<'a> {
/// [`RunPostinstallHooks::scripts_prepend_node_path`] for each
/// spawned lifecycle script. Default [`ScriptsPrependNodePath::Never`].
pub scripts_prepend_node_path: ScriptsPrependNodePath,
pub extra_env: &'a HashMap<String, String>,
/// Mirrors `config.unsafe_perm`. When `false`, [`pacquet_executor`]
/// runs each lifecycle script under a per-package TMPDIR set to
/// `node_modules/.tmp`; when `true`, TMPDIR is left at the
@@ -437,6 +438,7 @@ impl BuildModules<'_> {
store_index_writer,
patches,
scripts_prepend_node_path,
extra_env,
unsafe_perm,
child_concurrency,
skipped,
@@ -450,8 +452,6 @@ impl BuildModules<'_> {
let Some(snapshots) = snapshots else { return Ok(Vec::new()) };
let extra_env = HashMap::new();
// Compute `requiresBuild` per snapshot. Warm store-index rows
// already carry the upstream worker's answer, so only misses
// need to inspect the materialized package directory.
@@ -586,7 +586,7 @@ impl BuildModules<'_> {
gather_ancestor_bin_paths,
modules_dir,
lockfile_dir,
&extra_env,
extra_env,
scripts_prepend_node_path,
unsafe_perm,
frozen_store,

View File

@@ -338,6 +338,7 @@ fn build_modules_collects_ignored_builds() {
patches: None,
scripts_prepend_node_path: ScriptsPrependNodePath::Never,
extra_env: &HashMap::new(),
unsafe_perm: true,
child_concurrency: 1,
skipped: &SkippedSnapshots::default(),
@@ -402,6 +403,7 @@ fn ignore_scripts_skips_build_without_collecting_ignored() {
patches: None,
scripts_prepend_node_path: ScriptsPrependNodePath::Never,
extra_env: &HashMap::new(),
unsafe_perm: true,
child_concurrency: 1,
skipped: &SkippedSnapshots::default(),
@@ -454,6 +456,7 @@ fn cached_requires_build_false_skips_package_dir_probe() {
patches: None,
scripts_prepend_node_path: ScriptsPrependNodePath::Never,
extra_env: &HashMap::new(),
unsafe_perm: true,
child_concurrency: 1,
skipped: &SkippedSnapshots::default(),
@@ -526,6 +529,7 @@ fn build_modules_collects_ignored_builds_under_concurrency() {
patches: None,
scripts_prepend_node_path: ScriptsPrependNodePath::Never,
extra_env: &HashMap::new(),
unsafe_perm: true,
child_concurrency: 2,
skipped: &SkippedSnapshots::default(),
@@ -591,6 +595,7 @@ fn build_modules_excludes_explicit_deny_from_ignored() {
patches: None,
scripts_prepend_node_path: ScriptsPrependNodePath::Never,
extra_env: &HashMap::new(),
unsafe_perm: true,
child_concurrency: 1,
skipped: &SkippedSnapshots::default(),
@@ -679,6 +684,7 @@ fn do_not_fail_on_optional_dep_with_failing_postinstall() {
patches: None,
scripts_prepend_node_path: ScriptsPrependNodePath::Never,
extra_env: &HashMap::new(),
unsafe_perm: true,
child_concurrency: 1,
skipped: &SkippedSnapshots::default(),
@@ -840,6 +846,7 @@ fn using_side_effects_cache_skips_rebuild() {
patches: None,
scripts_prepend_node_path: ScriptsPrependNodePath::Never,
extra_env: &HashMap::new(),
unsafe_perm: true,
child_concurrency: 1,
skipped: &SkippedSnapshots::default(),
@@ -964,6 +971,7 @@ fn corrupt_side_effects_cache_falls_back_to_rebuild() {
patches: None,
scripts_prepend_node_path: ScriptsPrependNodePath::Never,
extra_env: &HashMap::new(),
unsafe_perm: true,
child_concurrency: 1,
skipped: &SkippedSnapshots::default(),
@@ -1079,6 +1087,7 @@ fn materialization_failure_on_incomplete_slot_is_fatal() {
patches: None,
scripts_prepend_node_path: ScriptsPrependNodePath::Never,
extra_env: &HashMap::new(),
unsafe_perm: true,
child_concurrency: 1,
skipped: &SkippedSnapshots::default(),
@@ -1145,6 +1154,7 @@ fn side_effects_cache_disabled_bypasses_the_gate() {
patches: None,
scripts_prepend_node_path: ScriptsPrependNodePath::Never,
extra_env: &HashMap::new(),
unsafe_perm: true,
child_concurrency: 1,
skipped: &SkippedSnapshots::default(),
@@ -1210,6 +1220,7 @@ fn fail_when_failing_postinstall_is_required() {
patches: None,
scripts_prepend_node_path: ScriptsPrependNodePath::Never,
extra_env: &HashMap::new(),
unsafe_perm: true,
child_concurrency: 1,
skipped: &SkippedSnapshots::default(),
@@ -1297,6 +1308,7 @@ fn frozen_backstop_run(
patches: Some(&patches),
scripts_prepend_node_path: ScriptsPrependNodePath::Never,
extra_env: &HashMap::new(),
unsafe_perm: true,
child_concurrency: 1,
skipped: &SkippedSnapshots::default(),
@@ -1620,6 +1632,7 @@ async fn write_path_populates_side_effects_row() {
patches: None,
scripts_prepend_node_path: ScriptsPrependNodePath::Never,
extra_env: &HashMap::new(),
unsafe_perm: true,
child_concurrency: 1,
skipped: &SkippedSnapshots::default(),
@@ -1739,6 +1752,7 @@ async fn write_path_disabled_skips_upload() {
patches: None,
scripts_prepend_node_path: ScriptsPrependNodePath::Never,
extra_env: &HashMap::new(),
unsafe_perm: true,
child_concurrency: 1,
skipped: &SkippedSnapshots::default(),
@@ -1866,6 +1880,7 @@ async fn upload_error_does_not_interrupt_install() {
patches: None,
scripts_prepend_node_path: ScriptsPrependNodePath::Never,
extra_env: &HashMap::new(),
unsafe_perm: true,
child_concurrency: 1,
skipped: &SkippedSnapshots::default(),
@@ -2101,6 +2116,7 @@ new file mode 100644
patches: Some(&patches),
scripts_prepend_node_path: ScriptsPrependNodePath::Never,
extra_env: &HashMap::new(),
unsafe_perm: true,
child_concurrency: 1,
skipped: &SkippedSnapshots::default(),
@@ -2215,6 +2231,7 @@ new file mode 100644
patches: Some(&patches),
scripts_prepend_node_path: ScriptsPrependNodePath::Never,
extra_env: &HashMap::new(),
unsafe_perm: true,
child_concurrency: 1,
skipped: &SkippedSnapshots::default(),
@@ -2300,6 +2317,7 @@ async fn missing_patch_file_path_errors_with_diagnostic() {
patches: Some(&patches),
scripts_prepend_node_path: ScriptsPrependNodePath::Never,
extra_env: &HashMap::new(),
unsafe_perm: true,
child_concurrency: 1,
skipped: &SkippedSnapshots::default(),

View File

@@ -415,6 +415,13 @@ pub enum InstallError {
#[diagnostic(transparent)]
WriteWorkspaceState(#[error(source)] UpdateWorkspaceStateError),
/// Surfaces a failure to persist `node_modules/.package-map.json`,
/// the package-map metadata Node consumes when the user opts into
/// `--experimental-package-map`.
#[display("Failed to write node_modules/.package-map.json: {_0}")]
#[diagnostic(code(pacquet_package_manager::write_package_map))]
WritePackageMap(#[error(source)] crate::WritePackageMapError),
/// A value in `pnpm.overrides` couldn't be parsed — the selector
/// key isn't a recognizable package name, or the override value
/// uses the `catalog:` protocol (which pacquet doesn't support
@@ -1134,6 +1141,7 @@ where
.as_ref()
.and_then(|lockfile| lockfile.packages.as_ref()),
dependency_groups,
project_manifests: &project_manifests,
logged_methods: &logged_methods,
workspace_root: &workspace_root,
requester: &prefix,
@@ -1450,6 +1458,14 @@ where
)
.map_err(InstallError::WriteModules)?;
let filtered_current_lockfile = if frozen_lockfile {
lockfile.map(|lockfile| {
crate::filter_lockfile_for_current(lockfile, included, &frozen_skipped)
})
} else {
None
};
// Write `<virtual_store_dir>/lock.yaml`. Mirrors upstream's
// `writeCurrentLockfile` call at
// <https://github.com/pnpm/pnpm/blob/94240bc046/installing/deps-installer/src/install/index.ts#L1597>:
@@ -1467,7 +1483,7 @@ where
// <https://github.com/pnpm/pacquet/issues/299>), this needs to narrow to the filtered lockfile
// (selected importers × engine filter) so the saved current
// lockfile reflects only what was actually materialized.
if frozen_lockfile && let Some(lockfile) = lockfile {
if frozen_lockfile && let Some(lockfile) = filtered_current_lockfile.as_ref() {
// Filter the wanted lockfile down to the snapshots that
// were actually materialized: dep maps the user excluded
// (`--no-optional`, `--no-dev`) plus snapshots the
@@ -1479,7 +1495,7 @@ where
// next install diffs against this filtered shape so
// dropped snapshots aren't mistaken for already-done
// work.
crate::filter_lockfile_for_current(lockfile, included, &frozen_skipped)
lockfile
.save_current_to_virtual_store_dir(&config.virtual_store_dir)
.map_err(InstallError::SaveCurrentLockfile)?;
} else if let Some(fresh_lockfile) = fresh_lockfile.as_ref() {
@@ -1531,6 +1547,7 @@ where
run_projects_lifecycle_scripts::<Reporter>(
&project_manifests,
config,
node_linker,
&workspace_root,
)?;
}
@@ -2040,6 +2057,7 @@ fn load_workspace_projects(
fn run_projects_lifecycle_scripts<Reporter: self::Reporter>(
project_manifests: &[(std::path::PathBuf, &PackageManifest)],
config: &Config,
node_linker: NodeLinker,
workspace_root: &Path,
) -> Result<(), InstallError> {
let modules_dir_basename =
@@ -2051,7 +2069,18 @@ fn run_projects_lifecycle_scripts<Reporter: self::Reporter>(
pacquet_config::ScriptsPrependNodePath::Never => ExecScriptsPrependNodePath::Never,
pacquet_config::ScriptsPrependNodePath::WarnOnly => ExecScriptsPrependNodePath::WarnOnly,
};
let extra_env = std::collections::HashMap::new();
let mut extra_env = std::collections::HashMap::new();
if let Some(node_options) = &config.node_options {
extra_env.insert("NODE_OPTIONS".to_string(), node_options.clone());
}
if config.node_experimental_package_map && !matches!(node_linker, NodeLinker::Pnp) {
let package_map_path = config.modules_dir.join(crate::package_map::PACKAGE_MAP_FILENAME);
let node_options = extra_env.get("NODE_OPTIONS").map(String::as_str);
extra_env.insert(
"NODE_OPTIONS".to_string(),
crate::make_node_package_map_option(&package_map_path, node_options),
);
}
for (project_dir, _manifest) in project_manifests {
let root_modules_dir = project_dir.join(modules_dir_basename);
let dep_path = project_dir.to_string_lossy();
@@ -2221,6 +2250,10 @@ fn build_workspace_packages_map(
Some(map)
}
pub(crate) fn should_write_package_map(_config: &Config, node_linker: NodeLinker) -> bool {
node_linker == NodeLinker::Isolated
}
/// Build the `projects` map for [`WorkspaceState`] from the
/// in-memory `(root_dir, manifest)` list the caller already
/// assembled. Mirrors upstream's

View File

@@ -5,9 +5,9 @@
use super::{
Install, InstallError, UpToDateFastPathCheck, install_already_up_to_date,
load_workspace_projects,
load_workspace_projects, should_write_package_map,
};
use pacquet_config::Config;
use pacquet_config::{Config, NodePackageMapType};
use pacquet_lockfile::{Lockfile, MaybeLazyLockfile};
use pacquet_modules_yaml::{
DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH, Host, LayoutVersion, Modules, NodeLinker,
@@ -101,6 +101,17 @@ fn workspace_without_packages_field_enumerates_root_only() {
assert_eq!(names, vec!["root"]);
}
#[test]
fn package_map_writer_is_gated_to_supported_pacquet_mode() {
let mut config = Config::new();
assert!(should_write_package_map(&config, pacquet_config::NodeLinker::Isolated));
assert!(!should_write_package_map(&config, pacquet_config::NodeLinker::Hoisted));
assert!(!should_write_package_map(&config, pacquet_config::NodeLinker::Pnp));
config.node_package_map_type = NodePackageMapType::Loose;
assert!(should_write_package_map(&config, pacquet_config::NodeLinker::Isolated));
}
#[tokio::test]
async fn should_install_dependencies() {
let mock_instance = TestRegistry::start();

View File

@@ -21,7 +21,7 @@ use pacquet_lockfile::{
use pacquet_lockfile_verification::{
VerifyError, VerifyLockfileResolutionsOptions, verify_lockfile_resolutions,
};
use pacquet_modules_yaml::{Host, read_modules_manifest};
use pacquet_modules_yaml::{Host, IncludedDependencies, read_modules_manifest};
use pacquet_network::ThrottledClient;
use pacquet_package_manifest::DependencyGroup;
use pacquet_patching::{
@@ -102,6 +102,7 @@ where
pub current_snapshots: Option<&'a HashMap<PackageKey, SnapshotEntry>>,
pub current_packages: Option<&'a HashMap<PackageKey, PackageMetadata>>,
pub dependency_groups: DependencyGroupList,
pub project_manifests: &'a [(PathBuf, &'a pacquet_package_manifest::PackageManifest)],
/// Install-scoped dedupe state for `pnpm:package-import-method`.
/// See `link_file::log_method_once`.
pub logged_methods: &'a AtomicU8,
@@ -264,6 +265,10 @@ pub enum InstallFrozenLockfileError {
/// bin-link errors.
#[diagnostic(transparent)]
LinkHoistedModules(#[error(source)] LinkHoistedModulesError),
#[display("failed to write package map: {_0}")]
#[diagnostic(code(pacquet_package_manager::write_package_map))]
WritePackageMap(#[error(source)] crate::WritePackageMapError),
}
/// Error type of `run_build_phase` and `resolve_snapshot_patches`.
@@ -382,6 +387,7 @@ pub(crate) struct BuildPhaseInputs<'a> {
pub(crate) side_effects_maps_by_snapshot: &'a crate::SideEffectsMapsBySnapshot,
pub(crate) requires_build_by_snapshot: &'a crate::RequiresBuildBySnapshot,
pub(crate) engine_name: Option<&'a str>,
pub(crate) extra_env: &'a HashMap<String, String>,
pub(crate) store_index_writer: &'a Arc<StoreIndexWriter>,
pub(crate) skipped: &'a SkippedSnapshots,
pub(crate) hoisted_pkg_root_by_key: Option<&'a HashMap<PackageKey, PathBuf>>,
@@ -421,6 +427,7 @@ pub(crate) fn run_build_phase<Reporter: self::Reporter>(
side_effects_maps_by_snapshot,
requires_build_by_snapshot,
engine_name,
extra_env,
store_index_writer,
skipped,
hoisted_pkg_root_by_key,
@@ -462,6 +469,7 @@ pub(crate) fn run_build_phase<Reporter: self::Reporter>(
store_index_writer: Some(store_index_writer),
patches: patches.as_ref(),
scripts_prepend_node_path,
extra_env,
unsafe_perm: config.unsafe_perm,
child_concurrency: config.child_concurrency,
skipped,
@@ -559,6 +567,7 @@ where
current_snapshots,
current_packages,
dependency_groups,
project_manifests,
logged_methods,
workspace_root,
requester,
@@ -1085,6 +1094,7 @@ where
layout: &layout,
importers,
dependency_groups: &dependency_groups,
project_manifests,
walker_lockfile_dir: workspace_root,
symlink_workspace_root: workspace_root,
host_node: host_node.as_ref(),
@@ -1190,6 +1200,27 @@ where
BTreeMap::new()
};
if crate::should_write_package_map(config, node_linker) {
let included = IncludedDependencies {
dependencies: dependency_groups.contains(&DependencyGroup::Prod),
dev_dependencies: dependency_groups.contains(&DependencyGroup::Dev),
optional_dependencies: dependency_groups.contains(&DependencyGroup::Optional),
};
let filtered_lockfile =
crate::filter_lockfile_for_current(lockfile, included, &skipped);
crate::package_map::write_package_map(
&filtered_lockfile,
&crate::package_map::PackageMapOptions {
lockfile_dir: workspace_root,
modules_dir: &config.modules_dir,
package_map_type: config.node_package_map_type,
layout: &layout,
project_manifests,
},
)
.map_err(InstallFrozenLockfileError::WritePackageMap)?;
}
// Mirrors upstream `link.ts:167-170`: `importing_done` fires once
// extraction and symlink linking are complete, before any build
// phase. Reporters use it to close the import progress display so
@@ -1212,6 +1243,20 @@ where
None => engine_name,
};
let mut build_extra_env = HashMap::new();
if let Some(node_options) = &config.node_options {
build_extra_env.insert("NODE_OPTIONS".to_string(), node_options.clone());
}
if config.node_experimental_package_map && !matches!(node_linker, NodeLinker::Pnp) {
let package_map_path =
config.modules_dir.join(crate::package_map::PACKAGE_MAP_FILENAME);
let node_options = build_extra_env.get("NODE_OPTIONS").map(String::as_str);
build_extra_env.insert(
"NODE_OPTIONS".to_string(),
crate::make_node_package_map_option(&package_map_path, node_options),
);
}
// Run lifecycle scripts, report ignored builds, and re-link
// top-level bins. `workspace_root` is upstream's `lockfileDir`;
// pass the real `Path` rather than reconstructing it from the
@@ -1234,6 +1279,7 @@ where
side_effects_maps_by_snapshot: &side_effects_maps_by_snapshot,
requires_build_by_snapshot: &requires_build_by_snapshot,
engine_name: engine_name.as_deref(),
extra_env: &build_extra_env,
store_index_writer: &store_index_writer,
skipped: &skipped,
hoisted_pkg_root_by_key: hoisted_pkg_root_by_key.as_ref(),
@@ -1343,6 +1389,7 @@ pub(crate) struct HoistedLinkerInputs<'a> {
pub(crate) layout: &'a VirtualStoreLayout,
pub(crate) importers: &'a HashMap<String, ProjectSnapshot>,
pub(crate) dependency_groups: &'a [DependencyGroup],
pub(crate) project_manifests: &'a [(PathBuf, &'a pacquet_package_manifest::PackageManifest)],
/// Lockfile root the walker resolves hoisted directories against.
pub(crate) walker_lockfile_dir: &'a Path,
/// Anchor for [`crate::SymlinkDirectDependencies`]'s per-importer
@@ -1376,6 +1423,9 @@ pub(crate) enum HoistedLinkerError {
LinkHoistedModules(#[error(source)] LinkHoistedModulesError),
#[diagnostic(transparent)]
SymlinkDirectDependencies(#[error(source)] SymlinkDirectDependenciesError),
#[display("failed to write package map: {_0}")]
#[diagnostic(code(pacquet_package_manager::write_package_map))]
WritePackageMap(#[error(source)] crate::WritePackageMapError),
}
impl From<HoistedLinkerError> for InstallFrozenLockfileError {
@@ -1390,6 +1440,9 @@ impl From<HoistedLinkerError> for InstallFrozenLockfileError {
HoistedLinkerError::SymlinkDirectDependencies(error) => {
InstallFrozenLockfileError::SymlinkDirectDependencies(error)
}
HoistedLinkerError::WritePackageMap(error) => {
InstallFrozenLockfileError::WritePackageMap(error)
}
}
}
}
@@ -1419,6 +1472,7 @@ pub(crate) fn run_hoisted_linker<Reporter: self::Reporter>(
layout,
importers,
dependency_groups,
project_manifests,
walker_lockfile_dir,
symlink_workspace_root,
host_node,
@@ -1489,6 +1543,17 @@ pub(crate) fn run_hoisted_linker<Reporter: self::Reporter>(
requester,
};
link_hoisted_modules::<Reporter>(&link_opts).map_err(HoistedLinkerError::LinkHoistedModules)?;
crate::package_map::write_hoisted_package_map(
lockfile,
&walker_result,
&crate::package_map::HoistedPackageMapOptions {
lockfile_dir: walker_lockfile_dir,
modules_dir: &config.modules_dir,
package_map_type: config.node_package_map_type,
project_manifests,
},
)
.map_err(HoistedLinkerError::WritePackageMap)?;
// Workspace `link:` deps still need symlinks under each importer's
// `node_modules/<alias>` even though the regular deps now live as
// real directories. The hoisted dep-graph walker skips

View File

@@ -33,6 +33,8 @@ fn create_config(store_dir: &Path, modules_dir: &Path, virtual_store_dir: &Path)
store_dir: StoreDir::new(store_dir),
modules_dir: modules_dir.to_path_buf(),
node_linker: Default::default(),
node_experimental_package_map: false,
node_package_map_type: Default::default(),
symlink: false,
virtual_store_dir: virtual_store_dir.to_path_buf(),
enable_global_virtual_store: false,

View File

@@ -239,6 +239,10 @@ pub enum InstallWithFreshLockfileError {
#[diagnostic(transparent)]
LinkHoistedModules(#[error(source)] crate::LinkHoistedModulesError),
#[display("failed to write package map: {_0}")]
#[diagnostic(code(pacquet_package_manager::write_package_map))]
WritePackageMap(#[error(source)] crate::WritePackageMapError),
#[diagnostic(transparent)]
LinkBins(#[error(source)] LinkBinsError),
@@ -395,6 +399,9 @@ impl From<crate::install_frozen_lockfile::HoistedLinkerError> for InstallWithFre
HoistedLinkerError::SymlinkDirectDependencies(error) => {
InstallWithFreshLockfileError::SymlinkDirectDependencies(error)
}
HoistedLinkerError::WritePackageMap(error) => {
InstallWithFreshLockfileError::WritePackageMap(error)
}
}
}
}
@@ -1606,6 +1613,10 @@ impl<DependencyGroupList> InstallWithFreshLockfile<'_, DependencyGroupList> {
// adapter shape); `hoisted_locations` carries the walker's
// placements so `.modules.yaml` round-trips them.
let (hoisted_dependencies, hoisted_locations) = if is_hoisted {
let project_manifests = importer_manifests
.iter()
.map(|(id, manifest)| (lockfile_dir.join(id), *manifest))
.collect::<Vec<_>>();
// Reuse the host probed once above for the engine-name key, so
// a hoisted install with installability constraints spawns
// `node --version` only once. `None` when nothing constrained
@@ -1622,6 +1633,7 @@ impl<DependencyGroupList> InstallWithFreshLockfile<'_, DependencyGroupList> {
layout: &layout,
importers: &built_lockfile.importers,
dependency_groups: &dependency_groups,
project_manifests: &project_manifests,
walker_lockfile_dir: lockfile_dir,
symlink_workspace_root: symlink_root,
host_node: host_node.as_ref(),
@@ -1791,6 +1803,45 @@ impl<DependencyGroupList> InstallWithFreshLockfile<'_, DependencyGroupList> {
None => engine_name,
};
// Write `node_modules/.package-map.json` before the build phase, since
// `build_extra_env` below points lifecycle scripts' `NODE_OPTIONS` at
// it. `layout` already resolves each snapshot to its real on-disk slot
// (flat or global-virtual-store). Reached only after materialization,
// mirroring the frozen path's write in `InstallFrozenLockfile::run`, so
// the `lockfile_only` early return never writes a map for an unlinked
// tree.
if crate::should_write_package_map(config, node_linker) {
let project_manifests = importer_manifests
.iter()
.map(|(id, manifest)| (lockfile_dir.join(id), *manifest))
.collect::<Vec<_>>();
crate::package_map::write_package_map(
&built_lockfile,
&crate::package_map::PackageMapOptions {
lockfile_dir,
modules_dir: &config.modules_dir,
package_map_type: config.node_package_map_type,
layout: &layout,
project_manifests: &project_manifests,
},
)
.map_err(InstallWithFreshLockfileError::WritePackageMap)?;
}
let mut build_extra_env = HashMap::new();
if let Some(node_options) = &config.node_options {
build_extra_env.insert("NODE_OPTIONS".to_string(), node_options.clone());
}
if config.node_experimental_package_map && !matches!(node_linker, NodeLinker::Pnp) {
let package_map_path =
config.modules_dir.join(crate::package_map::PACKAGE_MAP_FILENAME);
let node_options = build_extra_env.get("NODE_OPTIONS").map(String::as_str);
build_extra_env.insert(
"NODE_OPTIONS".to_string(),
crate::make_node_package_map_option(&package_map_path, node_options),
);
}
// Run lifecycle scripts, report ignored builds, and re-link
// top-level bins — the same build phase the frozen path runs, so
// `pacquet add esbuild` reports the blocked `esbuild` build (and
@@ -1815,6 +1866,7 @@ impl<DependencyGroupList> InstallWithFreshLockfile<'_, DependencyGroupList> {
side_effects_maps_by_snapshot: &side_effects_maps_by_snapshot,
requires_build_by_snapshot: &requires_build_by_snapshot,
engine_name: engine_name.as_deref(),
extra_env: &build_extra_env,
store_index_writer: &store_index_writer,
skipped: &skipped,
hoisted_pkg_root_by_key: hoisted_pkg_root_by_key.as_ref(),

View File

@@ -30,6 +30,7 @@ mod link_hoisted_modules;
mod optimistic_repeat_install;
mod overrides;
mod package_extender;
mod package_map;
mod prefetching_resolver;
mod prune_virtual_store;
mod remove;
@@ -75,6 +76,9 @@ pub use link_hoisted_modules::*;
pub use optimistic_repeat_install::*;
pub use overrides::*;
pub use package_extender::*;
pub use package_map::{
WritePackageMapError, make_node_package_map_option, package_map_path_for_execution,
};
pub use prefetching_resolver::*;
pub use remove::*;
pub use resolution_observer::*;

View File

@@ -0,0 +1,857 @@
use crate::LockfileToDepGraphResult;
use pacquet_config::{Config, NodePackageMapType};
use pacquet_fs::lexical_normalize;
use pacquet_lockfile::{Lockfile, PackageKey, ProjectSnapshot, SnapshotDepRef};
use pacquet_package_manifest::PackageManifest;
use serde::Serialize;
use std::{
collections::{BTreeMap, HashMap},
fmt::Write as _,
path::{Path, PathBuf},
};
pub(crate) const PACKAGE_MAP_FILENAME: &str = ".package-map.json";
#[derive(Debug, PartialEq, Eq, Serialize)]
pub(crate) struct PackageMap {
packages: BTreeMap<String, PackageMapPackage>,
}
#[derive(Debug, PartialEq, Eq, Serialize)]
pub(crate) struct PackageMapPackage {
url: String,
dependencies: BTreeMap<String, String>,
}
#[derive(Debug, derive_more::Display, derive_more::Error)]
pub enum WritePackageMapError {
#[display("failed to create package-map directory: {_0}")]
CreateDir(#[error(source)] std::io::Error),
#[display("failed to serialize package map: {_0}")]
Serialize(#[error(source)] serde_json::Error),
#[display("failed to write package map: {_0}")]
Write(#[error(source)] pacquet_fs::EnsureFileError),
}
pub(crate) struct PackageMapOptions<'a> {
pub lockfile_dir: &'a Path,
pub modules_dir: &'a Path,
pub package_map_type: NodePackageMapType,
/// Resolves each snapshot to its real on-disk slot, so the map stays
/// correct under both the legacy flat layout and the content-hashed
/// global virtual store.
pub layout: &'a crate::VirtualStoreLayout,
pub project_manifests: &'a [(PathBuf, &'a PackageManifest)],
}
pub(crate) struct HoistedPackageMapOptions<'a> {
pub lockfile_dir: &'a Path,
pub modules_dir: &'a Path,
pub package_map_type: NodePackageMapType,
pub project_manifests: &'a [(PathBuf, &'a PackageManifest)],
}
pub(crate) fn write_package_map(
lockfile: &Lockfile,
opts: &PackageMapOptions<'_>,
) -> Result<(), WritePackageMapError> {
std::fs::create_dir_all(opts.modules_dir).map_err(WritePackageMapError::CreateDir)?;
let mut contents = serde_json::to_vec(&lockfile_to_package_map(lockfile, opts))
.map_err(WritePackageMapError::Serialize)?;
contents.push(b'\n');
// Hardened atomic write (temp file + rename): never follows a symlink an
// attacker (or a crashed prior install) may have pre-seeded at the target,
// and never leaves a torn file a concurrent reader could observe.
pacquet_fs::ensure_file(&opts.modules_dir.join(PACKAGE_MAP_FILENAME), &contents, None)
.map_err(WritePackageMapError::Write)
}
pub(crate) fn write_hoisted_package_map(
lockfile: &Lockfile,
graph: &LockfileToDepGraphResult,
opts: &HoistedPackageMapOptions<'_>,
) -> Result<(), WritePackageMapError> {
std::fs::create_dir_all(opts.modules_dir).map_err(WritePackageMapError::CreateDir)?;
let mut contents =
serde_json::to_vec(&dependencies_graph_to_package_map(lockfile, graph, opts))
.map_err(WritePackageMapError::Serialize)?;
contents.push(b'\n');
// Hardened atomic write (temp file + rename): never follows a symlink an
// attacker (or a crashed prior install) may have pre-seeded at the target,
// and never leaves a torn file a concurrent reader could observe.
pacquet_fs::ensure_file(&opts.modules_dir.join(PACKAGE_MAP_FILENAME), &contents, None)
.map_err(WritePackageMapError::Write)
}
pub(crate) fn lockfile_to_package_map(
lockfile: &Lockfile,
opts: &PackageMapOptions<'_>,
) -> PackageMap {
let is_loose = opts.package_map_type == NodePackageMapType::Loose;
let mut packages = BTreeMap::new();
let mut loose_index = is_loose.then(PhysicalPackageIndex::default);
let mut package_dirs = is_loose.then(BTreeMap::new);
let importer_names = importer_names(opts.lockfile_dir, opts.project_manifests);
for (importer_id, importer) in &lockfile.importers {
let mut dependencies = BTreeMap::new();
if let Some(Some(name)) = importer_names.get(importer_id) {
dependencies.insert(name.clone(), importer_id.clone());
}
add_importer_dependencies(
&mut packages,
&mut dependencies,
lockfile,
opts,
importer_id,
importer,
);
let importer_dir = lexical_normalize(&opts.lockfile_dir.join(importer_id));
add_package(
&mut packages,
importer_id.clone(),
&mut package_dirs,
&importer_dir,
dependencies,
opts.modules_dir,
);
if let Some(loose_index) = loose_index.as_mut() {
let importer_modules_dir =
lexical_normalize(&opts.lockfile_dir.join(importer_id).join("node_modules"));
add_physical_importer_dependencies(
loose_index,
&mut packages,
lockfile,
opts,
&importer_modules_dir,
importer.dependencies.as_ref(),
Some(importer_id),
);
add_physical_importer_dependencies(
loose_index,
&mut packages,
lockfile,
opts,
&importer_modules_dir,
importer.optional_dependencies.as_ref(),
Some(importer_id),
);
add_physical_importer_dependencies(
loose_index,
&mut packages,
lockfile,
opts,
&importer_modules_dir,
importer.dev_dependencies.as_ref(),
Some(importer_id),
);
}
}
if let Some(snapshots) = lockfile.snapshots.as_ref() {
for (key, snapshot) in snapshots {
let id = key.to_string();
let mut dependencies = BTreeMap::new();
dependencies.insert(key.name.to_string(), id.clone());
add_snapshot_dependencies(
&mut packages,
&mut dependencies,
lockfile,
opts,
snapshot.dependencies.as_ref(),
);
add_snapshot_dependencies(
&mut packages,
&mut dependencies,
lockfile,
opts,
snapshot.optional_dependencies.as_ref(),
);
add_package(
&mut packages,
id,
&mut package_dirs,
&opts.layout.slot_dir(key).join("node_modules").join(key.name.to_string()),
dependencies,
opts.modules_dir,
);
if let Some(loose_index) = loose_index.as_mut() {
let package_dir =
opts.layout.slot_dir(key).join("node_modules").join(key.name.to_string());
if let Some(modules_dir) = get_node_modules_path(&package_dir) {
loose_index.add(&modules_dir, key.name.to_string(), key.to_string());
}
let package_modules_dir = package_dir.join("node_modules");
add_physical_snapshot_dependencies(
loose_index,
&mut packages,
lockfile,
opts,
&package_modules_dir,
snapshot.dependencies.as_ref(),
);
add_physical_snapshot_dependencies(
loose_index,
&mut packages,
lockfile,
opts,
&package_modules_dir,
snapshot.optional_dependencies.as_ref(),
);
}
}
}
if let Some(metadata) = lockfile.packages.as_ref() {
for key in metadata.keys() {
let id = key.to_string();
packages.entry(id.clone()).or_insert_with(|| {
let mut dependencies = BTreeMap::new();
dependencies.insert(key.name.to_string(), id.clone());
PackageMapPackage {
url: to_relative_url(
opts.modules_dir,
&opts.layout.slot_dir(key).join("node_modules").join(key.name.to_string()),
),
dependencies,
}
});
if let Some(package_dirs) = package_dirs.as_mut() {
package_dirs.entry(id).or_insert_with(|| {
opts.layout.slot_dir(key).join("node_modules").join(key.name.to_string())
});
}
}
}
add_loose_dependencies(&mut packages, package_dirs.as_ref(), loose_index.as_ref());
PackageMap { packages }
}
pub(crate) fn dependencies_graph_to_package_map(
lockfile: &Lockfile,
graph: &LockfileToDepGraphResult,
opts: &HoistedPackageMapOptions<'_>,
) -> PackageMap {
let is_loose = opts.package_map_type == NodePackageMapType::Loose;
let mut packages = BTreeMap::new();
let mut package_ids_by_graph_key = BTreeMap::new();
let mut package_ids_by_dep_path = BTreeMap::new();
let mut package_dirs = is_loose.then(BTreeMap::new);
let mut loose_index = is_loose.then(PhysicalPackageIndex::default);
let importer_names = importer_names(opts.lockfile_dir, opts.project_manifests);
for (graph_key, node) in &graph.graph {
let id = graph_package_id(&node.dir, opts.modules_dir);
package_ids_by_graph_key.insert(graph_key.clone(), id.clone());
package_ids_by_dep_path
.entry(node.dep_path.as_str().to_string())
.or_insert_with(|| id.clone());
if let Some(loose_index) = loose_index.as_mut()
&& let Some(modules_dir) = get_node_modules_path(&node.dir)
{
loose_index.add(&modules_dir, node.name.clone(), id);
}
}
for (importer_id, importer) in &lockfile.importers {
let importer_dir = lexical_normalize(&opts.lockfile_dir.join(importer_id));
let importer_id_for_map = graph_package_id(&importer_dir, opts.modules_dir);
let mut dependencies = BTreeMap::new();
if let Some(Some(name)) = importer_names.get(importer_id) {
dependencies.insert(name.clone(), importer_id_for_map.clone());
}
add_hoisted_importer_dependencies(
&mut dependencies,
importer.dependencies.as_ref(),
&package_ids_by_dep_path,
);
add_hoisted_importer_dependencies(
&mut dependencies,
importer.optional_dependencies.as_ref(),
&package_ids_by_dep_path,
);
add_hoisted_importer_dependencies(
&mut dependencies,
importer.dev_dependencies.as_ref(),
&package_ids_by_dep_path,
);
let importer_modules_dir = is_loose.then(|| importer_dir.join("node_modules"));
add_hoisted_linked_dependencies(
&mut packages,
&mut dependencies,
&mut loose_index,
opts,
importer.dependencies.as_ref(),
Some(importer_id),
importer_modules_dir.as_deref(),
);
add_hoisted_linked_dependencies(
&mut packages,
&mut dependencies,
&mut loose_index,
opts,
importer.optional_dependencies.as_ref(),
Some(importer_id),
importer_modules_dir.as_deref(),
);
add_hoisted_linked_dependencies(
&mut packages,
&mut dependencies,
&mut loose_index,
opts,
importer.dev_dependencies.as_ref(),
Some(importer_id),
importer_modules_dir.as_deref(),
);
add_package(
&mut packages,
importer_id_for_map,
&mut package_dirs,
&importer_dir,
dependencies,
opts.modules_dir,
);
}
for (graph_key, node) in &graph.graph {
let id = package_ids_by_graph_key[graph_key].clone();
let mut dependencies = BTreeMap::from([(node.name.clone(), id.clone())]);
add_hoisted_graph_dependencies(
&mut dependencies,
&node.children,
&package_ids_by_graph_key,
);
if let Some(snapshot) = lockfile.snapshots.as_ref().and_then(|snapshots| {
node.dep_path.as_str().parse::<PackageKey>().ok().and_then(|key| snapshots.get(&key))
}) {
let package_modules_dir = is_loose.then(|| node.dir.join("node_modules"));
add_hoisted_linked_dependencies(
&mut packages,
&mut dependencies,
&mut loose_index,
opts,
snapshot.dependencies.as_ref(),
None,
package_modules_dir.as_deref(),
);
add_hoisted_linked_dependencies(
&mut packages,
&mut dependencies,
&mut loose_index,
opts,
snapshot.optional_dependencies.as_ref(),
None,
package_modules_dir.as_deref(),
);
}
add_package(
&mut packages,
id,
&mut package_dirs,
&node.dir,
dependencies,
opts.modules_dir,
);
}
add_loose_dependencies(&mut packages, package_dirs.as_ref(), loose_index.as_ref());
PackageMap { packages }
}
pub fn make_node_package_map_option(package_map_path: &Path, node_options: Option<&str>) -> String {
let node_options =
node_options.map(str::to_string).or_else(|| std::env::var("NODE_OPTIONS").ok());
let mut parts = remove_node_package_map_option(node_options.as_deref().unwrap_or_default());
parts.push(format!(
"--experimental-package-map={}",
quote_path_if_needed(&package_map_path.to_string_lossy()),
));
parts.join(" ")
}
pub fn package_map_path_for_execution(config: &Config, dir: &Path) -> Option<PathBuf> {
if !config.node_experimental_package_map {
return None;
}
// Installs write the map under the configured modules dir, so detect it
// by that dir's basename rather than the hard-coded `node_modules`.
let modules_dir_name =
config.modules_dir.file_name().unwrap_or_else(|| std::ffi::OsStr::new("node_modules"));
let workspace_path = config
.workspace_dir
.as_ref()
.map(|dir| dir.join(modules_dir_name).join(PACKAGE_MAP_FILENAME));
if let Some(path) = workspace_path
&& path.exists()
{
return Some(path);
}
let path = dir.join(modules_dir_name).join(PACKAGE_MAP_FILENAME);
path.exists().then_some(path)
}
fn add_importer_dependencies(
packages: &mut BTreeMap<String, PackageMapPackage>,
dependencies: &mut BTreeMap<String, String>,
lockfile: &Lockfile,
opts: &PackageMapOptions<'_>,
importer_id: &str,
importer: &ProjectSnapshot,
) {
for deps in [
importer.dependencies.as_ref(),
importer.optional_dependencies.as_ref(),
importer.dev_dependencies.as_ref(),
]
.into_iter()
.flatten()
{
for (alias, spec) in deps {
if let Some(target) = spec.version.as_link_target() {
let target = resolve_link_target(opts.lockfile_dir, Some(importer_id), target);
add_external_link_package(packages, &target, opts.modules_dir);
dependencies.insert(alias.to_string(), target.id);
continue;
}
if let Some(key) = spec.version.resolved_key(alias)
&& has_package_entry(lockfile, &key)
{
dependencies.insert(alias.to_string(), key.to_string());
}
}
}
}
fn add_physical_importer_dependencies(
loose_index: &mut PhysicalPackageIndex,
packages: &mut BTreeMap<String, PackageMapPackage>,
lockfile: &Lockfile,
opts: &PackageMapOptions<'_>,
modules_dir: &Path,
deps: Option<&pacquet_lockfile::ResolvedDependencyMap>,
importer_id: Option<&str>,
) {
let Some(deps) = deps else { return };
for (alias, spec) in deps {
if let Some(target) = spec.version.as_link_target() {
let target = resolve_link_target(opts.lockfile_dir, importer_id, target);
add_external_link_package(packages, &target, opts.modules_dir);
loose_index.add(modules_dir, alias.to_string(), target.id);
continue;
}
if let Some(key) = spec.version.resolved_key(alias)
&& has_package_entry(lockfile, &key)
{
loose_index.add(modules_dir, alias.to_string(), key.to_string());
}
}
}
fn add_snapshot_dependencies(
packages: &mut BTreeMap<String, PackageMapPackage>,
dependencies: &mut BTreeMap<String, String>,
lockfile: &Lockfile,
opts: &PackageMapOptions<'_>,
deps: Option<&HashMap<pacquet_lockfile::PkgName, SnapshotDepRef>>,
) {
let Some(deps) = deps else { return };
for (alias, reference) in deps {
if let Some(target) = reference.as_link_target() {
let target = resolve_link_target(opts.lockfile_dir, None, target);
add_external_link_package(packages, &target, opts.modules_dir);
dependencies.insert(alias.to_string(), target.id);
continue;
}
if let Some(key) = reference.resolve(alias)
&& has_package_entry(lockfile, &key)
{
dependencies.insert(alias.to_string(), key.to_string());
}
}
}
fn add_physical_snapshot_dependencies(
loose_index: &mut PhysicalPackageIndex,
packages: &mut BTreeMap<String, PackageMapPackage>,
lockfile: &Lockfile,
opts: &PackageMapOptions<'_>,
modules_dir: &Path,
deps: Option<&HashMap<pacquet_lockfile::PkgName, SnapshotDepRef>>,
) {
let Some(deps) = deps else { return };
for (alias, reference) in deps {
if let Some(target) = reference.as_link_target() {
let target = resolve_link_target(opts.lockfile_dir, None, target);
add_external_link_package(packages, &target, opts.modules_dir);
loose_index.add(modules_dir, alias.to_string(), target.id);
continue;
}
if let Some(key) = reference.resolve(alias)
&& has_package_entry(lockfile, &key)
{
loose_index.add(modules_dir, alias.to_string(), key.to_string());
}
}
}
fn add_hoisted_importer_dependencies(
dependencies: &mut BTreeMap<String, String>,
deps: Option<&pacquet_lockfile::ResolvedDependencyMap>,
package_ids_by_dep_path: &BTreeMap<String, String>,
) {
let Some(deps) = deps else { return };
for (alias, spec) in deps {
if spec.version.as_link_target().is_some() {
continue;
}
if let Some(key) = spec.version.resolved_key(alias)
&& let Some(id) = package_ids_by_dep_path.get(&key.to_string())
{
dependencies.insert(alias.to_string(), id.clone());
}
}
}
fn add_hoisted_graph_dependencies(
dependencies: &mut BTreeMap<String, String>,
deps: &BTreeMap<String, PathBuf>,
package_ids_by_graph_key: &BTreeMap<PathBuf, String>,
) {
for (alias, graph_key) in deps {
if let Some(id) = package_ids_by_graph_key.get(graph_key) {
dependencies.insert(alias.clone(), id.clone());
}
}
}
fn add_hoisted_linked_dependencies<Reference>(
packages: &mut BTreeMap<String, PackageMapPackage>,
dependencies: &mut BTreeMap<String, String>,
loose_index: &mut Option<PhysicalPackageIndex>,
opts: &HoistedPackageMapOptions<'_>,
deps: Option<&HashMap<pacquet_lockfile::PkgName, Reference>>,
importer_id: Option<&str>,
modules_dir: Option<&Path>,
) where
Reference: LinkReference,
{
let Some(deps) = deps else { return };
for (alias, reference) in deps {
let Some(target_ref) = reference.as_link_target() else { continue };
let target = resolve_link_target(opts.lockfile_dir, importer_id, target_ref);
let id = graph_package_id(&target.dir, opts.modules_dir);
add_external_link_package(
packages,
&LinkTarget { id: id.clone(), dir: target.dir.clone() },
opts.modules_dir,
);
dependencies.insert(alias.to_string(), id.clone());
if let (Some(loose_index), Some(modules_dir)) = (loose_index.as_mut(), modules_dir) {
loose_index.add(modules_dir, alias.to_string(), id);
}
}
}
trait LinkReference {
fn as_link_target(&self) -> Option<&'_ str>;
}
impl LinkReference for pacquet_lockfile::ResolvedDependencySpec {
fn as_link_target(&self) -> Option<&'_ str> {
self.version.as_link_target()
}
}
impl LinkReference for SnapshotDepRef {
fn as_link_target(&self) -> Option<&'_ str> {
SnapshotDepRef::as_link_target(self)
}
}
fn has_package_entry(lockfile: &Lockfile, key: &PackageKey) -> bool {
lockfile.snapshots.as_ref().is_some_and(|snapshots| snapshots.contains_key(key))
|| lockfile.packages.as_ref().is_some_and(|packages| packages.contains_key(key))
}
fn add_package(
packages: &mut BTreeMap<String, PackageMapPackage>,
id: String,
package_dirs: &mut Option<BTreeMap<String, PathBuf>>,
package_dir: &Path,
dependencies: BTreeMap<String, String>,
modules_dir: &Path,
) {
if let Some(package_dirs) = package_dirs {
package_dirs.insert(id.clone(), package_dir.to_path_buf());
}
packages.insert(
id,
PackageMapPackage { url: to_relative_url(modules_dir, package_dir), dependencies },
);
}
fn add_external_link_package(
packages: &mut BTreeMap<String, PackageMapPackage>,
target: &LinkTarget,
modules_dir: &Path,
) {
packages.entry(target.id.clone()).or_insert_with(|| PackageMapPackage {
url: to_relative_url(modules_dir, &target.dir),
dependencies: BTreeMap::new(),
});
}
#[derive(Debug, Default)]
struct PhysicalPackageIndex {
by_modules_dir: BTreeMap<String, BTreeMap<String, String>>,
}
impl PhysicalPackageIndex {
fn add(&mut self, modules_dir: &Path, package_name: String, package_id: String) {
self.by_modules_dir
.entry(normalize_path(&lexical_normalize(modules_dir)))
.or_default()
.insert(package_name, package_id);
}
}
fn add_loose_dependencies(
packages: &mut BTreeMap<String, PackageMapPackage>,
package_dirs: Option<&BTreeMap<String, PathBuf>>,
loose_index: Option<&PhysicalPackageIndex>,
) {
let (Some(package_dirs), Some(loose_index)) = (package_dirs, loose_index) else { return };
for (id, package_dir) in package_dirs {
let physical = physical_dependencies(package_dir, loose_index);
if let Some(pkg) = packages.get_mut(id) {
for (alias, dep_id) in physical {
pkg.dependencies.insert(alias, dep_id);
}
}
}
}
fn physical_dependencies(
package_dir: &Path,
loose_index: &PhysicalPackageIndex,
) -> BTreeMap<String, String> {
let mut dependencies = BTreeMap::new();
let mut current = package_dir.to_path_buf();
loop {
let modules_dir = normalize_path(&current.join("node_modules"));
if let Some(locations) = loose_index.by_modules_dir.get(&modules_dir) {
for (name, id) in locations {
dependencies.entry(name.clone()).or_insert_with(|| id.clone());
}
}
if !current.pop() {
break;
}
}
dependencies
}
fn get_node_modules_path(package_location: &Path) -> Option<PathBuf> {
let mut result = PathBuf::new();
let mut last_node_modules = None;
for component in package_location.components() {
result.push(component.as_os_str());
if component.as_os_str() == "node_modules" {
last_node_modules = Some(result.clone());
}
}
last_node_modules
}
struct LinkTarget {
id: String,
dir: PathBuf,
}
fn resolve_link_target(lockfile_dir: &Path, importer_id: Option<&str>, target: &str) -> LinkTarget {
let importer_dir =
importer_id.map_or_else(|| lockfile_dir.to_path_buf(), |id| lockfile_dir.join(id));
let dir = if Path::new(target).is_absolute() {
PathBuf::from(target)
} else {
importer_dir.join(target)
};
let dir = lexical_normalize(&dir);
let id = link_target_id(pathdiff::diff_paths(&dir, lockfile_dir), &dir);
LinkTarget { id, dir }
}
fn importer_names(
lockfile_dir: &Path,
project_manifests: &[(PathBuf, &PackageManifest)],
) -> BTreeMap<String, Option<String>> {
project_manifests
.iter()
.map(|(project_dir, manifest)| {
let relative = pathdiff::diff_paths(project_dir, lockfile_dir)
.unwrap_or_else(|| project_dir.clone());
let id = normalize_path(&relative);
let id = if id.is_empty() { ".".to_string() } else { id };
(id, manifest_string_field(manifest, "name"))
})
.collect()
}
fn manifest_string_field(manifest: &PackageManifest, key: &str) -> Option<String> {
manifest.value().get(key).and_then(|v| v.as_str()).map(ToString::to_string)
}
fn to_relative_url(from: &Path, to: &Path) -> String {
let Some(relative) = pathdiff::diff_paths(to, from) else {
return absolute_package_url(to);
};
let relative = normalize_path(&relative);
let relative = if relative.is_empty() { ".".to_string() } else { relative };
if relative == "."
|| relative == ".."
|| relative.starts_with("./")
|| relative.starts_with("../")
{
relative
} else {
format!("./{relative}")
}
}
fn link_target_id(relative: Option<PathBuf>, dir: &Path) -> String {
let Some(relative) = relative else {
return format!("link:{}", normalize_path(dir));
};
let relative_id = normalize_path(&relative);
if relative_id == ".." || relative_id.starts_with("../") {
format!("link:{}", normalize_path(dir))
} else if relative_id.is_empty() {
".".to_string()
} else {
relative_id
}
}
fn graph_package_id(package_dir: &Path, modules_dir: &Path) -> String {
let package_dir = lexical_normalize(package_dir);
let Some(relative) = pathdiff::diff_paths(&package_dir, modules_dir) else {
return format!("link:{}", normalize_path(&package_dir));
};
let relative = normalize_path(&relative);
if relative == ".." || relative.is_empty() { ".".to_string() } else { relative }
}
fn remove_node_package_map_option(node_options: &str) -> Vec<String> {
let tokens = split_node_options(node_options);
let mut retained = Vec::new();
let mut skip_next = false;
for token in tokens {
if skip_next {
skip_next = false;
continue;
}
if token == "--experimental-package-map" {
skip_next = true;
continue;
}
if token.starts_with("--experimental-package-map=") {
continue;
}
retained.push(token);
}
retained
}
fn split_node_options(node_options: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut token = String::new();
let mut quote: Option<char> = None;
let mut escaped = false;
for ch in node_options.chars() {
// `\` escapes the next character anywhere, matching Node's
// NODE_OPTIONS tokenizer, so an escaped quote does not end a token.
// The literal text (backslash included) is preserved so retained
// tokens round-trip verbatim.
if escaped {
token.push(ch);
escaped = false;
continue;
}
if ch == '\\' {
token.push(ch);
escaped = true;
continue;
}
if let Some(q) = quote {
token.push(ch);
if ch == q {
quote = None;
}
continue;
}
if ch == '"' || ch == '\'' {
quote = Some(ch);
token.push(ch);
} else if ch.is_whitespace() {
if !token.is_empty() {
tokens.push(std::mem::take(&mut token));
}
} else {
token.push(ch);
}
}
if !token.is_empty() {
tokens.push(token);
}
tokens
}
fn quote_path_if_needed(path: &str) -> String {
// Node's NODE_OPTIONS tokenizer treats whitespace as a separator, `'`/`"`
// as quote delimiters, and `\` as an escape character (so a bare Windows
// path would lose its separators). Wrap such paths in double quotes,
// escaping only `\` and `"`. A full JSON encode is wrong here: Node does
// not decode `\uXXXX`, so escaping non-ASCII bytes would corrupt the path.
if path.chars().any(|ch| ch.is_whitespace() || matches!(ch, '"' | '\'' | '\\')) {
let escaped = path.replace('\\', r"\\").replace('"', r#"\""#);
format!("\"{escaped}\"")
} else {
path.to_string()
}
}
fn absolute_package_url(path: &Path) -> String {
let normalized = normalize_path(path);
if cfg!(windows) && normalized.starts_with("//") {
format!("file:{}", encode_url_path(&normalized))
} else if cfg!(windows) && !normalized.starts_with('/') {
format!("file:///{}", encode_url_path(&normalized))
} else {
format!("file://{}", encode_url_path(&normalized))
}
}
fn encode_url_path(path: &str) -> String {
let mut encoded = String::with_capacity(path.len());
for byte in path.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
encoded.push(byte as char);
}
_ => write!(encoded, "%{byte:02X}").expect("writing to a string cannot fail"),
}
}
encoded
}
fn normalize_path(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,489 @@
use super::{
HoistedPackageMapOptions, PackageMapOptions, absolute_package_url,
dependencies_graph_to_package_map, link_target_id, lockfile_to_package_map,
make_node_package_map_option, to_relative_url,
};
use crate::{DependenciesGraphNode, LockfileToDepGraphResult, VirtualStoreLayout};
use pacquet_lockfile::{
ComVer, Lockfile, LockfileResolution, LockfileVersion, PackageKey, PkgIdWithPatchHash, PkgName,
ProjectSnapshot, ResolvedDependencyMap, ResolvedDependencySpec, SnapshotDepRef, SnapshotEntry,
TarballResolution,
};
use pacquet_modules_yaml::DepPath;
use pacquet_package_manifest::PackageManifest;
use std::{
collections::{BTreeMap, BTreeSet, HashMap},
path::{Path, PathBuf},
};
#[test]
fn builds_package_map_from_lockfile() {
let cwd = std::env::current_dir().expect("current dir");
let layout = VirtualStoreLayout::legacy(cwd.join("node_modules/.pnpm"), 120);
let root_manifest = manifest("root");
let app_manifest = manifest("app");
let linked_manifest = manifest("linked");
let project_manifests = vec![
(cwd.clone(), &root_manifest),
(cwd.join("packages/app"), &app_manifest),
(cwd.join("packages/linked"), &linked_manifest),
];
let package_map = lockfile_to_package_map(
&Lockfile {
importers: HashMap::from([
(
".".to_string(),
ProjectSnapshot {
dependencies: Some(deps(&[
("dep1", "1.0.0"),
("dep2-alias", "foo@2.0.0"),
("linked", "link:packages/linked"),
])),
..ProjectSnapshot::default()
},
),
(
"packages/app".to_string(),
ProjectSnapshot {
dependencies: Some(deps(&[
("dep1", "1.0.0"),
("linked", "link:../linked"),
])),
dev_dependencies: Some(deps(&[("dep2-alias", "foo@2.0.0")])),
..ProjectSnapshot::default()
},
),
(
"packages/linked".to_string(),
ProjectSnapshot {
dependencies: Some(deps(&[("qar", "3.0.0")])),
..ProjectSnapshot::default()
},
),
]),
snapshots: Some(HashMap::from([
("dep1@1.0.0".parse().unwrap(), snapshot_deps(&[("dep2-alias", "foo@2.0.0")])),
("foo@2.0.0".parse().unwrap(), snapshot_optional_deps(&[("qar", "3.0.0")])),
("qar@3.0.0".parse().unwrap(), SnapshotEntry::default()),
])),
..empty_lockfile()
},
&PackageMapOptions {
lockfile_dir: &cwd,
modules_dir: &cwd.join("node_modules"),
package_map_type: pacquet_config::NodePackageMapType::Standard,
layout: &layout,
project_manifests: &project_manifests,
},
);
assert_eq!(
serde_json::to_value(&package_map).expect("serialize package map"),
serde_json::json!({
"packages": {
".": {
"url": "..",
"dependencies": {
"dep1": "dep1@1.0.0",
"dep2-alias": "foo@2.0.0",
"linked": "packages/linked",
"root": "."
}
},
"dep1@1.0.0": {
"url": "./.pnpm/dep1@1.0.0/node_modules/dep1",
"dependencies": {
"dep1": "dep1@1.0.0",
"dep2-alias": "foo@2.0.0"
}
},
"foo@2.0.0": {
"url": "./.pnpm/foo@2.0.0/node_modules/foo",
"dependencies": {
"foo": "foo@2.0.0",
"qar": "qar@3.0.0"
}
},
"packages/app": {
"url": "../packages/app",
"dependencies": {
"app": "packages/app",
"dep1": "dep1@1.0.0",
"dep2-alias": "foo@2.0.0",
"linked": "packages/linked"
}
},
"packages/linked": {
"url": "../packages/linked",
"dependencies": {
"linked": "packages/linked",
"qar": "qar@3.0.0"
}
},
"qar@3.0.0": {
"url": "./.pnpm/qar@3.0.0/node_modules/qar",
"dependencies": {
"qar": "qar@3.0.0"
}
}
}
}),
);
}
#[test]
fn lockfile_package_map_uses_global_virtual_store_layout() {
let cwd = std::env::current_dir().expect("current dir");
let mut config = pacquet_config::Config::new();
config.enable_global_virtual_store = true;
config.global_virtual_store_dir = cwd.join("store/links");
config.virtual_store_dir = cwd.join("node_modules/.pnpm");
let snapshots =
HashMap::from([("dep1@1.0.0".parse::<PackageKey>().unwrap(), SnapshotEntry::default())]);
// GVS precomputes a `<name>/<version>/<hash>` slot per snapshot; the
// package map must read those locations rather than the flat depPath name.
let layout = VirtualStoreLayout::new(&config, None, Some(&snapshots), None, None);
let root_manifest = manifest("root");
let project_manifests = vec![(cwd.clone(), &root_manifest)];
let package_map = lockfile_to_package_map(
&Lockfile {
importers: HashMap::from([(
".".to_string(),
ProjectSnapshot {
dependencies: Some(deps(&[("dep1", "1.0.0")])),
..ProjectSnapshot::default()
},
)]),
snapshots: Some(snapshots),
..empty_lockfile()
},
&PackageMapOptions {
lockfile_dir: &cwd,
modules_dir: &cwd.join("node_modules"),
package_map_type: pacquet_config::NodePackageMapType::Standard,
layout: &layout,
project_manifests: &project_manifests,
},
);
let url = &package_map.packages["dep1@1.0.0"].url;
assert!(
url.contains("store/links/") && url.contains("/dep1/1.0.0/"),
"expected a nested GVS slot url, got {url:?}",
);
assert!(
!url.contains("dep1@1.0.0/node_modules"),
"must not fall back to the flat local layout, got {url:?}",
);
}
#[test]
fn link_target_id_uses_link_prefix_when_relative_path_cannot_be_computed() {
let dir = PathBuf::from("/outside/store/pkg");
assert_eq!(link_target_id(None, &dir), "link:/outside/store/pkg");
}
#[test]
fn lockfile_package_map_loose_mode_includes_physical_ancestor_dependencies() {
let cwd = std::env::current_dir().expect("current dir");
let layout = VirtualStoreLayout::legacy(cwd.join("node_modules/.pnpm"), 120);
let root_manifest = manifest("root");
let project_manifests = vec![(cwd.clone(), &root_manifest)];
let lockfile = Lockfile {
importers: HashMap::from([(
".".to_string(),
ProjectSnapshot {
dependencies: Some(deps(&[("dep1", "1.0.0"), ("linked", "link:packages/linked")])),
..ProjectSnapshot::default()
},
)]),
snapshots: Some(HashMap::from([("dep1@1.0.0".parse().unwrap(), SnapshotEntry::default())])),
..empty_lockfile()
};
let standard_package_map = lockfile_to_package_map(
&lockfile,
&PackageMapOptions {
lockfile_dir: &cwd,
modules_dir: &cwd.join("node_modules"),
package_map_type: pacquet_config::NodePackageMapType::Standard,
layout: &layout,
project_manifests: &project_manifests,
},
);
let loose_package_map = lockfile_to_package_map(
&lockfile,
&PackageMapOptions {
lockfile_dir: &cwd,
modules_dir: &cwd.join("node_modules"),
package_map_type: pacquet_config::NodePackageMapType::Loose,
layout: &layout,
project_manifests: &project_manifests,
},
);
assert_eq!(
standard_package_map.packages["dep1@1.0.0"].dependencies,
BTreeMap::from([("dep1".to_string(), "dep1@1.0.0".to_string())]),
);
assert_eq!(
loose_package_map.packages["dep1@1.0.0"].dependencies,
BTreeMap::from([
("dep1".to_string(), "dep1@1.0.0".to_string()),
("linked".to_string(), "packages/linked".to_string()),
]),
);
}
#[test]
fn hoisted_package_map_loose_mode_includes_physical_ancestor_dependencies() {
let cwd = std::env::current_dir().expect("current dir");
let root_modules_dir = cwd.join("node_modules");
let dep1_dir = root_modules_dir.join("dep1");
let root_manifest = manifest("root");
let project_manifests = vec![(cwd.clone(), &root_manifest)];
let mut graph = LockfileToDepGraphResult::default();
graph
.direct_dependencies_by_importer_id
.insert(".".to_string(), BTreeMap::from([("dep1".to_string(), dep1_dir.clone())]));
graph.graph.insert(dep1_dir.clone(), graph_node("dep1", "1.0.0", &dep1_dir));
let lockfile = Lockfile {
importers: HashMap::from([(
".".to_string(),
ProjectSnapshot {
dependencies: Some(deps(&[("dep1", "1.0.0"), ("linked", "link:packages/linked")])),
..ProjectSnapshot::default()
},
)]),
snapshots: Some(HashMap::from([("dep1@1.0.0".parse().unwrap(), SnapshotEntry::default())])),
..empty_lockfile()
};
let package_map = dependencies_graph_to_package_map(
&lockfile,
&graph,
&HoistedPackageMapOptions {
lockfile_dir: &cwd,
modules_dir: &root_modules_dir,
package_map_type: pacquet_config::NodePackageMapType::Loose,
project_manifests: &project_manifests,
},
);
assert_eq!(
package_map.packages["dep1"].dependencies,
BTreeMap::from([
("dep1".to_string(), "dep1".to_string()),
("linked".to_string(), "../packages/linked".to_string()),
]),
);
}
#[test]
fn hoisted_package_map_standard_mode_uses_declared_importer_dependencies_only() {
let cwd = std::env::current_dir().expect("current dir");
let root_modules_dir = cwd.join("node_modules");
let dep1_dir = root_modules_dir.join("dep1");
let dep2_dir = root_modules_dir.join("dep2");
let root_manifest = manifest("root");
let project_manifests = vec![(cwd.clone(), &root_manifest)];
let mut graph = LockfileToDepGraphResult::default();
graph.graph.insert(dep1_dir.clone(), graph_node("dep1", "1.0.0", &dep1_dir));
graph.graph.insert(dep2_dir.clone(), graph_node("dep2", "1.0.0", &dep2_dir));
let lockfile = Lockfile {
importers: HashMap::from([(
".".to_string(),
ProjectSnapshot {
dependencies: Some(deps(&[("dep1", "1.0.0")])),
..ProjectSnapshot::default()
},
)]),
snapshots: Some(HashMap::from([
("dep1@1.0.0".parse().unwrap(), SnapshotEntry::default()),
("dep2@1.0.0".parse().unwrap(), SnapshotEntry::default()),
])),
..empty_lockfile()
};
let standard_package_map = dependencies_graph_to_package_map(
&lockfile,
&graph,
&HoistedPackageMapOptions {
lockfile_dir: &cwd,
modules_dir: &root_modules_dir,
package_map_type: pacquet_config::NodePackageMapType::Standard,
project_manifests: &project_manifests,
},
);
let loose_package_map = dependencies_graph_to_package_map(
&lockfile,
&graph,
&HoistedPackageMapOptions {
lockfile_dir: &cwd,
modules_dir: &root_modules_dir,
package_map_type: pacquet_config::NodePackageMapType::Loose,
project_manifests: &project_manifests,
},
);
assert_eq!(
standard_package_map.packages["."].dependencies,
BTreeMap::from([
("dep1".to_string(), "dep1".to_string()),
("root".to_string(), ".".to_string()),
]),
);
assert_eq!(
loose_package_map.packages["."].dependencies,
BTreeMap::from([
("dep1".to_string(), "dep1".to_string()),
("dep2".to_string(), "dep2".to_string()),
("root".to_string(), ".".to_string()),
]),
);
}
#[test]
fn package_map_node_options_replaces_existing_package_map_option() {
assert_eq!(
make_node_package_map_option(
Path::new("/repo/node_modules/.package-map.json"),
Some("--require ./hook.cjs --experimental-package-map=old.json --trace-warnings"),
),
"--require ./hook.cjs --trace-warnings --experimental-package-map=/repo/node_modules/.package-map.json",
);
assert_eq!(
make_node_package_map_option(
Path::new("/repo with spaces/node_modules/.package-map.json"),
Some("--experimental-package-map old.json"),
),
r#"--experimental-package-map="/repo with spaces/node_modules/.package-map.json""#,
);
// A backslash path (e.g. Windows) must be quoted and the separators escaped
// so Node's NODE_OPTIONS parser does not consume them as escapes.
assert_eq!(
make_node_package_map_option(
Path::new(r"C:\repo\node_modules\.package-map.json"),
Some(""),
),
r#"--experimental-package-map="C:\\repo\\node_modules\\.package-map.json""#,
);
// An existing flag whose quoted path contains an escaped quote must be
// stripped in full, so the rebuilt NODE_OPTIONS is not corrupted.
assert_eq!(
make_node_package_map_option(
Path::new("/new/.package-map.json"),
Some(r#"--experimental-package-map="/quo\"te/old.json" --inspect"#),
),
"--inspect --experimental-package-map=/new/.package-map.json",
);
}
#[test]
fn link_target_id_uses_link_prefix_for_paths_above_the_lockfile_dir() {
let dir = PathBuf::from("/outside/pkg");
assert_eq!(link_target_id(Some(PathBuf::from("../outside/pkg")), &dir), "link:/outside/pkg");
}
#[test]
fn relative_url_uses_a_file_url_when_relative_path_cannot_be_computed() {
assert_eq!(
absolute_package_url(Path::new("/outside/pkg with space")),
"file:///outside/pkg%20with%20space",
);
}
#[test]
fn relative_url_keeps_same_volume_paths_relative() {
assert_eq!(
to_relative_url(
Path::new("/workspace/node_modules"),
Path::new("/workspace/node_modules/.pnpm/foo")
),
"./.pnpm/foo",
);
}
fn manifest(name: &str) -> PackageManifest {
let dir = tempfile::tempdir().expect("tempdir");
let mut manifest = PackageManifest::create_if_needed(dir.path().join("package.json"))
.expect("create package manifest");
manifest.value_mut()["name"] = serde_json::json!(name);
manifest
}
fn deps(entries: &[(&str, &str)]) -> ResolvedDependencyMap {
entries
.iter()
.map(|(alias, version)| {
(
pkg(alias),
ResolvedDependencySpec {
specifier: (*version).to_string(),
version: (*version).parse().unwrap(),
},
)
})
.collect()
}
fn snapshot_deps(entries: &[(&str, &str)]) -> SnapshotEntry {
SnapshotEntry { dependencies: Some(snapshot_dep_map(entries)), ..SnapshotEntry::default() }
}
fn snapshot_optional_deps(entries: &[(&str, &str)]) -> SnapshotEntry {
SnapshotEntry {
optional_dependencies: Some(snapshot_dep_map(entries)),
..SnapshotEntry::default()
}
}
fn snapshot_dep_map(entries: &[(&str, &str)]) -> HashMap<PkgName, SnapshotDepRef> {
entries.iter().map(|(alias, version)| (pkg(alias), version.parse().unwrap())).collect()
}
fn pkg(name: &str) -> PkgName {
name.parse().unwrap()
}
fn empty_lockfile() -> Lockfile {
Lockfile {
lockfile_version: LockfileVersion::<9>::try_from(ComVer { major: 9, minor: 0 }).unwrap(),
settings: None,
catalogs: None,
overrides: None,
package_extensions_checksum: None,
pnpmfile_checksum: None,
ignored_optional_dependencies: None,
patched_dependencies: None,
importers: HashMap::new(),
packages: None,
snapshots: None,
}
}
fn graph_node(name: &str, version: &str, dir: &Path) -> DependenciesGraphNode {
let key: PackageKey = format!("{name}@{version}").parse().unwrap();
DependenciesGraphNode {
alias: Some(name.to_string()),
dep_path: DepPath::from(key.to_string()),
pkg_id_with_patch_hash: PkgIdWithPatchHash::from(key.to_string()),
dir: dir.to_path_buf(),
modules: dir.parent().expect("package dir has parent").to_path_buf(),
children: BTreeMap::new(),
name: name.to_string(),
version: version.to_string(),
optional: false,
optional_dependencies: BTreeSet::new(),
has_bin: false,
has_bundled_dependencies: false,
patch: None,
resolution: LockfileResolution::Tarball(TarballResolution {
tarball: String::new(),
integrity: None,
git_hosted: None,
path: None,
}),
}
}

12
pnpm-lock.yaml generated
View File

@@ -515,9 +515,6 @@ catalogs:
'@yarnpkg/nm':
specifier: 4.0.7
version: 4.0.7
'@yarnpkg/parsers':
specifier: 3.0.3
version: 3.0.3
'@yarnpkg/pnp':
specifier: ^4.1.6
version: 4.1.7
@@ -5402,9 +5399,6 @@ importers:
'@yarnpkg/lockfile':
specifier: 'catalog:'
version: 1.1.0
'@yarnpkg/parsers':
specifier: 'catalog:'
version: 3.0.3
'@zkochan/rimraf':
specifier: 'catalog:'
version: 4.0.0
@@ -5426,6 +5420,9 @@ importers:
is-subdir:
specifier: 'catalog:'
version: 2.0.0
js-yaml:
specifier: 'catalog:'
version: '@zkochan/js-yaml@0.0.11'
load-json-file:
specifier: 'catalog:'
version: 7.0.1
@@ -5487,6 +5484,9 @@ importers:
'@pnpm/worker':
specifier: workspace:*
version: link:../../worker
'@types/js-yaml':
specifier: 'catalog:'
version: 4.0.9
'@types/normalize-path':
specifier: 'catalog:'
version: 3.0.2

View File

@@ -151,7 +151,6 @@ catalog:
'@yarnpkg/extensions': 2.0.6
'@yarnpkg/lockfile': ^1.1.0
'@yarnpkg/nm': 4.0.7
'@yarnpkg/parsers': 3.0.3
'@yarnpkg/pnp': ^4.1.6
'@zkochan/cmd-shim': ^9.0.6
'@zkochan/retry': ^0.2.0