mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-30 19:05:23 -04:00
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:
6
.changeset/fix-yarn-import-js-yaml.md
Normal file
6
.changeset/fix-yarn-import-js-yaml.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@pnpm/installing.commands": patch
|
||||
"pnpm": patch
|
||||
---
|
||||
|
||||
Fixed `pnpm import` for Yarn v2 lockfiles when `js-yaml` v4 is installed.
|
||||
12
.changeset/package-maps.md
Normal file
12
.changeset/package-maps.md
Normal 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
1
Cargo.lock
generated
@@ -4067,6 +4067,7 @@ dependencies = [
|
||||
"pretty_assertions",
|
||||
"rayon",
|
||||
"reflink-copy",
|
||||
"serde",
|
||||
"serde-saphyr",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
481
lockfile/to-pnp/src/packageMap.ts
Normal file
481
lockfile/to-pnp/src/packageMap.ts
Normal 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
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
857
pacquet/crates/package-manager/src/package_map.rs
Normal file
857
pacquet/crates/package-manager/src/package_map.rs
Normal 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(¤t.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;
|
||||
489
pacquet/crates/package-manager/src/package_map/tests.rs
Normal file
489
pacquet/crates/package-manager/src/package_map/tests.rs
Normal 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
12
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user