feat: store config deps and package manager integrities in pnpm-lock.env.yaml (#10912)

## Summary

Store config dependency and package manager integrity info in a separate `pnpm-lock.env.yaml` lockfile instead of inlining it in `pnpm-workspace.yaml`. The workspace manifest now contains only clean version specifiers for `configDependencies`, while the resolved versions, integrity hashes, and tarball URLs are recorded in the new env lockfile.

### Key changes

- **New `pnpm-lock.env.yaml` lockfile**: Uses the standard lockfile format (`importers`, `packages`, `snapshots`) to store resolved config dependencies and package manager dependencies with integrity hashes and tarball URLs.
- **Automatic migration**: Projects using the old inline-hash format in `pnpm-workspace.yaml` are automatically migrated on install.
- **Global Virtual Store (GVS) for version switching**: When switching pnpm versions via the `packageManager` field, pnpm is installed to the global virtual store (`$STORE_DIR/links/`) instead of `globalPkgDir`, reusing the content-addressable store for deduplication.
- **Self-update uses headless install**: `pnpm self-update` performs frozen headless installs using integrity hashes from the env lockfile, then links bins to `PNPM_HOME`.
- **`packageManagerDependencies`**: The env lockfile also stores resolved `packageManagerDependencies` during version switching and self-update.
- **`@pnpm/exe` support**: Replicates `@pnpm/exe`'s postinstall script (linking platform-specific binaries) since install scripts are disabled.
- **`pnpm setup` refactored**: Uses `pnpm add -g` instead of copying the CLI binary directly.
- **Extracted `toLockfileResolution`** to `@pnpm/lockfile.utils` and **deduplicated `iteratePkgMeta`** into `@pnpm/calc-dep-state`.
- **Removed unused `@pnpm/tools.path` package**.
This commit is contained in:
Zoltan Kochan
2026-03-11 00:39:37 +01:00
committed by GitHub
parent 2b68ae123b
commit a8f016ca59
54 changed files with 1712 additions and 644 deletions

View File

@@ -0,0 +1,15 @@
---
"@pnpm/config.deps-installer": minor
"@pnpm/constants": patch
"@pnpm/lockfile.fs": minor
"@pnpm/lockfile.types": minor
"@pnpm/lockfile.utils": minor
"@pnpm/types": patch
"@pnpm/tools.plugin-commands-self-updater": minor
"@pnpm/calc-dep-state": minor
"@pnpm/plugin-commands-setup": patch
"@pnpm/resolve-dependencies": patch
"pnpm": minor
---
Store config dependency and package manager integrity info in a separate `pnpm-lock.env.yaml` lockfile instead of inlining it in `pnpm-workspace.yaml`. The workspace manifest now contains only clean version specifiers for `configDependencies`, while the resolved versions, integrity hashes, and tarball URLs are recorded in the new env lockfile. The env lockfile also stores `packageManagerDependencies` resolved during version switching and self-update. Projects using the old inline-hash format are automatically migrated on install.

View File

@@ -175,7 +175,6 @@
"@pnpm/hooks.types": major
"@pnpm/lockfile.fs": major
"@pnpm/store.cafs": major
"@pnpm/tools.path": major
"@pnpm/cache.api": major
"@pnpm/env.path": major
"pd": major

View File

@@ -162,7 +162,6 @@
"@pnpm/hooks.types": major
"@pnpm/lockfile.fs": major
"@pnpm/store.cafs": major
"@pnpm/tools.path": major
"@pnpm/cache.api": major
"@pnpm/env.path": major
"@pnpm/worker": major

View File

@@ -2,10 +2,10 @@ import fs from 'fs'
import path from 'path'
import { packageManager } from '@pnpm/cli-meta'
import { getConfig as _getConfig, type CliOptions, type Config } from '@pnpm/config'
import { formatWarn } from '@pnpm/default-reporter'
import { createStoreController } from '@pnpm/store-connection-manager'
import { installConfigDeps } from '@pnpm/config.deps-installer'
import { formatWarn } from '@pnpm/default-reporter'
import { requireHooks } from '@pnpm/pnpmfile'
import { createStoreController } from '@pnpm/store-connection-manager'
import type { ConfigDependencies } from '@pnpm/types'
import { lexCompare } from '@pnpm/util.lex-comparator'
@@ -30,6 +30,20 @@ export async function getConfig (
ignoreNonAuthSettingsFromLocal: opts.ignoreNonAuthSettingsFromLocal,
})
config.cliOptions = cliOptions
applyDerivedConfig(config)
if (opts.excludeReporter) {
delete config.reporter // This is a silly workaround because @pnpm/core expects a function as opts.reporter
}
if (warnings.length > 0) {
console.warn(warnings.map((warning) => formatWarn(warning)).join('\n'))
}
return config
}
export async function installConfigDepsAndLoadHooks (config: Config): Promise<Config> {
if (config.configDependencies) {
const store = await createStoreController(config)
await installConfigDeps(config.configDependencies, {
@@ -61,16 +75,6 @@ export async function getConfig (
}
}
}
applyDerivedConfig(config)
if (opts.excludeReporter) {
delete config.reporter // This is a silly workaround because @pnpm/core expects a function as opts.reporter
}
if (warnings.length > 0) {
console.warn(warnings.map((warning) => formatWarn(warning)).join('\n'))
}
return config
}

View File

@@ -1,6 +1,6 @@
import { packageManager } from '@pnpm/cli-meta'
export { calcPnpmfilePathsOfPluginDeps, getConfig } from './getConfig.js'
export { calcPnpmfilePathsOfPluginDeps, getConfig, installConfigDepsAndLoadHooks } from './getConfig.js'
export * from './packageIsInstallable.js'
export * from './readDepNameCompletions.js'
export * from './readProjectManifest.js'

View File

@@ -35,9 +35,14 @@
"dependencies": {
"@pnpm/calc-dep-state": "workspace:*",
"@pnpm/config.config-writer": "workspace:*",
"@pnpm/constants": "workspace:*",
"@pnpm/core-loggers": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/fetch": "workspace:*",
"@pnpm/lockfile.fs": "workspace:*",
"@pnpm/lockfile.pruner": "workspace:*",
"@pnpm/lockfile.types": "workspace:*",
"@pnpm/lockfile.utils": "workspace:*",
"@pnpm/network.auth-header": "workspace:*",
"@pnpm/npm-resolver": "workspace:*",
"@pnpm/package-store": "workspace:*",
@@ -45,13 +50,16 @@
"@pnpm/pick-registry-for-package": "workspace:*",
"@pnpm/read-modules-dir": "workspace:*",
"@pnpm/read-package-json": "workspace:*",
"@pnpm/resolve-dependencies": "workspace:*",
"@pnpm/store-controller-types": "workspace:*",
"@pnpm/types": "workspace:*",
"@zkochan/rimraf": "catalog:",
"get-npm-tarball-url": "catalog:",
"symlink-dir": "catalog:"
},
"peerDependencies": {
"@pnpm/logger": "catalog:"
"@pnpm/logger": "catalog:",
"@pnpm/worker": "workspace:^"
},
"devDependencies": {
"@pnpm/config.deps-installer": "workspace:*",

View File

@@ -1,3 +1,3 @@
export { installConfigDeps, type InstallConfigDepsOpts } from './installConfigDeps.js'
export { resolveConfigDeps, type ResolveConfigDepsOpts } from './resolveConfigDeps.js'
export { normalizeConfigDeps } from './normalizeConfigDeps.js'
export { resolvePackageManagerIntegrities, isPackageManagerResolved, type ResolvePackageManagerIntegritiesOpts } from './resolvePackageManagerIntegrities.js'

View File

@@ -2,13 +2,18 @@ import fs from 'fs'
import path from 'path'
import { calcLeafGlobalVirtualStorePath } from '@pnpm/calc-dep-state'
import { installingConfigDepsLogger } from '@pnpm/core-loggers'
import { readModulesDir } from '@pnpm/read-modules-dir'
import rimraf from '@zkochan/rimraf'
import { safeReadPackageJsonFromDir } from '@pnpm/read-package-json'
import { PnpmError } from '@pnpm/error'
import { readEnvLockfile, type EnvLockfile } from '@pnpm/lockfile.fs'
import type { StoreController } from '@pnpm/package-store'
import { pickRegistryForPackage } from '@pnpm/pick-registry-for-package'
import { readModulesDir } from '@pnpm/read-modules-dir'
import { safeReadPackageJsonFromDir } from '@pnpm/read-package-json'
import type { ConfigDependencies, Registries } from '@pnpm/types'
import rimraf from '@zkochan/rimraf'
import getNpmTarballUrl from 'get-npm-tarball-url'
import symlinkDir from 'symlink-dir'
import { normalizeConfigDeps } from './normalizeConfigDeps.js'
import { migrateConfigDepsToLockfile } from './migrateConfigDeps.js'
import type { NormalizedConfigDep } from './parseIntegrity.js'
export interface InstallConfigDepsOpts {
registries: Registries
@@ -17,21 +22,28 @@ export interface InstallConfigDepsOpts {
storeDir: string
}
export async function installConfigDeps (configDeps: ConfigDependencies, opts: InstallConfigDepsOpts): Promise<void> {
/**
* Install config dependencies using the env lockfile.
* Accepts either a EnvLockfile directly (from resolveConfigDeps) or
* ConfigDependencies from the workspace manifest (legacy/migration).
*/
export async function installConfigDeps (
configDepsOrLockfile: ConfigDependencies | EnvLockfile,
opts: InstallConfigDepsOpts
): Promise<void> {
const normalizedDeps = await normalizeForInstall(configDepsOrLockfile, opts)
const globalVirtualStoreDir = path.join(opts.storeDir, 'links')
const configModulesDir = path.join(opts.rootDir, 'node_modules/.pnpm-config')
const existingConfigDeps: string[] = await readModulesDir(configModulesDir) ?? []
await Promise.all(existingConfigDeps.map(async (existingConfigDep) => {
if (!configDeps[existingConfigDep]) {
if (!normalizedDeps[existingConfigDep]) {
await rimraf(path.join(configModulesDir, existingConfigDep))
}
}))
const installedConfigDeps: Array<{ name: string, version: string }> = []
const normalizedConfigDeps = normalizeConfigDeps(configDeps, {
registries: opts.registries,
})
await Promise.all(Object.entries(normalizedConfigDeps).map(async ([pkgName, pkg]) => {
await Promise.all(Object.entries(normalizedDeps).map(async ([pkgName, pkg]) => {
const configDepPath = path.join(configModulesDir, pkgName)
const existingPkgJson = existingConfigDeps.includes(pkgName)
? await safeReadPackageJsonFromDir(configDepPath)
@@ -73,3 +85,71 @@ export async function installConfigDeps (configDeps: ConfigDependencies, opts: I
installingConfigDepsLogger.debug({ status: 'done', deps: installedConfigDeps })
}
}
async function normalizeForInstall (
configDepsOrLockfile: ConfigDependencies | EnvLockfile,
opts: InstallConfigDepsOpts
): Promise<Record<string, NormalizedConfigDep>> {
// If it's a EnvLockfile object (has lockfileVersion), use it directly
if (isEnvLockfile(configDepsOrLockfile)) {
return normalizeFromLockfile(configDepsOrLockfile, opts.registries)
}
// It's ConfigDependencies from workspace manifest.
// Try to read the env lockfile first.
const envLockfile = await readEnvLockfile(opts.rootDir)
if (envLockfile) {
return normalizeFromLockfile(envLockfile, opts.registries)
}
// No env lockfile yet — migrate from old inline integrity format
return migrateConfigDepsToLockfile(configDepsOrLockfile, opts)
}
function isEnvLockfile (obj: ConfigDependencies | EnvLockfile): obj is EnvLockfile {
return 'lockfileVersion' in obj &&
'importers' in obj &&
obj.importers != null &&
typeof obj.importers === 'object' &&
'packages' in obj &&
obj.packages != null &&
typeof obj.packages === 'object' &&
'snapshots' in obj &&
obj.snapshots != null &&
typeof obj.snapshots === 'object'
}
function normalizeFromLockfile (
lockfile: EnvLockfile,
registries: Registries
): Record<string, NormalizedConfigDep> {
const deps: Record<string, NormalizedConfigDep> = {}
const configDeps = lockfile.importers['.']?.configDependencies ?? {}
for (const [pkgName, { version }] of Object.entries(configDeps)) {
const pkgKey = `${pkgName}@${version}`
const pkgInfo = lockfile.packages[pkgKey]
if (!pkgInfo) {
throw new PnpmError(
'ENV_LOCKFILE_CORRUPTED',
`pnpm-lock.env.yaml is corrupted or incomplete: missing packages entry for "${pkgKey}" ` +
'referenced from importers[\'.\'].configDependencies'
)
}
const resolution = pkgInfo.resolution as { integrity?: string; tarball?: string }
if (!resolution.integrity) {
throw new PnpmError(
'ENV_LOCKFILE_CORRUPTED',
`pnpm-lock.env.yaml is corrupted or incomplete: missing integrity for "${pkgKey}"`
)
}
const registry = pickRegistryForPackage(registries, pkgName)
deps[pkgName] = {
version,
resolution: {
integrity: resolution.integrity,
tarball: resolution.tarball ?? getNpmTarballUrl(pkgName, version, { registry }),
},
}
}
return deps
}

View File

@@ -0,0 +1,104 @@
import { PnpmError } from '@pnpm/error'
import { writeSettings } from '@pnpm/config.config-writer'
import { createEnvLockfile, writeEnvLockfile } from '@pnpm/lockfile.fs'
import { toLockfileResolution } from '@pnpm/lockfile.utils'
import { pickRegistryForPackage } from '@pnpm/pick-registry-for-package'
import type { ConfigDependencies, ConfigDependencySpecifiers, Registries } from '@pnpm/types'
import getNpmTarballUrl from 'get-npm-tarball-url'
import type { NormalizedConfigDep } from './parseIntegrity.js'
import { parseIntegrity } from './parseIntegrity.js'
interface MigrateOpts {
registries: Registries
rootDir: string
}
/**
* Migrates old-format configDependencies (with inline integrity in pnpm-workspace.yaml)
* to the new pnpm-lock.env.yaml format.
*
* Returns normalized deps for immediate installation, and writes the env lockfile
* and clean specifiers to pnpm-workspace.yaml as a side effect.
*/
export async function migrateConfigDepsToLockfile (
configDeps: ConfigDependencies,
opts: MigrateOpts
): Promise<Record<string, NormalizedConfigDep>> {
const envLockfile = createEnvLockfile()
const cleanSpecifiers: ConfigDependencySpecifiers = {}
const normalizedDeps: Record<string, NormalizedConfigDep> = {}
for (const [pkgName, pkgSpec] of Object.entries(configDeps)) {
const registry = pickRegistryForPackage(opts.registries, pkgName)
if (typeof pkgSpec === 'object') {
const { version, integrity } = parseIntegrity(pkgName, pkgSpec.integrity)
const tarball = pkgSpec.tarball ?? getNpmTarballUrl(pkgName, version, { registry })
cleanSpecifiers[pkgName] = version
const pkgKey = `${pkgName}@${version}`
envLockfile.importers['.'].configDependencies[pkgName] = {
specifier: version,
version,
}
envLockfile.packages[pkgKey] = {
resolution: toLockfileResolution(
{ name: pkgName, version },
{ integrity, tarball },
registry
),
}
envLockfile.snapshots[pkgKey] = {}
normalizedDeps[pkgName] = {
version,
resolution: { integrity, tarball },
}
continue
}
if (typeof pkgSpec === 'string') {
// This branch only handles the legacy inline format (version+integrity).
// New clean specifiers (just version/range) require an existing pnpm-lock.env.yaml.
if (!pkgSpec.includes('+')) {
throw new PnpmError(
'CONFIG_DEP_MISSING_LOCKFILE',
`Config dependency "${pkgName}" is already in clean-specifier form (${pkgSpec}) ` +
'but no pnpm-lock.env.yaml was found to resolve it. ' +
'Please generate and commit pnpm-lock.env.yaml (for example by running ' +
'`pnpm install` in the workspace root) before attempting to migrate configDependencies.'
)
}
const { version, integrity } = parseIntegrity(pkgName, pkgSpec)
const tarball = getNpmTarballUrl(pkgName, version, { registry })
cleanSpecifiers[pkgName] = version
const pkgKey = `${pkgName}@${version}`
envLockfile.importers['.'].configDependencies[pkgName] = {
specifier: version,
version,
}
envLockfile.packages[pkgKey] = {
resolution: { integrity },
}
envLockfile.snapshots[pkgKey] = {}
normalizedDeps[pkgName] = {
version,
resolution: { integrity, tarball },
}
}
}
// Write the new env lockfile and clean up workspace manifest
await Promise.all([
writeEnvLockfile(opts.rootDir, envLockfile),
writeSettings({
rootProjectManifestDir: opts.rootDir,
workspaceDir: opts.rootDir,
updatedSettings: {
configDependencies: cleanSpecifiers,
},
}),
])
return normalizedDeps
}

View File

@@ -1,65 +0,0 @@
import getNpmTarballUrl from 'get-npm-tarball-url'
import { PnpmError } from '@pnpm/error'
import { pickRegistryForPackage } from '@pnpm/pick-registry-for-package'
import type { ConfigDependencies, Registries } from '@pnpm/types'
interface NormalizeConfigDepsOpts {
registries: Registries
}
type NormalizedConfigDeps = Record<string, {
version: string
resolution: {
integrity: string
tarball: string
}
}>
export function normalizeConfigDeps (configDependencies: ConfigDependencies, opts: NormalizeConfigDepsOpts): NormalizedConfigDeps {
const deps: NormalizedConfigDeps = {}
for (const [pkgName, pkgSpec] of Object.entries(configDependencies)) {
const registry = pickRegistryForPackage(opts.registries, pkgName)
if (typeof pkgSpec === 'object') {
const { version, integrity } = parseIntegrity(pkgName, pkgSpec.integrity)
deps[pkgName] = {
version,
resolution: {
integrity,
tarball: pkgSpec.tarball ? pkgSpec.tarball : getNpmTarballUrl(pkgName, version, { registry }),
},
}
continue
}
if (typeof pkgSpec === 'string') {
const { version, integrity } = parseIntegrity(pkgName, pkgSpec)
deps[pkgName] = {
version,
resolution: {
integrity,
tarball: getNpmTarballUrl(pkgName, version, { registry }),
},
}
}
}
return deps
}
function parseIntegrity (pkgName: string, pkgSpec: string) {
const sepIndex = pkgSpec.indexOf('+')
if (sepIndex === -1) {
throw new PnpmError('CONFIG_DEP_NO_INTEGRITY', `Your config dependency called "${pkgName}" doesn't have an integrity checksum`, {
hint: `Integrity checksum should be inlined in the version specifier. For example:
pnpm-workspace.yaml:
configDependencies:
my-config: "1.0.0+sha512-Xg0tn4HcfTijTwfDwYlvVCl43V6h4KyVVX2aEm4qdO/PC6L2YvzLHFdmxhoeSA3eslcE6+ZVXHgWwopXYLNq4Q=="
`,
})
}
const version = pkgSpec.substring(0, sepIndex)
const integrity = pkgSpec.substring(sepIndex + 1)
return { version, integrity }
}

View File

@@ -0,0 +1,26 @@
import { PnpmError } from '@pnpm/error'
export interface NormalizedConfigDep {
version: string
resolution: {
integrity: string
tarball: string
}
}
export function parseIntegrity (pkgName: string, pkgSpec: string): { version: string, integrity: string } {
const sepIndex = pkgSpec.indexOf('+')
if (sepIndex === -1) {
throw new PnpmError('CONFIG_DEP_NO_INTEGRITY', `Your config dependency called "${pkgName}" doesn't have an integrity checksum`, {
hint: `Integrity checksum should be inlined in the version specifier. For example:
pnpm-workspace.yaml:
configDependencies:
my-config: "1.0.0+sha512-Xg0tn4HcfTijTwfDwYlvVCl43V6h4KyVVX2aEm4qdO/PC6L2YvzLHFdmxhoeSA3eslcE6+ZVXHgWwopXYLNq4Q=="
`,
})
}
const version = pkgSpec.substring(0, sepIndex)
const integrity = pkgSpec.substring(sepIndex + 1)
return { version, integrity }
}

View File

@@ -1,11 +1,17 @@
import getNpmTarballUrl from 'get-npm-tarball-url'
import { PnpmError } from '@pnpm/error'
import { writeSettings } from '@pnpm/config.config-writer'
import { createFetchFromRegistry, type CreateFetchFromRegistryOptions } from '@pnpm/fetch'
import {
type EnvLockfile,
createEnvLockfile,
readEnvLockfile,
writeEnvLockfile,
} from '@pnpm/lockfile.fs'
import { toLockfileResolution } from '@pnpm/lockfile.utils'
import { createNpmResolver, type ResolverFactoryOptions } from '@pnpm/npm-resolver'
import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header'
import { parseWantedDependency } from '@pnpm/parse-wanted-dependency'
import type { ConfigDependencies } from '@pnpm/types'
import type { ConfigDependencies, ConfigDependencySpecifiers } from '@pnpm/types'
import { pickRegistryForPackage } from '@pnpm/pick-registry-for-package'
import { installConfigDeps, type InstallConfigDepsOpts } from './installConfigDeps.js'
@@ -19,7 +25,11 @@ export async function resolveConfigDeps (configDeps: string[], opts: ResolveConf
const fetch = createFetchFromRegistry(opts)
const getAuthHeader = createGetAuthHeaderByURI({ allSettings: opts.userConfig!, userSettings: opts.userConfig })
const { resolveFromNpm } = createNpmResolver(fetch, getAuthHeader, opts)
const configDependencies = opts.configDependencies ?? {}
// Extract existing specifiers from configDependencies (handles both old and new formats)
const configDependencySpecifiers: ConfigDependencySpecifiers = extractSpecifiers(opts.configDependencies)
const envLockfile: EnvLockfile = (await readEnvLockfile(opts.rootDir)) ?? createEnvLockfile()
await Promise.all(configDeps.map(async (configDep) => {
const wantedDep = parseWantedDependency(configDep)
if (!wantedDep.alias) {
@@ -30,39 +40,63 @@ export async function resolveConfigDeps (configDeps: string[], opts: ResolveConf
preferredVersions: {},
projectDir: opts.rootDir,
})
if (resolution?.resolution == null || !('integrity' in resolution.resolution)) {
if (resolution?.resolution == null || !('integrity' in resolution.resolution) || typeof resolution.resolution.integrity !== 'string' || !resolution.resolution.integrity) {
throw new PnpmError('BAD_CONFIG_DEP', `Cannot install ${configDep} as configuration dependency because it has no integrity`)
}
const pkgName = wantedDep.alias
const version = resolution.manifest.version
const { tarball, integrity } = resolution.resolution
const registry = pickRegistryForPackage(opts.registries, pkgName)
const defaultTarball = getNpmTarballUrl(pkgName, version, { registry })
if (tarball !== defaultTarball && isValidHttpUrl(tarball)) {
configDependencies[pkgName] = {
tarball,
integrity: `${version}+${integrity}`,
}
} else {
configDependencies[pkgName] = `${version}+${integrity}`
// Write clean specifier to workspace manifest
configDependencySpecifiers[pkgName] = wantedDep.bareSpecifier ?? version
// Write resolved info to env lockfile
const pkgKey = `${pkgName}@${version}`
envLockfile.importers['.'].configDependencies[pkgName] = {
specifier: configDependencySpecifiers[pkgName],
version,
}
envLockfile.packages[pkgKey] = {
resolution: toLockfileResolution(
{ name: pkgName, version },
resolution.resolution,
registry
),
}
envLockfile.snapshots[pkgKey] = {}
}))
await writeSettings({
...opts,
rootProjectManifestDir: opts.rootDir,
workspaceDir: opts.rootDir,
updatedSettings: {
configDependencies,
},
})
await installConfigDeps(configDependencies, opts)
await Promise.all([
writeSettings({
...opts,
rootProjectManifestDir: opts.rootDir,
workspaceDir: opts.rootDir,
updatedSettings: {
configDependencies: configDependencySpecifiers,
},
}),
writeEnvLockfile(opts.rootDir, envLockfile),
])
await installConfigDeps(envLockfile, opts)
}
function isValidHttpUrl (url: string): boolean {
try {
const parsed = new URL(url)
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
} catch {
return false
/**
* Extracts plain specifiers from configDependencies, handling both old format
* ("version+integrity") and new format (plain specifiers).
*/
function extractSpecifiers (configDependencies?: ConfigDependencies): ConfigDependencySpecifiers {
if (!configDependencies) return {}
const specifiers: ConfigDependencySpecifiers = {}
for (const [name, value] of Object.entries(configDependencies)) {
if (typeof value === 'object') {
// Old format with tarball: extract version from integrity string
const sepIndex = value.integrity.indexOf('+')
specifiers[name] = sepIndex !== -1 ? value.integrity.substring(0, sepIndex) : value.integrity
} else {
// Could be old "version+integrity" or new plain specifier
const sepIndex = value.indexOf('+')
specifiers[name] = sepIndex !== -1 ? value.substring(0, sepIndex) : value
}
}
return specifiers
}

View File

@@ -0,0 +1,88 @@
import path from 'path'
import { LOCKFILE_VERSION } from '@pnpm/constants'
import {
getWantedDependencies,
resolveDependencies,
} from '@pnpm/resolve-dependencies'
import type { StoreController } from '@pnpm/store-controller-types'
import type {
LockfileObject,
ProjectSnapshot,
} from '@pnpm/lockfile.types'
import type {
ProjectId,
ProjectManifest,
ProjectRootDir,
Registries,
} from '@pnpm/types'
export interface ResolveManifestDependenciesOpts {
dir: string
registries: Registries
storeController: StoreController
storeDir: string
}
/**
* Resolves the dependencies of a manifest and returns the resulting lockfile
* without writing anything to disk.
*
* This is a lightweight wrapper around resolveDependencies for cases where
* you only need the lockfile output (e.g., resolving package manager integrities).
*/
export async function resolveManifestDependencies (
manifest: ProjectManifest,
opts: ResolveManifestDependenciesOpts
): Promise<LockfileObject> {
const dir = opts.dir as ProjectRootDir
const emptyLockfile: LockfileObject = {
lockfileVersion: LOCKFILE_VERSION,
importers: {
['.' as ProjectId]: { specifiers: {} } as ProjectSnapshot,
},
}
const wantedDependencies = getWantedDependencies(manifest)
.map((dep) => ({ ...dep, updateSpec: true }))
const { newLockfile, waitTillAllFetchingsFinish } = await resolveDependencies(
[
{
id: '.' as ProjectId,
manifest,
modulesDir: path.join(opts.dir, 'node_modules'),
rootDir: dir,
wantedDependencies,
binsDir: path.join(opts.dir, 'node_modules', '.bin'),
updatePackageManifest: false,
},
],
{
allowedDeprecatedVersions: {},
allowUnusedPatches: true,
currentLockfile: emptyLockfile,
defaultUpdateDepth: 0,
dryRun: true,
engineStrict: false,
force: false,
forceFullResolution: true,
hooks: {},
lockfileDir: opts.dir,
nodeVersion: process.version,
pnpmVersion: '',
preferWorkspacePackages: false,
preserveWorkspaceProtocol: false,
registries: opts.registries,
saveWorkspaceProtocol: false,
storeController: opts.storeController,
tag: 'latest',
virtualStoreDir: path.join(opts.dir, 'node_modules', '.pnpm'),
globalVirtualStoreDir: path.join(opts.storeDir, 'links'),
virtualStoreDirMaxLength: 120,
wantedLockfile: emptyLockfile,
workspacePackages: new Map(),
peersSuffixMaxLength: 1000,
}
)
await waitTillAllFetchingsFinish()
return newLockfile
}

View File

@@ -0,0 +1,98 @@
import { convertToLockfileFile, convertToLockfileObject, readEnvLockfile, writeEnvLockfile, createEnvLockfile } from '@pnpm/lockfile.fs'
import { pruneSharedLockfile } from '@pnpm/lockfile.pruner'
import type { EnvLockfile } from '@pnpm/lockfile.types'
import type { StoreController } from '@pnpm/package-store'
import type { DepPath, ProjectId, Registries } from '@pnpm/types'
import { resolveManifestDependencies } from './resolveManifestDependencies.js'
export interface ResolvePackageManagerIntegritiesOpts {
envLockfile?: EnvLockfile
registries: Registries
rootDir: string
storeController: StoreController
storeDir: string
}
/**
* Checks if the wanted pnpm version integrities are already fully resolved in the env lockfile.
*/
export function isPackageManagerResolved (
envLockfile: EnvLockfile | undefined,
pnpmVersion: string
): boolean {
if (!envLockfile) return false
const pmDeps = envLockfile.importers['.'].packageManagerDependencies
return pmDeps != null &&
pmDeps['pnpm']?.version === pnpmVersion &&
pmDeps['@pnpm/exe']?.version === pnpmVersion
}
/**
* Resolves integrity checksums for `pnpm`, `@pnpm/exe`, and their dependencies
* by calling resolveManifestDependencies.
* Writes the results to the `packageManagerDependencies` section of pnpm-lock.env.yaml.
*/
export async function resolvePackageManagerIntegrities (
pnpmVersion: string,
opts: ResolvePackageManagerIntegritiesOpts
): Promise<EnvLockfile> {
const envLockfile = opts.envLockfile ?? (await readEnvLockfile(opts.rootDir)) ?? createEnvLockfile()
if (isPackageManagerResolved(envLockfile, pnpmVersion)) {
return envLockfile
}
const lockfile = await resolveManifestDependencies(
{
dependencies: {
'pnpm': pnpmVersion,
'@pnpm/exe': pnpmVersion,
},
},
{
dir: opts.rootDir,
registries: opts.registries,
storeController: opts.storeController,
storeDir: opts.storeDir,
}
)
if (lockfile.packages) {
// Build packageManagerDependencies from the resolved lockfile importers
const importer = lockfile.importers['.' as ProjectId]
const packageManagerDependencies: Record<string, { specifier: string, version: string }> = {}
for (const [name, version] of Object.entries(importer.dependencies ?? {})) {
packageManagerDependencies[name] = {
specifier: importer.specifiers[name],
version,
}
}
envLockfile.importers['.'].packageManagerDependencies = packageManagerDependencies
// Convert env lockfile to LockfileObject, merge new packages, prune, and split back
const merged = convertToLockfileObject({
lockfileVersion: envLockfile.lockfileVersion,
importers: {
'.': {
dependencies: {
...envLockfile.importers['.'].configDependencies,
...envLockfile.importers['.'].packageManagerDependencies,
},
},
},
packages: envLockfile.packages,
snapshots: envLockfile.snapshots,
})
for (const [depPath, pkg] of Object.entries(lockfile.packages)) {
merged.packages![depPath as DepPath] = pkg
}
const pruned = pruneSharedLockfile(merged)
const prunedFile = convertToLockfileFile(pruned)
envLockfile.packages = prunedFile.packages ?? {}
envLockfile.snapshots = prunedFile.snapshots ?? {}
await writeEnvLockfile(opts.rootDir, envLockfile)
}
return envLockfile
}

View File

@@ -1,20 +1,33 @@
import fs from 'fs'
import { installConfigDeps } from '@pnpm/config.deps-installer'
import { createEnvLockfile, type EnvLockfile } from '@pnpm/lockfile.fs'
import { prepareEmpty } from '@pnpm/prepare'
import { getIntegrity, REGISTRY_MOCK_PORT } from '@pnpm/registry-mock'
import { createTempStore } from '@pnpm/testing.temp-store'
import { installConfigDeps } from '@pnpm/config.deps-installer'
import { loadJsonFileSync } from 'load-json-file'
import { sync as readYamlFile } from 'read-yaml-file'
const registry = `http://localhost:${REGISTRY_MOCK_PORT}/`
test('configuration dependency is installed', async () => {
function makeEnvLockfile (deps: Record<string, { version: string, integrity: string }>): EnvLockfile {
const lockfile = createEnvLockfile()
for (const [name, { version, integrity }] of Object.entries(deps)) {
const pkgKey = `${name}@${version}`
lockfile.importers['.'].configDependencies[name] = { specifier: version, version }
lockfile.packages[pkgKey] = { resolution: { integrity } }
lockfile.snapshots[pkgKey] = {}
}
return lockfile
}
test('configuration dependency is installed from env lockfile', async () => {
prepareEmpty()
const { storeController, storeDir } = createTempStore()
let configDeps: Record<string, string> = {
'@pnpm.e2e/foo': `100.0.0+${getIntegrity('@pnpm.e2e/foo', '100.0.0')}`,
}
await installConfigDeps(configDeps, {
const lockfile = makeEnvLockfile({
'@pnpm.e2e/foo': { version: '100.0.0', integrity: getIntegrity('@pnpm.e2e/foo', '100.0.0') },
})
await installConfigDeps(lockfile, {
registries: {
default: registry,
},
@@ -32,9 +45,11 @@ test('configuration dependency is installed', async () => {
}
// Dependency is updated
configDeps!['@pnpm.e2e/foo'] = `100.1.0+${getIntegrity('@pnpm.e2e/foo', '100.1.0')}`
const lockfile2 = makeEnvLockfile({
'@pnpm.e2e/foo': { version: '100.1.0', integrity: getIntegrity('@pnpm.e2e/foo', '100.1.0') },
})
await installConfigDeps(configDeps, {
await installConfigDeps(lockfile2, {
registries: {
default: registry,
},
@@ -50,9 +65,9 @@ test('configuration dependency is installed', async () => {
}
// Dependency is removed
configDeps! = {}
const lockfile3 = createEnvLockfile()
await installConfigDeps(configDeps, {
await installConfigDeps(lockfile3, {
registries: {
default: registry,
},
@@ -74,10 +89,13 @@ test('installation fails if the checksum of the config dependency is invalid', a
},
})
const configDeps: Record<string, string> = {
'@pnpm.e2e/foo': '100.0.0+sha512-00000000000000000000000000000000000000000000000000000000000000000000000000000000000000==',
}
await expect(installConfigDeps(configDeps, {
const lockfile = makeEnvLockfile({
'@pnpm.e2e/foo': {
version: '100.0.0',
integrity: 'sha512-00000000000000000000000000000000000000000000000000000000000000000000000000000000000000==',
},
})
await expect(installConfigDeps(lockfile, {
registries: {
default: registry,
},
@@ -87,7 +105,41 @@ test('installation fails if the checksum of the config dependency is invalid', a
})).rejects.toThrow('Got unexpected checksum for')
})
test('installation fails if the config dependency does not have a checksum', async () => {
test('migration: installs from old inline integrity format and creates env lockfile', async () => {
prepareEmpty()
const { storeController, storeDir } = createTempStore()
// Old format: ConfigDependencies with inline integrity
const integrity = getIntegrity('@pnpm.e2e/foo', '100.0.0')
const configDeps: Record<string, string> = {
'@pnpm.e2e/foo': `100.0.0+${integrity}`,
}
await installConfigDeps(configDeps, {
registries: {
default: registry,
},
rootDir: process.cwd(),
store: storeController,
storeDir,
})
{
const configDepManifest = loadJsonFileSync<{ name: string, version: string }>('node_modules/.pnpm-config/@pnpm.e2e/foo/package.json')
expect(configDepManifest.name).toBe('@pnpm.e2e/foo')
expect(configDepManifest.version).toBe('100.0.0')
}
// Verify pnpm-lock.env.yaml was created with expected content
const envLockfile = readYamlFile<EnvLockfile>('pnpm-lock.env.yaml')
expect(envLockfile.lockfileVersion).toBeDefined()
expect(envLockfile.importers['.'].configDependencies['@pnpm.e2e/foo']).toEqual({
specifier: '100.0.0',
version: '100.0.0',
})
expect((envLockfile.packages['@pnpm.e2e/foo@100.0.0'].resolution as { integrity: string }).integrity).toBe(integrity)
})
test('installation fails if the config dependency does not have a checksum (old format)', async () => {
prepareEmpty()
const { storeController, storeDir } = createTempStore({
clientOptions: {
@@ -107,5 +159,5 @@ test('installation fails if the config dependency does not have a checksum', asy
rootDir: process.cwd(),
store: storeController,
storeDir,
})).rejects.toThrow("doesn't have an integrity checksum")
})).rejects.toThrow('already in clean-specifier form')
})

View File

@@ -1,83 +0,0 @@
import { normalizeConfigDeps } from '@pnpm/config.deps-installer'
import { getIntegrity, REGISTRY_MOCK_PORT } from '@pnpm/registry-mock'
const registry = `http://localhost:${REGISTRY_MOCK_PORT}/`
test('normalizes string spec with integrity to structured dependency and computes tarball', () => {
const deps = normalizeConfigDeps({
'@pnpm.e2e/foo': `100.0.0+${getIntegrity('@pnpm.e2e/foo', '100.0.0')}`,
}, {
registries: {
default: registry,
},
})
expect(deps['@pnpm.e2e/foo']).toStrictEqual({
version: '100.0.0',
resolution: {
integrity: getIntegrity('@pnpm.e2e/foo', '100.0.0'),
tarball: `${registry}@pnpm.e2e/foo/-/foo-100.0.0.tgz`,
},
})
})
test('keeps provided tarball when resolution.tarball is specified', () => {
const customTarball = 'https://custom.example.com/foo-100.0.0.tgz'
const deps = normalizeConfigDeps({
'@pnpm.e2e/foo': {
integrity: `100.0.0+${getIntegrity('@pnpm.e2e/foo', '100.0.0')}`,
tarball: customTarball,
},
}, {
registries: {
default: registry,
},
})
expect(deps['@pnpm.e2e/foo']).toStrictEqual({
version: '100.0.0',
resolution: {
integrity: getIntegrity('@pnpm.e2e/foo', '100.0.0'),
tarball: customTarball,
},
})
})
test('computes tarball when not specified in object spec', () => {
const deps = normalizeConfigDeps({
'@pnpm.e2e/foo': {
integrity: `100.0.0+${getIntegrity('@pnpm.e2e/foo', '100.0.0')}`,
},
}, {
registries: {
default: registry,
},
})
expect(deps['@pnpm.e2e/foo']).toStrictEqual({
version: '100.0.0',
resolution: {
integrity: getIntegrity('@pnpm.e2e/foo', '100.0.0'),
tarball: `${registry}@pnpm.e2e/foo/-/foo-100.0.0.tgz`,
},
})
})
test('throws when string spec does not include integrity', () => {
expect(() => normalizeConfigDeps({
'@pnpm.e2e/foo': '100.0.0',
}, {
registries: {
default: registry,
},
})).toThrow("doesn't have an integrity checksum")
})
test('throws when object spec does not include integrity', () => {
expect(() => normalizeConfigDeps({
'@pnpm.e2e/foo': {
integrity: '100.0.0',
},
}, {
registries: {
default: registry,
},
})).toThrow("doesn't have an integrity checksum")
})

View File

@@ -1,7 +1,8 @@
import path from 'path'
import { resolveConfigDeps } from '@pnpm/config.deps-installer'
import { readEnvLockfile } from '@pnpm/lockfile.fs'
import { prepareEmpty } from '@pnpm/prepare'
import { getIntegrity, REGISTRY_MOCK_PORT } from '@pnpm/registry-mock'
import { resolveConfigDeps } from '@pnpm/config.deps-installer'
import { createTempStore } from '@pnpm/testing.temp-store'
import { sync as readYamlFile } from 'read-yaml-file'
@@ -22,8 +23,23 @@ test('configuration dependency is resolved', async () => {
storeDir,
})
// Workspace manifest should have a clean specifier (no integrity)
const workspaceManifest = readYamlFile<{ configDependencies: Record<string, string> }>('pnpm-workspace.yaml')
expect(workspaceManifest.configDependencies).toStrictEqual({
'@pnpm.e2e/foo': `100.0.0+${getIntegrity('@pnpm.e2e/foo', '100.0.0')}`,
'@pnpm.e2e/foo': '100.0.0',
})
// Env lockfile should contain the resolved dependency with integrity
const envLockfile = await readEnvLockfile(process.cwd())
expect(envLockfile).not.toBeNull()
expect(envLockfile!.importers['.'].configDependencies['@pnpm.e2e/foo']).toStrictEqual({
specifier: '100.0.0',
version: '100.0.0',
})
expect(envLockfile!.packages['@pnpm.e2e/foo@100.0.0']).toStrictEqual({
resolution: {
integrity: getIntegrity('@pnpm.e2e/foo', '100.0.0'),
},
})
expect(envLockfile!.snapshots['@pnpm.e2e/foo@100.0.0']).toStrictEqual({})
})

View File

@@ -15,6 +15,18 @@
{
"path": "../../fs/read-modules-dir"
},
{
"path": "../../lockfile/fs"
},
{
"path": "../../lockfile/pruner"
},
{
"path": "../../lockfile/types"
},
{
"path": "../../lockfile/utils"
},
{
"path": "../../network/auth-header"
},
@@ -24,6 +36,9 @@
{
"path": "../../packages/calc-dep-state"
},
{
"path": "../../packages/constants"
},
{
"path": "../../packages/core-loggers"
},
@@ -36,6 +51,9 @@
{
"path": "../../packages/types"
},
{
"path": "../../pkg-manager/resolve-dependencies"
},
{
"path": "../../pkg-manifest/read-package-json"
},
@@ -45,9 +63,15 @@
{
"path": "../../store/package-store"
},
{
"path": "../../store/store-controller-types"
},
{
"path": "../../testing/temp-store"
},
{
"path": "../../worker"
},
{
"path": "../config-writer"
},

View File

@@ -1,19 +1,19 @@
import path from 'path'
import {
iterateHashedGraphNodes,
iteratePkgMeta,
lockfileToDepGraph,
calcGraphNodeHash,
type PkgMeta,
type PkgMetaAndSnapshot,
type DepsGraph,
type PkgMetaIterator,
type HashedDepPath,
type DepsStateCache,
} from '@pnpm/calc-dep-state'
import type { LockfileObject, PackageSnapshot } from '@pnpm/lockfile.fs'
import type { LockfileObject } from '@pnpm/lockfile.fs'
import {
nameVerFromPkgSnapshot,
} from '@pnpm/lockfile.utils'
import type { AllowBuild, DepPath, PkgIdWithPatchHash } from '@pnpm/types'
import type { AllowBuild, DepPath } from '@pnpm/types'
import * as dp from '@pnpm/dependency-path'
interface PkgSnapshotWithLocation {
@@ -69,32 +69,7 @@ export function * iteratePkgsForVirtualStore (lockfile: LockfileObject, opts: {
}
}
interface PkgMetaAndSnapshot extends PkgMeta {
pkgSnapshot: PackageSnapshot
pkgIdWithPatchHash: PkgIdWithPatchHash
}
function hashDependencyPaths (lockfile: LockfileObject, allowBuild?: AllowBuild): IterableIterator<HashedDepPath<PkgMetaAndSnapshot>> {
const graph = lockfileToDepGraph(lockfile)
return iterateHashedGraphNodes(graph, iteratePkgMeta(lockfile, graph), allowBuild)
}
function * iteratePkgMeta (lockfile: LockfileObject, graph: DepsGraph<DepPath>): PkgMetaIterator<PkgMetaAndSnapshot> {
if (lockfile.packages == null) {
return
}
for (const depPath in lockfile.packages) {
if (!Object.hasOwn(lockfile.packages, depPath)) {
continue
}
const pkgSnapshot = lockfile.packages[depPath as DepPath]
const { name, version } = nameVerFromPkgSnapshot(depPath, pkgSnapshot)
yield {
name,
version,
depPath: depPath as DepPath,
pkgIdWithPatchHash: graph[depPath as DepPath].pkgIdWithPatchHash ?? dp.getPkgIdWithPatchHash(depPath as DepPath),
pkgSnapshot,
}
}
}

View File

@@ -0,0 +1,85 @@
import { promises as fs } from 'fs'
import path from 'path'
import util from 'util'
import { ENV_LOCKFILE, LOCKFILE_VERSION } from '@pnpm/constants'
import { PnpmError } from '@pnpm/error'
import type { EnvLockfile } from '@pnpm/lockfile.types'
import { sortDirectKeys } from '@pnpm/object.key-sorting'
import yaml from 'js-yaml'
import stripBom from 'strip-bom'
import writeFileAtomic from 'write-file-atomic'
import { lockfileYamlDump } from './write.js'
export function createEnvLockfile (): EnvLockfile {
return {
lockfileVersion: LOCKFILE_VERSION,
importers: {
'.': {
configDependencies: {},
},
},
packages: {},
snapshots: {},
}
}
export async function readEnvLockfile (rootDir: string): Promise<EnvLockfile | null> {
const lockfilePath = path.join(rootDir, ENV_LOCKFILE)
let rawContent: string
try {
rawContent = stripBom(await fs.readFile(lockfilePath, 'utf8'))
} catch (err: unknown) {
if (util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT') {
return null
}
throw err
}
const parsed = yaml.load(rawContent)
if (parsed == null || typeof parsed !== 'object') {
throw new PnpmError('INVALID_ENV_LOCKFILE', `Invalid env lockfile at ${lockfilePath}: expected a YAML object`)
}
const lockfile = parsed as Record<string, unknown>
if (typeof lockfile.lockfileVersion !== 'string') {
throw new PnpmError('INVALID_ENV_LOCKFILE', `Invalid env lockfile at ${lockfilePath}: missing or non-string "lockfileVersion"`)
}
if (lockfile.importers == null || typeof lockfile.importers !== 'object') {
throw new PnpmError('INVALID_ENV_LOCKFILE', `Invalid env lockfile at ${lockfilePath}: missing or invalid "importers"`)
}
if (lockfile.packages == null || typeof lockfile.packages !== 'object') {
throw new PnpmError('INVALID_ENV_LOCKFILE', `Invalid env lockfile at ${lockfilePath}: missing or invalid "packages"`)
}
if (lockfile.snapshots == null || typeof lockfile.snapshots !== 'object') {
throw new PnpmError('INVALID_ENV_LOCKFILE', `Invalid env lockfile at ${lockfilePath}: missing or invalid "snapshots"`)
}
const envLockfile = parsed as EnvLockfile
if (!envLockfile.importers['.']) {
envLockfile.importers['.'] = { configDependencies: {} }
} else if (!envLockfile.importers['.'].configDependencies) {
envLockfile.importers['.'].configDependencies = {}
}
return envLockfile
}
export async function writeEnvLockfile (rootDir: string, lockfile: EnvLockfile): Promise<void> {
const lockfilePath = path.join(rootDir, ENV_LOCKFILE)
const sorted = sortEnvLockfile(lockfile)
const yamlDoc = lockfileYamlDump(sorted)
return writeFileAtomic(lockfilePath, yamlDoc)
}
function sortEnvLockfile (lockfile: EnvLockfile): EnvLockfile {
const importer: EnvLockfile['importers']['.'] = {
configDependencies: sortDirectKeys(lockfile.importers['.']?.configDependencies ?? {}),
}
if (lockfile.importers['.'].packageManagerDependencies && Object.keys(lockfile.importers['.'].packageManagerDependencies).length > 0) {
importer.packageManagerDependencies = sortDirectKeys(lockfile.importers['.'].packageManagerDependencies)
}
return {
lockfileVersion: lockfile.lockfileVersion,
importers: {
'.': importer,
},
packages: sortDirectKeys(lockfile.packages),
snapshots: sortDirectKeys(lockfile.snapshots),
}
}

View File

@@ -10,4 +10,5 @@ export { getLockfileImporterId } from './getLockfileImporterId.js'
export * from '@pnpm/lockfile.types'
export * from './read.js'
export { cleanGitBranchLockfiles } from './gitBranchLockfile.js'
export { convertToLockfileFile } from './lockfileFormatConverters.js'
export { convertToLockfileFile, convertToLockfileObject } from './lockfileFormatConverters.js'
export { createEnvLockfile, readEnvLockfile, writeEnvLockfile } from './envLockfile.js'

View File

@@ -5,32 +5,24 @@ import { WANTED_LOCKFILE } from '@pnpm/constants'
import rimraf from '@zkochan/rimraf'
import yaml from 'js-yaml'
import { isEmpty } from 'ramda'
import writeFileAtomicCB from 'write-file-atomic'
import writeFileAtomic from 'write-file-atomic'
import { lockfileLogger as logger } from './logger.js'
import { sortLockfileKeys } from './sortLockfileKeys.js'
import { getWantedLockfileName } from './lockfileName.js'
import { convertToLockfileFile } from './lockfileFormatConverters.js'
async function writeFileAtomic (filename: string, data: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
writeFileAtomicCB(filename, data, {}, (err?: Error) => {
if (err != null) {
reject(err)
} else {
resolve()
}
})
})
}
const LOCKFILE_YAML_FORMAT = {
blankLines: true,
lineWidth: -1, // This is setting line width to never wrap
lineWidth: -1,
noCompatMode: true,
noRefs: true,
sortKeys: false,
}
export function lockfileYamlDump (obj: object): string {
return yaml.dump(obj, LOCKFILE_YAML_FORMAT)
}
export async function writeWantedLockfile (
pkgPath: string,
wantedLockfile: LockfileObject,
@@ -77,7 +69,7 @@ export function writeLockfileFile (
function yamlStringify (lockfile: LockfileFile) {
const sortedLockfile = sortLockfileKeys(lockfile as LockfileFile)
return yaml.dump(sortedLockfile, LOCKFILE_YAML_FORMAT)
return lockfileYamlDump(sortedLockfile)
}
export function isEmptyLockfile (lockfile: LockfileObject): boolean {

View File

@@ -4,6 +4,7 @@ import type { PlatformAssetTarget } from '@pnpm/resolver-base'
export type { ProjectId }
export * from './lockfileFileTypes.js'
import type { SpecifierAndResolution } from './lockfileFileTypes.js'
export interface LockfileSettings {
autoInstallPeers?: boolean
@@ -164,6 +165,18 @@ export type PackageBin = string | { [name: string]: string }
*/
export type ResolvedDependencies = Record<string, string>
export interface EnvLockfile {
lockfileVersion: string
importers: {
'.': {
configDependencies: Record<string, SpecifierAndResolution>
packageManagerDependencies?: Record<string, SpecifierAndResolution>
}
}
packages: Record<string, LockfilePackageInfo>
snapshots: Record<string, LockfilePackageSnapshot>
}
export interface CatalogSnapshots {
[catalogName: string]: { [dependencyName: string]: ResolvedCatalogEntry }
}

View File

@@ -5,6 +5,7 @@ export { packageIdFromSnapshot } from './packageIdFromSnapshot.js'
export { packageIsIndependent } from './packageIsIndependent.js'
export { pkgSnapshotToResolution } from './pkgSnapshotToResolution.js'
export { refIsLocalTarball, refIsLocalDirectory } from './refIsLocalTarball.js'
export { toLockfileResolution } from './toLockfileResolution.js'
export * from '@pnpm/lockfile.types'
// for backward compatibility

View File

@@ -0,0 +1,46 @@
import type { LockfileResolution } from '@pnpm/lockfile.types'
import type { Resolution } from '@pnpm/resolver-base'
import getNpmTarballUrl from 'get-npm-tarball-url'
export function toLockfileResolution (
pkg: {
name: string
version: string
},
resolution: Resolution,
registry: string,
lockfileIncludeTarballUrl?: boolean
): LockfileResolution {
if (resolution.type !== undefined || !resolution['integrity']) {
return resolution as LockfileResolution
}
if (lockfileIncludeTarballUrl) {
return {
integrity: resolution['integrity'],
tarball: resolution['tarball'],
}
}
if (lockfileIncludeTarballUrl === false) {
return {
integrity: resolution['integrity'],
}
}
// Sometimes packages are hosted under non-standard tarball URLs.
// For instance, when they are hosted on npm Enterprise. See https://github.com/pnpm/pnpm/issues/867
// Or in other weird cases, like https://github.com/pnpm/pnpm/issues/1072
const expectedTarball = getNpmTarballUrl(pkg.name, pkg.version, { registry })
const actualTarball = resolution['tarball'].replaceAll('%2f', '/')
if (removeProtocol(expectedTarball) !== removeProtocol(actualTarball)) {
return {
integrity: resolution['integrity'],
tarball: resolution['tarball'],
}
}
return {
integrity: resolution['integrity'],
}
}
function removeProtocol (url: string): string {
return url.split('://')[1]
}

View File

@@ -2,7 +2,8 @@ import { ENGINE_NAME } from '@pnpm/constants'
import { getPkgIdWithPatchHash, refToRelative } from '@pnpm/dependency-path'
import type { AllowBuild, DepPath, PkgIdWithPatchHash } from '@pnpm/types'
import { hashObjectWithoutSorting, hashObject } from '@pnpm/crypto.object-hasher'
import type { LockfileResolution, LockfileObject } from '@pnpm/lockfile.types'
import type { LockfileResolution, LockfileObject, PackageSnapshot } from '@pnpm/lockfile.types'
import { nameVerFromPkgSnapshot } from '@pnpm/lockfile.utils'
export type DepsGraph<T extends string> = Record<T, DepsGraphNode<T>>
@@ -153,6 +154,31 @@ function formatGlobalVirtualStorePath (name: string, version: string, hexDigest:
return `${prefix}${name}/${version}/${hexDigest}`
}
export interface PkgMetaAndSnapshot extends PkgMeta {
pkgSnapshot: PackageSnapshot
pkgIdWithPatchHash: PkgIdWithPatchHash
}
export function * iteratePkgMeta (lockfile: LockfileObject, graph: DepsGraph<DepPath>): PkgMetaIterator<PkgMetaAndSnapshot> {
if (lockfile.packages == null) {
return
}
for (const depPath in lockfile.packages) {
if (!Object.hasOwn(lockfile.packages, depPath)) {
continue
}
const pkgSnapshot = lockfile.packages[depPath as DepPath]
const { name, version } = nameVerFromPkgSnapshot(depPath, pkgSnapshot)
yield {
name,
version,
depPath: depPath as DepPath,
pkgIdWithPatchHash: graph[depPath as DepPath]?.pkgIdWithPatchHash ?? getPkgIdWithPatchHash(depPath as DepPath),
pkgSnapshot,
}
}
}
export function lockfileToDepGraph (lockfile: LockfileObject): DepsGraph<DepPath> {
const graph: DepsGraph<DepPath> = {}
if (lockfile.packages != null) {

View File

@@ -1,4 +1,5 @@
export const WANTED_LOCKFILE = 'pnpm-lock.yaml'
export const ENV_LOCKFILE = 'pnpm-lock.env.yaml'
export const LOCKFILE_MAJOR_VERSION = '9'
export const LOCKFILE_VERSION = `${LOCKFILE_MAJOR_VERSION}.0`

View File

@@ -35,8 +35,6 @@
"@pnpm/cli-meta": "workspace:*",
"@pnpm/cli-utils": "workspace:*",
"@pnpm/os.env.path-extender": "catalog:",
"@zkochan/cmd-shim": "catalog:",
"@zkochan/rimraf": "catalog:",
"render-help": "catalog:"
},
"peerDependencies": {

View File

@@ -1,3 +1,4 @@
import { spawnSync } from 'child_process'
import fs from 'fs'
import path from 'path'
import { detectIfCurrentPkgIsExecutable, packageManager } from '@pnpm/cli-meta'
@@ -9,8 +10,6 @@ import {
type PathExtenderReport,
} from '@pnpm/os.env.path-extender'
import renderHelp from 'render-help'
import rimraf from '@zkochan/rimraf'
import cmdShim from '@zkochan/cmd-shim'
export const rcOptionsTypes = (): Record<string, unknown> => ({})
@@ -53,32 +52,50 @@ function getExecPath (): string {
}
/**
* Copy the CLI into a directory on the PATH and create a command shim to run it.
* Without the shim, `pnpm self-update` on Windows cannot replace the running executable
* and fails with: `EPERM: operation not permitted, unlink 'C:\Users\<user>\AppData\Local\pnpm\pnpm.exe'`.
* Related issue: https://github.com/pnpm/pnpm/issues/5700
* Install the CLI as a global package using `pnpm add -g file:<dir>`.
* This places pnpm in the standard global directory alongside other
* globally installed packages.
*/
async function copyCli (currentLocation: string, targetDir: string): Promise<void> {
const toolsDir = path.join(targetDir, '.tools/pnpm-exe', packageManager.version)
const newExecPath = path.join(toolsDir, path.basename(currentLocation))
if (path.relative(newExecPath, currentLocation) === '') return
function installCliGlobally (execPath: string, pnpmHomeDir: string): void {
const execDir = path.dirname(execPath)
const execName = path.basename(execPath)
const pkgJsonPath = path.join(execDir, 'package.json')
// Write a package.json if one doesn't already exist.
// (Updated tarballs on GitHub Pages will ship with package.json already.)
let createdPkgJson = false
if (!fs.existsSync(pkgJsonPath)) {
fs.writeFileSync(pkgJsonPath, JSON.stringify({
name: '@pnpm/exe',
version: packageManager.version,
bin: { pnpm: execName },
}))
createdPkgJson = true
}
logger.info({
message: `Copying pnpm CLI from ${currentLocation} to ${newExecPath}`,
message: `Installing pnpm CLI globally from ${execDir}`,
prefix: process.cwd(),
})
fs.mkdirSync(toolsDir, { recursive: true })
rimraf.sync(newExecPath)
fs.copyFileSync(currentLocation, newExecPath)
// For SEA binaries, also copy the dist/ directory that lives alongside the binary.
const distSrc = path.join(path.dirname(currentLocation), 'dist')
if (fs.existsSync(distSrc)) {
const distDest = path.join(toolsDir, 'dist')
fs.rmSync(distDest, { recursive: true, force: true })
fs.cpSync(distSrc, distDest, { recursive: true })
try {
const { status, error } = spawnSync(execPath, ['add', '-g', `file:${execDir}`], {
stdio: 'inherit',
env: {
...process.env,
PNPM_HOME: pnpmHomeDir,
},
})
if (error) throw error
if (status !== 0) {
throw new Error(`Failed to install pnpm globally (exit code ${status})`)
}
} finally {
if (createdPkgJson) {
fs.unlinkSync(pkgJsonPath)
}
}
await cmdShim(newExecPath, path.join(targetDir, 'pnpm'), {
createPwshFile: false,
})
}
function createPnpxScripts (targetDir: string): void {
@@ -118,7 +135,7 @@ export async function handler (
): Promise<string> {
const execPath = getExecPath()
if (execPath.match(/\.[cm]?js$/) == null) {
await copyCli(execPath, opts.pnpmHomeDir)
installCliGlobally(execPath, opts.pnpmHomeDir)
createPnpxScripts(opts.pnpmHomeDir)
}
try {

View File

@@ -18,11 +18,6 @@ jest.unstable_mockModule('fs', () => {
}
})
jest.mock('@zkochan/cmd-shim', () => ({
__esModule: true,
default: jest.fn(),
}))
const { addDirToEnvPath } = await import('@pnpm/os.env.path-extender')
const { setup } = await import('@pnpm/plugin-commands-setup')

View File

@@ -143,11 +143,25 @@ export type AllowedDeprecatedVersions = Record<string, string>
type VersionWithIntegrity = string
/**
* Old format (inline integrity in pnpm-workspace.yaml):
* "@my-org/cfg": "1.2.0+sha512-XYZ"
* or { tarball: "...", integrity: "1.2.0+sha512-XYZ" }
*
* New format (plain specifiers in pnpm-workspace.yaml, integrity in pnpm-lock.env.yaml):
* "@my-org/cfg": "^1.2.0"
*/
export type ConfigDependencies = Record<string, VersionWithIntegrity | {
tarball?: string
integrity: VersionWithIntegrity
}>
/**
* Clean specifiers for configDependencies in pnpm-workspace.yaml (new format).
* Integrity info is stored in pnpm-lock.env.yaml instead.
*/
export type ConfigDependencySpecifiers = Record<string, string>
export interface AuditConfig {
ignoreCves?: string[]
ignoreGhsas?: string[]

View File

@@ -59,7 +59,6 @@
"@pnpm/workspace.spec-parser": "workspace:*",
"@yarnpkg/core": "catalog:",
"filenamify": "catalog:",
"get-npm-tarball-url": "catalog:",
"graph-cycles": "catalog:",
"is-inner-link": "catalog:",
"is-subdir": "catalog:",

View File

@@ -1,14 +1,12 @@
import { logger } from '@pnpm/logger'
import {
type LockfileObject,
type LockfileResolution,
type PackageSnapshot,
pruneSharedLockfile,
} from '@pnpm/lockfile.pruner'
import type { Resolution } from '@pnpm/resolver-base'
import { toLockfileResolution } from '@pnpm/lockfile.utils'
import type { DepPath, Registries } from '@pnpm/types'
import * as dp from '@pnpm/dependency-path'
import getNpmTarballUrl from 'get-npm-tarball-url'
import type { KeyValuePair } from 'ramda'
import { partition } from 'ramda'
import { depPathToRef } from './depPathToRef.js'
@@ -178,45 +176,3 @@ function updateResolvedDeps (
)
}
function toLockfileResolution (
pkg: {
name: string
version: string
},
resolution: Resolution,
registry: string,
lockfileIncludeTarballUrl?: boolean
): LockfileResolution {
if (resolution.type !== undefined || !resolution['integrity']) {
return resolution as LockfileResolution
}
if (lockfileIncludeTarballUrl) {
return {
integrity: resolution['integrity'],
tarball: resolution['tarball'],
}
}
if (lockfileIncludeTarballUrl === false) {
return {
integrity: resolution['integrity'],
}
}
// Sometimes packages are hosted under non-standard tarball URLs.
// For instance, when they are hosted on npm Enterprise. See https://github.com/pnpm/pnpm/issues/867
// Or in other weird cases, like https://github.com/pnpm/pnpm/issues/1072
const expectedTarball = getNpmTarballUrl(pkg.name, pkg.version, { registry })
const actualTarball = resolution['tarball'].replace('%2f', '/')
if (removeProtocol(expectedTarball) !== removeProtocol(actualTarball)) {
return {
integrity: resolution['integrity'],
tarball: resolution['tarball'],
}
}
return {
integrity: resolution['integrity'],
}
}
function removeProtocol (url: string): string {
return url.split('://')[1]
}

86
pnpm-lock.yaml generated
View File

@@ -2094,6 +2094,9 @@ importers:
'@pnpm/config.config-writer':
specifier: workspace:*
version: link:../config-writer
'@pnpm/constants':
specifier: workspace:*
version: link:../../packages/constants
'@pnpm/core-loggers':
specifier: workspace:*
version: link:../../packages/core-loggers
@@ -2103,6 +2106,18 @@ importers:
'@pnpm/fetch':
specifier: workspace:*
version: link:../../network/fetch
'@pnpm/lockfile.fs':
specifier: workspace:*
version: link:../../lockfile/fs
'@pnpm/lockfile.pruner':
specifier: workspace:*
version: link:../../lockfile/pruner
'@pnpm/lockfile.types':
specifier: workspace:*
version: link:../../lockfile/types
'@pnpm/lockfile.utils':
specifier: workspace:*
version: link:../../lockfile/utils
'@pnpm/logger':
specifier: 'catalog:'
version: 1001.0.1
@@ -2127,9 +2142,18 @@ importers:
'@pnpm/read-package-json':
specifier: workspace:*
version: link:../../pkg-manifest/read-package-json
'@pnpm/resolve-dependencies':
specifier: workspace:*
version: link:../../pkg-manager/resolve-dependencies
'@pnpm/store-controller-types':
specifier: workspace:*
version: link:../../store/store-controller-types
'@pnpm/types':
specifier: workspace:*
version: link:../../packages/types
'@pnpm/worker':
specifier: workspace:^
version: link:../../worker
'@zkochan/rimraf':
specifier: 'catalog:'
version: 3.0.2
@@ -4699,12 +4723,6 @@ importers:
'@pnpm/os.env.path-extender':
specifier: 'catalog:'
version: 2.0.3
'@zkochan/cmd-shim':
specifier: 'catalog:'
version: 8.0.1
'@zkochan/rimraf':
specifier: 'catalog:'
version: 3.0.2
render-help:
specifier: 'catalog:'
version: 1.0.3
@@ -6342,9 +6360,6 @@ importers:
filenamify:
specifier: 'catalog:'
version: 6.0.0
get-npm-tarball-url:
specifier: 'catalog:'
version: 2.1.0
graph-cycles:
specifier: 'catalog:'
version: 3.0.0
@@ -6646,6 +6661,9 @@ importers:
'@pnpm/config':
specifier: workspace:*
version: link:../config/config
'@pnpm/config.deps-installer':
specifier: workspace:*
version: link:../config/deps-installer
'@pnpm/config.version-policy':
specifier: workspace:*
version: link:../config/version-policy
@@ -6679,6 +6697,9 @@ importers:
'@pnpm/fs.msgpack-file':
specifier: workspace:*
version: link:../fs/msgpack-file
'@pnpm/lockfile.fs':
specifier: workspace:*
version: link:../lockfile/fs
'@pnpm/lockfile.types':
specifier: workspace:*
version: link:../lockfile/types
@@ -6766,6 +6787,9 @@ importers:
'@pnpm/runtime.commands':
specifier: workspace:*
version: link:../runtime/commands
'@pnpm/store-connection-manager':
specifier: workspace:*
version: link:../store/store-connection-manager
'@pnpm/store.cafs':
specifier: workspace:*
version: link:../store/cafs
@@ -6781,9 +6805,6 @@ importers:
'@pnpm/test-ipc-server':
specifier: workspace:*
version: link:../__utils__/test-ipc-server
'@pnpm/tools.path':
specifier: workspace:*
version: link:../tools/path
'@pnpm/tools.plugin-commands-self-updater':
specifier: workspace:*
version: link:../tools/plugin-commands-self-updater
@@ -8924,14 +8945,11 @@ importers:
specifier: workspace:*
version: 'link:'
tools/path:
devDependencies:
'@pnpm/tools.path':
specifier: workspace:*
version: 'link:'
tools/plugin-commands-self-updater:
dependencies:
'@pnpm/calc-dep-state':
specifier: workspace:*
version: link:../../packages/calc-dep-state
'@pnpm/cli-meta':
specifier: workspace:*
version: link:../../cli/cli-meta
@@ -8944,30 +8962,42 @@ importers:
'@pnpm/config':
specifier: workspace:*
version: link:../../config/config
'@pnpm/config.deps-installer':
specifier: workspace:*
version: link:../../config/deps-installer
'@pnpm/error':
specifier: workspace:*
version: link:../../packages/error
'@pnpm/exec.pnpm-cli-runner':
'@pnpm/global.commands':
specifier: workspace:*
version: link:../../exec/pnpm-cli-runner
version: link:../../global/commands
'@pnpm/global.packages':
specifier: workspace:*
version: link:../../global/packages
'@pnpm/headless':
specifier: workspace:*
version: link:../../pkg-manager/headless
'@pnpm/link-bins':
specifier: workspace:*
version: link:../../pkg-manager/link-bins
'@pnpm/lockfile.types':
specifier: workspace:*
version: link:../../lockfile/types
'@pnpm/logger':
specifier: 'catalog:'
version: 1001.0.1
'@pnpm/package-store':
specifier: workspace:*
version: link:../../store/package-store
'@pnpm/read-project-manifest':
specifier: workspace:*
version: link:../../pkg-manifest/read-project-manifest
'@pnpm/tools.path':
'@pnpm/store-connection-manager':
specifier: workspace:*
version: link:../path
'@zkochan/rimraf':
specifier: 'catalog:'
version: 3.0.2
path-temp:
specifier: 'catalog:'
version: 2.1.1
version: link:../../store/store-connection-manager
'@pnpm/types':
specifier: workspace:*
version: link:../../packages/types
ramda:
specifier: 'catalog:'
version: '@pnpm/ramda@0.28.1'

View File

@@ -85,6 +85,7 @@
"@pnpm/command": "workspace:*",
"@pnpm/common-cli-options-help": "workspace:*",
"@pnpm/config": "workspace:*",
"@pnpm/config.deps-installer": "workspace:*",
"@pnpm/config.version-policy": "workspace:*",
"@pnpm/constants": "workspace:*",
"@pnpm/core-loggers": "workspace:*",
@@ -97,6 +98,7 @@
"@pnpm/filter-workspace-packages": "workspace:*",
"@pnpm/find-workspace-dir": "workspace:*",
"@pnpm/fs.msgpack-file": "workspace:*",
"@pnpm/lockfile.fs": "workspace:*",
"@pnpm/lockfile.types": "workspace:*",
"@pnpm/logger": "workspace:*",
"@pnpm/modules-yaml": "workspace:*",
@@ -127,12 +129,12 @@
"@pnpm/read-project-manifest": "workspace:*",
"@pnpm/registry-mock": "catalog:",
"@pnpm/run-npm": "workspace:*",
"@pnpm/store-connection-manager": "workspace:*",
"@pnpm/store.index": "workspace:*",
"@pnpm/store.cafs": "workspace:*",
"@pnpm/tabtab": "catalog:",
"@pnpm/test-fixtures": "workspace:*",
"@pnpm/test-ipc-server": "workspace:*",
"@pnpm/tools.path": "workspace:*",
"@pnpm/tools.plugin-commands-self-updater": "workspace:*",
"@pnpm/types": "workspace:*",
"@pnpm/util.lex-comparator": "catalog:",

View File

@@ -8,7 +8,7 @@ if (!global['pnpm__startedAt']) {
}
import loudRejection from 'loud-rejection'
import { packageManager, isExecutedByCorepack } from '@pnpm/cli-meta'
import { getConfig } from '@pnpm/cli-utils'
import { getConfig, installConfigDepsAndLoadHooks } from '@pnpm/cli-utils'
import type { Config, WantedPackageManager } from '@pnpm/config'
import { executionTimeLogger, scopeLogger } from '@pnpm/core-loggers'
import { PnpmError } from '@pnpm/error'
@@ -122,6 +122,7 @@ export async function main (inputArgv: string[]): Promise<void> {
}
}
}
config = await installConfigDepsAndLoadHooks(config) as typeof config
if (isDlxOrCreateCommand) {
config.useStderr = true
}

View File

@@ -1,16 +1,19 @@
import path from 'path'
import type { Config } from '@pnpm/config'
import { PnpmError } from '@pnpm/error'
import { globalWarn } from '@pnpm/logger'
import { packageManager } from '@pnpm/cli-meta'
import type { Config } from '@pnpm/config'
import { resolvePackageManagerIntegrities, isPackageManagerResolved } from '@pnpm/config.deps-installer'
import { PnpmError } from '@pnpm/error'
import { prependDirsToPath } from '@pnpm/env.path'
import { installPnpmToTools } from '@pnpm/tools.plugin-commands-self-updater'
import { globalWarn } from '@pnpm/logger'
import { readEnvLockfile } from '@pnpm/lockfile.fs'
import { createStoreController } from '@pnpm/store-connection-manager'
import { installPnpmToStore } from '@pnpm/tools.plugin-commands-self-updater'
import spawn from 'cross-spawn'
import semver from 'semver'
export async function switchCliVersion (config: Config): Promise<void> {
const pm = config.wantedPackageManager
if (pm == null || pm.name !== 'pnpm' || pm.version == null || pm.version === packageManager.version) return
if (pm == null || pm.name !== 'pnpm' || pm.version == null) return
const pmVersion = semver.valid(pm.version)
if (!pmVersion) {
globalWarn(`Cannot switch to pnpm@${pm.version}: "${pm.version}" is not a valid version`)
@@ -20,7 +23,48 @@ export async function switchCliVersion (config: Config): Promise<void> {
globalWarn(`Cannot switch to pnpm@${pm.version}: you need to specify the version as "${pmVersion}"`)
return
}
const { binDir: wantedPnpmBinDir } = await installPnpmToTools(pmVersion, config)
let envLockfile = await readEnvLockfile(config.rootProjectManifestDir) ?? undefined
let storeToUse: Awaited<ReturnType<typeof createStoreController>> | undefined
if (!isPackageManagerResolved(envLockfile, pmVersion)) {
storeToUse = await createStoreController(config)
envLockfile = await resolvePackageManagerIntegrities(pmVersion, {
envLockfile,
registries: config.registries,
rootDir: config.rootProjectManifestDir,
storeController: storeToUse.ctrl,
storeDir: storeToUse.dir,
})
}
// If the wanted version matches the current version, no switch needed
if (pmVersion === packageManager.version) {
await storeToUse?.ctrl.close()
return
}
// We need a store controller to install pnpm. If it wasn't created during
// integrity resolution (because integrities were already cached), create it now.
if (!storeToUse) {
storeToUse = await createStoreController(config)
}
if (!envLockfile) {
throw new PnpmError('NO_PKG_MANAGER_INTEGRITY', `The packageManager dependency ${pmVersion} was not found in the pnpm-lock.env.yaml`)
}
const { binDir: wantedPnpmBinDir } = await installPnpmToStore(pmVersion, {
envLockfile,
storeController: storeToUse.ctrl,
storeDir: storeToUse.dir,
registries: config.registries,
virtualStoreDirMaxLength: config.virtualStoreDirMaxLength,
packageManager: { name: packageManager.name, version: packageManager.version },
})
await storeToUse.ctrl.close()
const pnpmEnv = prependDirsToPath([wantedPnpmBinDir])
if (!pnpmEnv.updated) {
// We throw this error to prevent an infinite recursive call of the same pnpm version.

View File

@@ -1,9 +1,13 @@
import fs from 'fs'
import path from 'path'
import { readEnvLockfile } from '@pnpm/lockfile.fs'
import { prepare } from '@pnpm/prepare'
import { getIntegrity } from '@pnpm/registry-mock'
import { sync as readYamlFile } from 'read-yaml-file'
import { writeJsonFileSync } from 'write-json-file'
import { sync as writeYamlFile } from 'write-yaml-file'
import { execPnpm } from './utils/index.js'
import { REGISTRY_MOCK_PORT } from '@pnpm/registry-mock'
import { execPnpm, execPnpmSync, pnpmBinLocation } from './utils/index.js'
test('patch from configuration dependency is applied', async () => {
prepare()
@@ -71,13 +75,119 @@ test('catalog applied by configurational dependency hook', async () => {
})
})
test('config deps are not installed before switching to a different pnpm version', async () => {
prepare()
const pnpmHome = path.resolve('pnpm')
const env = { PNPM_HOME: pnpmHome }
// First, add config dep to create the env lockfile (clean specifier format)
await execPnpm(['add', '@pnpm.e2e/has-patch-for-foo@1.0.0', '--config'], { env })
// Remove node_modules so we can check if config deps get re-installed
fs.rmSync('node_modules', { recursive: true })
// Switch to pnpm 9.3.0, which doesn't know about configDependencies.
// If the current pnpm installed config deps before switching, the directory would exist.
writeJsonFileSync('package.json', {
packageManager: 'pnpm@9.3.0',
})
execPnpmSync(['install'], { env, stdio: 'pipe' })
// Config deps should NOT be installed — pnpm 9.3.0 doesn't support them,
// and the current pnpm should not have installed them before switching.
expect(fs.existsSync('node_modules/.pnpm-config/@pnpm.e2e/has-patch-for-foo')).toBeFalsy()
// The env lockfile should have packageManagerDependencies from the version switch
const envLockfile = await readEnvLockfile(process.cwd())
expect(envLockfile).not.toBeNull()
expect(envLockfile!.importers['.'].configDependencies['@pnpm.e2e/has-patch-for-foo']).toBeDefined()
expect(envLockfile!.importers['.'].packageManagerDependencies).toBeDefined()
expect(envLockfile!.importers['.'].packageManagerDependencies!['pnpm']).toStrictEqual({
specifier: '9.3.0',
version: '9.3.0',
})
expect(envLockfile!.importers['.'].packageManagerDependencies!['@pnpm/exe']).toStrictEqual({
specifier: '9.3.0',
version: '9.3.0',
})
})
test('config deps are installed after switching to a pnpm version that supports them', async () => {
prepare({
packageManager: 'pnpm@10.32.0',
})
const pnpmHome = path.resolve('pnpm')
const env = { PNPM_HOME: pnpmHome }
// Write .npmrc so the switched-to pnpm version can find the mock registry
fs.writeFileSync('.npmrc', `registry=http://localhost:${REGISTRY_MOCK_PORT}/\n`)
// Use old inline integrity format that pnpm v10 understands
writeYamlFile('pnpm-workspace.yaml', {
configDependencies: {
'@pnpm.e2e/has-patch-for-foo': `1.0.0+${getIntegrity('@pnpm.e2e/has-patch-for-foo', '1.0.0')}`,
},
})
execPnpmSync(['install'], { env })
// pnpm 10.32.0 supports configDependencies and should have installed them
expect(fs.existsSync('node_modules/.pnpm-config/@pnpm.e2e/has-patch-for-foo')).toBeTruthy()
// The env lockfile should exist (created by version switch) but should
// NOT have configDependencies — v11 didn't install them before switching,
// and v10 doesn't write env lockfiles. This proves v10 handled the install.
const envLockfile = await readEnvLockfile(process.cwd())
expect(envLockfile).not.toBeNull()
expect(envLockfile!.importers['.'].configDependencies).toStrictEqual({})
expect(envLockfile!.importers['.'].packageManagerDependencies).toBeDefined()
})
test('package manager is saved into the lockfile even if it matches the current version', async () => {
const pnpmVersion = JSON.parse(fs.readFileSync(path.join(path.dirname(pnpmBinLocation), '..', 'package.json'), 'utf8')).version as string
prepare({
packageManager: `pnpm@${pnpmVersion}`,
})
const pnpmHome = path.resolve('pnpm')
const env = { PNPM_HOME: pnpmHome }
// Create the env lockfile via pnpm add --config
await execPnpm(['add', '@pnpm.e2e/has-patch-for-foo@1.0.0', '--config'], { env })
expect(fs.existsSync('node_modules/.pnpm-config/@pnpm.e2e/has-patch-for-foo')).toBeTruthy()
// The env lockfile should have both config dep and package manager entries
const envLockfile = await readEnvLockfile(process.cwd())
expect(envLockfile).not.toBeNull()
expect(envLockfile!.importers['.'].configDependencies['@pnpm.e2e/has-patch-for-foo']).toStrictEqual({
specifier: '1.0.0',
version: '1.0.0',
})
expect(envLockfile!.importers['.'].packageManagerDependencies).toBeDefined()
expect(envLockfile!.importers['.'].packageManagerDependencies!['pnpm']).toStrictEqual({
specifier: pnpmVersion,
version: pnpmVersion,
})
})
test('installing a new configurational dependency', async () => {
prepare()
await execPnpm(['add', '@pnpm.e2e/foo@100.0.0', '--config'])
// Workspace manifest should have a clean specifier (no integrity)
const workspaceManifest = readYamlFile<{ configDependencies: Record<string, string> }>('pnpm-workspace.yaml')
expect(workspaceManifest.configDependencies).toStrictEqual({
'@pnpm.e2e/foo': `100.0.0+${getIntegrity('@pnpm.e2e/foo', '100.0.0')}`,
'@pnpm.e2e/foo': '100.0.0',
})
// Env lockfile should contain the resolved dependency with integrity
const envLockfile = await readEnvLockfile(process.cwd())
expect(envLockfile).not.toBeNull()
expect(envLockfile!.importers['.'].configDependencies['@pnpm.e2e/foo']).toStrictEqual({
specifier: '100.0.0',
version: '100.0.0',
})
expect((envLockfile!.packages['@pnpm.e2e/foo@100.0.0'].resolution as { integrity: string }).integrity).toBe(
getIntegrity('@pnpm.e2e/foo', '100.0.0')
)
})

View File

@@ -1,9 +1,10 @@
import fs from 'fs'
import path from 'path'
import PATH_NAME from 'path-name'
import { prepare } from '@pnpm/prepare'
import type { ProjectManifest } from '@pnpm/types'
import { loadJsonFileSync } from 'load-json-file'
import { execPnpm } from '../utils/index.js'
import { execPnpm, execPnpmSync } from '../utils/index.js'
test('self-update updates the packageManager field in package.json', async () => {
prepare({
@@ -22,3 +23,27 @@ test('self-update updates the packageManager field in package.json', async () =>
expect(loadJsonFileSync<ProjectManifest>('package.json').packageManager).toBe('pnpm@10.0.0')
})
test('version switch reuses pnpm previously installed by self-update', async () => {
prepare({})
const pnpmHome = process.cwd()
const env = {
[PATH_NAME]: `${pnpmHome}${path.delimiter}${process.env[PATH_NAME]!}`,
PNPM_HOME: pnpmHome,
XDG_DATA_HOME: path.resolve('data'),
}
// self-update without managePackageManagerVersions installs pnpm 10.0.0
// globally (with GVS enabled), populating the global virtual store
await execPnpm(['self-update', '10.0.0'], { env })
// Write packageManager field so the version switch triggers.
// It should find pnpm 10.0.0 already in the GVS and reuse it
// without downloading again.
fs.writeFileSync('package.json', JSON.stringify({ packageManager: 'pnpm@10.0.0' }))
const result = execPnpmSync(['-v'], { env })
expect(result.status).toBe(0)
expect(result.stdout.toString().trim()).toBe('10.0.0')
})

View File

@@ -1,7 +1,6 @@
import path from 'path'
import fs from 'fs'
import { prepare } from '@pnpm/prepare'
import { getToolDirPath } from '@pnpm/tools.path'
import { writeJsonFileSync } from 'write-json-file'
import { sync as writeYamlFile } from 'write-yaml-file'
import { execPnpmSync } from './utils/index.js'
@@ -75,25 +74,31 @@ test('do not switch to pnpm version when a range is specified', async () => {
expect(stdout.toString()).toContain('Cannot switch to pnpm@^9.3.0')
})
test('throws error if pnpm tools dir is corrupt', () => {
test('throws error if pnpm binary in store is corrupt', () => {
prepare()
const config = ['--config.manage-package-manager-versions=true'] as const
const pnpmHome = path.resolve('pnpm')
const env = { PNPM_HOME: pnpmHome }
const storeDir = path.resolve('store')
const env = { PNPM_HOME: pnpmHome, pnpm_config_store_dir: storeDir }
const version = '9.3.0'
writeJsonFileSync('package.json', {
packageManager: `pnpm@${version}`,
})
// Run pnpm once to ensure the tools dir is created.
// Run pnpm once to ensure pnpm is installed to the store.
execPnpmSync([...config, 'help'], { env })
// Intentionally corrupt the tool dir.
const toolDir = getToolDirPath({ pnpmHomeDir: pnpmHome, tool: { name: 'pnpm', version } })
fs.rmSync(path.join(toolDir, 'bin/pnpm'))
// Find the pnpm binary in the global virtual store and corrupt it.
const entries = fs.readdirSync(storeDir, { recursive: true }) as string[]
const pnpmBinEntry = entries.find(e => {
const normalized = e.replace(/\\/g, '/')
return normalized.endsWith('/bin/pnpm') && !normalized.includes('node_modules')
})
if (!pnpmBinEntry) throw new Error('Could not find pnpm binary in store')
fs.rmSync(path.join(storeDir, pnpmBinEntry))
if (isWindows()) {
fs.rmSync(path.join(toolDir, 'bin/pnpm.cmd'))
fs.rmSync(path.join(storeDir, pnpmBinEntry + '.cmd'))
}
const { stderr } = execPnpmSync([...config, 'help'], { env })

View File

@@ -56,6 +56,9 @@
{
"path": "../config/config"
},
{
"path": "../config/deps-installer"
},
{
"path": "../config/plugin-commands-config"
},
@@ -80,6 +83,9 @@
{
"path": "../fs/msgpack-file"
},
{
"path": "../lockfile/fs"
},
{
"path": "../lockfile/plugin-commands-audit"
},
@@ -168,7 +174,7 @@
"path": "../store/plugin-commands-store-inspecting"
},
{
"path": "../tools/path"
"path": "../store/store-connection-manager"
},
{
"path": "../tools/plugin-commands-self-updater"

View File

@@ -1,9 +0,0 @@
# @pnpm/tools.path
## 1.0.0
### Major Changes
- eb8bf2a: Added a new command for upgrading pnpm itself when it isn't managed by Corepack: `pnpm self-update`. This command will work, when pnpm was installed via the standalone script from the [pnpm installation page](https://pnpm.io/installation#using-a-standalone-script) [#8424](https://github.com/pnpm/pnpm/pull/8424).
When executed in a project that has a `packageManager` field in its `package.json` file, pnpm will update its version in the `packageManager` field.

View File

@@ -1,15 +0,0 @@
# @pnpm/tools.path
> Path to tools
[![npm version](https://img.shields.io/npm/v/@pnpm/tools.path.svg)](https://www.npmjs.com/package/@pnpm/tools.path)
## Installation
```
pnpm add @pnpm/tools.path
```
## License
MIT

View File

@@ -1,41 +0,0 @@
{
"name": "@pnpm/tools.path",
"version": "1000.0.0",
"description": "Path to tools",
"keywords": [
"pnpm",
"pnpm11"
],
"license": "MIT",
"funding": "https://opencollective.com/pnpm",
"repository": "https://github.com/pnpm/pnpm/tree/main/tools/path",
"homepage": "https://github.com/pnpm/pnpm/tree/main/tools/path#readme",
"bugs": {
"url": "https://github.com/pnpm/pnpm/issues"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"exports": {
".": "./lib/index.js"
},
"files": [
"lib",
"!*.map"
],
"scripts": {
"lint": "eslint \"src/**/*.ts\"",
"prepublishOnly": "pnpm run compile",
"test": "pnpm run compile",
"compile": "tsgo --build && pnpm run lint --fix"
},
"devDependencies": {
"@pnpm/tools.path": "workspace:*"
},
"engines": {
"node": ">=22.13"
},
"jest": {
"preset": "@pnpm/jest-config"
}
}

View File

@@ -1,13 +0,0 @@
import path from 'path'
export function getToolDirPath (
opts: {
pnpmHomeDir: string
tool: {
name: string
version: string
}
}
): string {
return path.join(opts.pnpmHomeDir, '.tools', opts.tool.name.replaceAll('/', '+'), opts.tool.version)
}

View File

@@ -1,12 +0,0 @@
{
"extends": "@pnpm/tsconfig",
"compilerOptions": {
"outDir": "lib",
"rootDir": "src"
},
"include": [
"src/**/*.ts",
"../../__typings__/**/*.d.ts"
],
"references": []
}

View File

@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.json",
"include": [
"src/**/*.ts",
"test/**/*.ts",
"../../__typings__/**/*.d.ts"
]
}

View File

@@ -31,17 +31,22 @@
"_test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest"
},
"dependencies": {
"@pnpm/calc-dep-state": "workspace:*",
"@pnpm/cli-meta": "workspace:*",
"@pnpm/cli-utils": "workspace:*",
"@pnpm/client": "workspace:*",
"@pnpm/config": "workspace:*",
"@pnpm/config.deps-installer": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/exec.pnpm-cli-runner": "workspace:*",
"@pnpm/global.commands": "workspace:*",
"@pnpm/global.packages": "workspace:*",
"@pnpm/headless": "workspace:*",
"@pnpm/link-bins": "workspace:*",
"@pnpm/lockfile.types": "workspace:*",
"@pnpm/package-store": "workspace:*",
"@pnpm/read-project-manifest": "workspace:*",
"@pnpm/tools.path": "workspace:*",
"@zkochan/rimraf": "catalog:",
"path-temp": "catalog:",
"@pnpm/store-connection-manager": "workspace:*",
"@pnpm/types": "workspace:*",
"ramda": "catalog:",
"render-help": "catalog:",
"symlink-dir": "catalog:"

View File

@@ -1,4 +1,4 @@
import * as selfUpdate from './selfUpdate.js'
export { installPnpmToTools } from './installPnpmToTools.js'
export { installPnpm, installPnpmToStore } from './installPnpm.js'
export { selfUpdate }

View File

@@ -0,0 +1,367 @@
import fs from 'fs'
import path from 'path'
import util from 'util'
import {
iterateHashedGraphNodes,
iteratePkgMeta,
lockfileToDepGraph,
} from '@pnpm/calc-dep-state'
import { getCurrentPackageName } from '@pnpm/cli-meta'
import { type GlobalAddOptions, installGlobalPackages } from '@pnpm/global.commands'
import {
cleanOrphanedInstallDirs,
createGlobalCacheKey,
createInstallDir,
findGlobalPackage,
getHashLink,
} from '@pnpm/global.packages'
import { headlessInstall } from '@pnpm/headless'
import { linkBins } from '@pnpm/link-bins'
import type { EnvLockfile, LockfileObject, PackageSnapshot } from '@pnpm/lockfile.types'
import type { StoreController } from '@pnpm/package-store'
import type { DepPath, ProjectId, ProjectRootDir, Registries } from '@pnpm/types'
import symlinkDir from 'symlink-dir'
export interface InstallPnpmResult {
binDir: string
baseDir: string
alreadyExisted: boolean
}
export interface InstallPnpmOptions extends GlobalAddOptions {
envLockfile?: EnvLockfile
storeController?: StoreController
storeDir?: string
packageManager?: { name: string, version: string }
}
/**
* Installs pnpm to the global packages directory (for self-update).
* Creates an entry in globalPkgDir that is visible to `pnpm ls -g`.
*/
export async function installPnpm (pnpmVersion: string, opts: InstallPnpmOptions): Promise<InstallPnpmResult> {
const currentPkgName = getCurrentPackageName()
const wantedLockfile = opts.envLockfile
? buildLockfileFromEnvLockfile(opts.envLockfile, currentPkgName, pnpmVersion)
: undefined
const result = await installPnpmToGlobalDir(
opts,
currentPkgName,
pnpmVersion,
wantedLockfile
)
return {
alreadyExisted: result.alreadyExisted,
baseDir: result.installDir,
binDir: result.binDir,
}
}
/**
* Installs pnpm to the global virtual store (for version switching).
* Does NOT create an entry in globalPkgDir — the package lives only in the store.
* Returns the bin directory where the pnpm binary can be found.
*/
export async function installPnpmToStore (
pnpmVersion: string,
opts: {
envLockfile: EnvLockfile
storeController: StoreController
storeDir: string
registries: Registries
virtualStoreDirMaxLength: number
packageManager?: { name: string, version: string }
}
): Promise<{ binDir: string }> {
const currentPkgName = getCurrentPackageName()
const wantedLockfile = buildLockfileFromEnvLockfile(opts.envLockfile, currentPkgName, pnpmVersion)
const globalVirtualStoreDir = path.join(opts.storeDir, 'links')
// Compute the GVS hash for the pnpm package to find its path
const pnpmGvsPath = findPnpmGvsPath(wantedLockfile, currentPkgName, globalVirtualStoreDir)
const pnpmPkgDir = path.join(pnpmGvsPath, 'node_modules', currentPkgName)
const binDir = path.join(pnpmGvsPath, 'bin')
// Check if already installed in the GVS
if (fs.existsSync(path.join(pnpmPkgDir, 'package.json'))) {
if (!fs.existsSync(binDir)) {
await linkBins(path.join(pnpmGvsPath, 'node_modules'), binDir, { warn: noop })
}
return { binDir }
}
// Install to a temporary directory — headless install with GVS enabled
// will populate the global virtual store
const tmpInstallDir = path.join(opts.storeDir, '.tmp', `pnpm-${pnpmVersion}-${Date.now()}`)
fs.mkdirSync(tmpInstallDir, { recursive: true })
try {
await installFromLockfile(tmpInstallDir, binDir, {
wantedLockfile,
storeController: opts.storeController,
storeDir: opts.storeDir,
registries: opts.registries,
virtualStoreDirMaxLength: opts.virtualStoreDirMaxLength,
packageManager: opts.packageManager,
})
// Now the GVS should be populated — create bins alongside the GVS entry
linkExePlatformBinary(pnpmGvsPath)
await linkBins(path.join(pnpmGvsPath, 'node_modules'), binDir, { warn: noop })
return { binDir }
} finally {
try {
fs.rmSync(tmpInstallDir, { recursive: true, force: true })
} catch {}
}
}
function noop (_message: string) {}
function findPnpmGvsPath (
lockfile: LockfileObject,
pkgName: string,
globalVirtualStoreDir: string
): string {
const graph = lockfileToDepGraph(lockfile)
const pkgMetaIterator = iteratePkgMeta(lockfile, graph)
for (const { hash, pkgMeta } of iterateHashedGraphNodes(graph, pkgMetaIterator)) {
if (pkgMeta.name === pkgName) {
return path.join(globalVirtualStoreDir, hash)
}
}
throw new Error(`Could not find ${pkgName} in lockfile`)
}
interface InstallPnpmToGlobalDirResult {
installDir: string
binDir: string
alreadyExisted: boolean
}
/**
* Installs pnpm to the global packages directory.
* Bins are created within the install dir's own bin/ subdirectory.
*
* When a `wantedLockfile` is provided, a frozen headless install is performed
* using the lockfile's integrity hashes for security. Otherwise, full resolution
* is performed via `installGlobalPackages`.
*/
async function installPnpmToGlobalDir (
opts: InstallPnpmOptions,
pkgName: string,
version: string,
wantedLockfile?: LockfileObject
): Promise<InstallPnpmToGlobalDirResult> {
const globalDir = opts.globalPkgDir!
cleanOrphanedInstallDirs(globalDir)
// Check if already installed globally
const existing = findGlobalPackage(globalDir, pkgName)
if (existing) {
const pkgJsonPath = path.join(existing.installDir, 'node_modules', pkgName, 'package.json')
try {
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'))
if (pkgJson.version === version) {
const binDir = path.join(existing.installDir, 'bin')
return { alreadyExisted: true, installDir: existing.installDir, binDir }
}
} catch {}
}
const installDir = createInstallDir(globalDir)
const binDir = path.join(installDir, 'bin')
try {
if (wantedLockfile != null && opts.storeController != null && opts.storeDir != null) {
await installFromLockfile(installDir, binDir, {
wantedLockfile,
storeController: opts.storeController,
storeDir: opts.storeDir,
registries: opts.registries as Registries,
virtualStoreDirMaxLength: opts.virtualStoreDirMaxLength,
packageManager: opts.packageManager,
})
} else {
await installFromResolution(installDir, opts, [`${pkgName}@${version}`])
}
linkExePlatformBinary(installDir)
await linkBins(path.join(installDir, 'node_modules'), binDir, { warn: noop })
// Create hash symlink for the global packages system
const pkgJson = JSON.parse(fs.readFileSync(path.join(installDir, 'package.json'), 'utf8'))
const aliases = Object.keys(pkgJson.dependencies ?? {})
const cacheHash = createGlobalCacheKey({ aliases, registries: opts.registries })
const hashLink = getHashLink(globalDir, cacheHash)
await symlinkDir(installDir, hashLink, { overwrite: true })
return { alreadyExisted: false, installDir, binDir }
} catch (err: unknown) {
try {
fs.rmSync(installDir, { recursive: true, force: true })
} catch {}
throw err
}
}
async function installFromLockfile (
installDir: string,
binDir: string,
opts: {
wantedLockfile: LockfileObject
storeController: StoreController
storeDir: string
registries: Registries
virtualStoreDirMaxLength: number
packageManager?: { name: string, version: string }
}
): Promise<void> {
const rootImporter = opts.wantedLockfile.importers['.' as ProjectId]
const dependencies = rootImporter?.dependencies ?? {}
fs.writeFileSync(path.join(installDir, 'package.json'), JSON.stringify({ dependencies }))
await headlessInstall({
wantedLockfile: opts.wantedLockfile,
lockfileDir: installDir,
storeController: opts.storeController,
storeDir: opts.storeDir,
registries: opts.registries,
enableGlobalVirtualStore: true,
globalVirtualStoreDir: path.join(opts.storeDir, 'links'),
ignoreScripts: true,
ignoreDepScripts: true,
force: false,
engineStrict: false,
currentEngine: {
pnpmVersion: opts.packageManager?.version ?? '',
},
include: {
dependencies: true,
devDependencies: false,
optionalDependencies: true,
},
selectedProjectDirs: [installDir],
allProjects: {
[installDir]: {
binsDir: binDir,
buildIndex: 0,
manifest: { dependencies },
modulesDir: path.join(installDir, 'node_modules'),
id: '.' as ProjectId,
rootDir: installDir as ProjectRootDir,
},
},
hoistedDependencies: {},
virtualStoreDirMaxLength: opts.virtualStoreDirMaxLength,
sideEffectsCacheRead: false,
sideEffectsCacheWrite: false,
rawConfig: {},
unsafePerm: false,
userAgent: '',
packageManager: opts.packageManager ?? { name: 'pnpm', version: '' },
pruneStore: false,
pendingBuilds: [],
skipped: new Set(),
})
}
async function installFromResolution (
installDir: string,
opts: GlobalAddOptions,
params: string[]
): Promise<void> {
const include = {
dependencies: true,
devDependencies: false,
optionalDependencies: true,
}
const fetchFullMetadata = Boolean(opts.supportedArchitectures?.libc ?? opts.rootProjectManifest?.pnpm?.supportedArchitectures?.libc)
await installGlobalPackages({
...opts,
global: false,
bin: path.join(installDir, 'node_modules/.bin'),
dir: installDir,
lockfileDir: installDir,
rootProjectManifestDir: installDir,
rootProjectManifest: undefined,
saveProd: true,
saveDev: false,
saveOptional: false,
savePeer: false,
workspaceDir: undefined,
sharedWorkspaceLockfile: false,
lockfileOnly: false,
fetchFullMetadata,
include,
includeDirect: include,
allowBuilds: {},
}, params)
}
// @pnpm/exe bundles Node.js via optional platform-specific packages (e.g. @pnpm/macos-arm64).
// Its postinstall script links the correct binary into the @pnpm/exe package dir.
// Since scripts are disabled during install (to support systems without Node.js),
// we replicate that linking here.
function linkExePlatformBinary (installDir: string): void {
const platform = process.platform === 'win32'
? 'win'
: process.platform === 'darwin'
? 'macos'
: process.platform
const arch = platform === 'win' && process.arch === 'ia32' ? 'x86' : process.arch
const executable = platform === 'win' ? 'pnpm.exe' : 'pnpm'
const platformPkgDir = path.join(installDir, 'node_modules', '@pnpm', `${platform}-${arch}`)
const src = path.join(platformPkgDir, executable)
if (!fs.existsSync(src)) return
const exePkgDir = path.join(installDir, 'node_modules', '@pnpm', 'exe')
const dest = path.join(exePkgDir, executable)
try {
fs.unlinkSync(dest)
} catch (err: unknown) {
if (!util.types.isNativeError(err) || !('code' in err) || err.code !== 'ENOENT') {
throw err
}
}
fs.linkSync(src, dest)
fs.chmodSync(dest, 0o755)
if (platform === 'win') {
const exePkgJsonPath = path.join(exePkgDir, 'package.json')
const exePkg = JSON.parse(fs.readFileSync(exePkgJsonPath, 'utf8'))
fs.writeFileSync(path.join(exePkgDir, 'pnpm'), 'This file intentionally left blank')
exePkg.bin.pnpm = 'pnpm.exe'
fs.writeFileSync(exePkgJsonPath, JSON.stringify(exePkg, null, 2))
}
}
function buildLockfileFromEnvLockfile (
envLockfile: EnvLockfile,
pkgName: string,
version: string
) {
const dependencies: Record<string, string> = {}
dependencies[pkgName] = version
const packages: Record<string, PackageSnapshot> = {}
for (const [depPath, snapshot] of Object.entries(envLockfile.snapshots)) {
packages[depPath as DepPath] = {
...snapshot,
...envLockfile.packages[depPath],
}
}
return {
lockfileVersion: envLockfile.lockfileVersion,
importers: {
['.' as ProjectId]: {
specifiers: { [pkgName]: version },
dependencies,
},
},
packages: packages as Record<DepPath, PackageSnapshot>,
}
}

View File

@@ -1,112 +0,0 @@
import fs from 'fs'
import path from 'path'
import { getCurrentPackageName } from '@pnpm/cli-meta'
import { runPnpmCli } from '@pnpm/exec.pnpm-cli-runner'
import { getToolDirPath } from '@pnpm/tools.path'
import { sync as rimraf } from '@zkochan/rimraf'
import { fastPathTemp as pathTemp } from 'path-temp'
import symlinkDir from 'symlink-dir'
import type { SelfUpdateCommandOptions } from './selfUpdate.js'
export interface InstallPnpmToToolsResult {
binDir: string
baseDir: string
alreadyExisted: boolean
}
export async function installPnpmToTools (pnpmVersion: string, opts: SelfUpdateCommandOptions): Promise<InstallPnpmToToolsResult> {
const currentPkgName = getCurrentPackageName()
const dir = getToolDirPath({
pnpmHomeDir: opts.pnpmHomeDir,
tool: {
name: currentPkgName,
version: pnpmVersion,
},
})
const binDir = path.join(dir, 'bin')
const alreadyExisted = fs.existsSync(binDir)
if (alreadyExisted) {
return {
alreadyExisted,
baseDir: dir,
binDir,
}
}
const stage = pathTemp(dir)
fs.mkdirSync(stage, { recursive: true })
fs.writeFileSync(path.join(stage, 'package.json'), '{}')
try {
// The reason we don't just run add.handler is that at this point we might have settings from local config files
// that we don't want to use while installing the pnpm CLI.
// We use --ignore-scripts because `@pnpm/exe` has a `preinstall` script that runs `node setup.js`,
// which fails in environments without a system Node.js (e.g. when pnpm is installed as a standalone executable).
// Instead, we link the platform-specific binary in-process after install.
runPnpmCli([
'add',
`${currentPkgName}@${pnpmVersion}`,
'--loglevel=error',
'--ignore-scripts',
'--config.strict-dep-builds=false',
// We want to avoid symlinks because of the rename step,
// which breaks the junctions on Windows.
'--config.node-linker=hoisted',
'--config.bin=bin',
], { cwd: stage })
if (currentPkgName === '@pnpm/exe') {
linkExePlatformBinary(stage)
}
// We need the operation of installing pnpm to be atomic.
// However, we cannot use a rename as that breaks the command shim created for pnpm.
// Hence, we use a symlink.
// In future we may switch back to rename if we will move Node.js out of the pnpm subdirectory.
symlinkDir.sync(stage, dir)
} catch (err: unknown) {
try {
rimraf(stage)
} catch {} // eslint-disable-line:no-empty
throw err
}
return {
alreadyExisted,
baseDir: dir,
binDir,
}
}
// This replicates the logic from @pnpm/exe's setup.js (pnpm/artifacts/exe/setup.js).
// We can't run setup.js via require() or import() because:
// - require() fails when setup.js is ESM (pnpm v11+)
// - import() is intercepted by pkg's virtual filesystem in standalone executables
// So we inline the logic: find the platform-specific binary and hard-link it
// into the @pnpm/exe package directory.
function linkExePlatformBinary (stageDir: string): void {
const platform = process.platform === 'win32'
? 'win'
: process.platform === 'darwin'
? 'macos'
: process.platform
const arch = platform === 'win' && process.arch === 'ia32' ? 'x86' : process.arch
const executable = platform === 'win' ? 'pnpm.exe' : 'pnpm'
const platformPkgDir = path.join(stageDir, 'node_modules', '@pnpm', `${platform}-${arch}`)
const src = path.join(platformPkgDir, executable)
if (!fs.existsSync(src)) return
const exePkgDir = path.join(stageDir, 'node_modules', '@pnpm', 'exe')
const dest = path.join(exePkgDir, executable)
try {
fs.unlinkSync(dest)
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
throw err
}
}
fs.linkSync(src, dest)
fs.chmodSync(dest, 0o755)
if (platform === 'win') {
const exePkgJsonPath = path.join(exePkgDir, 'package.json')
const exePkg = JSON.parse(fs.readFileSync(exePkgJsonPath, 'utf8'))
fs.writeFileSync(path.join(exePkgDir, 'pnpm'), 'This file intentionally left blank')
exePkg.bin.pnpm = 'pnpm.exe'
fs.writeFileSync(exePkgJsonPath, JSON.stringify(exePkg, null, 2))
}
}

View File

@@ -3,13 +3,15 @@ import { docsUrl } from '@pnpm/cli-utils'
import { packageManager, isExecutedByCorepack } from '@pnpm/cli-meta'
import { createResolver } from '@pnpm/client'
import { type Config, types as allTypes } from '@pnpm/config'
import { resolvePackageManagerIntegrities } from '@pnpm/config.deps-installer'
import { PnpmError } from '@pnpm/error'
import { linkBins } from '@pnpm/link-bins'
import { globalWarn } from '@pnpm/logger'
import { readProjectManifest } from '@pnpm/read-project-manifest'
import { linkBins } from '@pnpm/link-bins'
import { createStoreController, type CreateStoreControllerOptions } from '@pnpm/store-connection-manager'
import { pick } from 'ramda'
import renderHelp from 'render-help'
import { installPnpmToTools } from './installPnpmToTools.js'
import { installPnpm } from './installPnpm.js'
export function rcOptionsTypes (): Record<string, unknown> {
return pick([], allTypes)
@@ -37,15 +39,12 @@ export function help (): string {
})
}
export type SelfUpdateCommandOptions = Pick<Config,
| 'cacheDir'
| 'dir'
export type SelfUpdateCommandOptions = CreateStoreControllerOptions & Pick<Config,
| 'globalPkgDir'
| 'lockfileDir'
| 'managePackageManagerVersions'
| 'modulesDir'
| 'pnpmHomeDir'
| 'rawConfig'
| 'registries'
| 'rootProjectManifestDir'
| 'wantedPackageManager'
>
@@ -74,6 +73,13 @@ export async function handler (
const { manifest, writeProjectManifest } = await readProjectManifest(opts.rootProjectManifestDir)
manifest.packageManager = `pnpm@${resolution.manifest.version}`
await writeProjectManifest(manifest)
const store = await createStoreController(opts)
await resolvePackageManagerIntegrities(resolution.manifest.version, {
registries: opts.registries,
rootDir: opts.rootProjectManifestDir,
storeController: store.ctrl,
storeDir: store.dir,
})
return `The current project has been updated to use pnpm v${resolution.manifest.version}`
} else {
return `The current project is already set to use pnpm v${resolution.manifest.version}`
@@ -83,13 +89,28 @@ export async function handler (
return `The currently active ${packageManager.name} v${packageManager.version} is already "${bareSpecifier}" and doesn't need an update`
}
const { baseDir, alreadyExisted } = await installPnpmToTools(resolution.manifest.version, opts)
await linkBins(path.join(baseDir, 'node_modules'), opts.pnpmHomeDir,
{
warn: globalWarn,
}
)
return alreadyExisted
? `The ${bareSpecifier} version, v${resolution.manifest.version}, is already present on the system. It was activated by linking it from ${baseDir}.`
: undefined
const store = await createStoreController(opts)
// Resolve integrities and write pnpm-lock.env.yaml
const envLockfile = await resolvePackageManagerIntegrities(resolution.manifest.version, {
registries: opts.registries,
rootDir: opts.pnpmHomeDir,
storeController: store.ctrl,
storeDir: store.dir,
})
const { baseDir, alreadyExisted } = await installPnpm(resolution.manifest.version, {
...opts,
envLockfile,
storeController: store.ctrl,
storeDir: store.dir,
})
// Link bins to pnpmHomeDir so the updated pnpm is the active global binary
await linkBins(path.join(baseDir, 'node_modules'), opts.pnpmHomeDir, { warn: globalWarn })
if (alreadyExisted) {
return `The ${bareSpecifier} version, v${resolution.manifest.version}, is already present on the system. It was activated by linking it from ${baseDir}.`
}
return undefined
}

View File

@@ -20,7 +20,7 @@ jest.unstable_mockModule('@pnpm/cli-meta', () => {
},
}
})
const { selfUpdate } = await import('@pnpm/tools.plugin-commands-self-updater')
const { selfUpdate, installPnpm } = await import('@pnpm/tools.plugin-commands-self-updater')
afterEach(() => {
nock.cleanAll()
@@ -33,6 +33,7 @@ beforeEach(() => {
function prepare () {
const dir = tempDir(false)
fs.writeFileSync(path.join(dir, 'package.json'), '{}', 'utf8')
return prepareOptions(dir)
}
@@ -45,6 +46,7 @@ function prepareOptions (dir: string) {
excludeLinksFromLockfile: false,
linkWorkspacePackages: true,
bail: true,
globalPkgDir: path.join(dir, 'global', 'v11'),
pnpmHomeDir: dir,
preferWorkspacePackages: true,
registries: {
@@ -86,18 +88,62 @@ function createMetadata (latest: string, registry: string, otherVersions: string
}
}
function createExeMetadata (version: string, registry: string) {
return {
name: '@pnpm/exe',
'dist-tags': { latest: version },
versions: {
[version]: {
name: '@pnpm/exe',
version,
dist: {
shasum: 'abcdef1234567890',
tarball: `${registry}@pnpm/exe/-/exe-${version}.tgz`,
integrity: 'sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==',
},
},
},
}
}
/**
* Mock @pnpm/exe metadata for tests that call resolvePackageManagerIntegrities.
* This prevents install() from making real HTTP requests for @pnpm/exe.
*/
function mockExeMetadata (registry: string, version: string) {
nock(registry)
.get('/@pnpm%2Fexe') // cspell:disable-line
.reply(200, createExeMetadata(version, registry))
}
/**
* Mock all registry requests needed for a full self-update flow.
* This includes: initial resolution, resolvePackageManagerIntegrities, and handleGlobalAdd.
*/
function mockRegistryForUpdate (registry: string, version: string, metadata: object) {
// Use persist for metadata since multiple components request it
nock(registry)
.persist()
.get('/pnpm')
.reply(200, metadata)
mockExeMetadata(registry, version)
nock(registry)
.get(`/pnpm/-/pnpm-${version}.tgz`)
.replyWithFile(200, pnpmTarballPath)
}
test('self-update', async () => {
const opts = prepare()
nock(opts.registries.default)
.get('/pnpm')
.reply(200, createMetadata('9.1.0', opts.registries.default))
nock(opts.registries.default)
.get('/pnpm/-/pnpm-9.1.0.tgz')
.replyWithFile(200, pnpmTarballPath)
mockRegistryForUpdate(opts.registries.default, '9.1.0', createMetadata('9.1.0', opts.registries.default))
await selfUpdate.handler(opts, [])
const pnpmPkgJson = JSON.parse(fs.readFileSync(path.join(opts.pnpmHomeDir, '.tools/pnpm/9.1.0/node_modules/pnpm/package.json'), 'utf8'))
// Verify the package was installed in the global dir
const globalDir = path.join(opts.pnpmHomeDir, 'global', 'v11')
const entries = fs.readdirSync(globalDir)
const installDirName = entries.find((e) => fs.statSync(path.join(globalDir, e)).isDirectory())
expect(installDirName).toBeDefined()
const pnpmPkgJson = JSON.parse(fs.readFileSync(path.join(globalDir, installDirName!, 'node_modules/pnpm/package.json'), 'utf8'))
expect(pnpmPkgJson.version).toBe('9.1.0')
const pnpmEnv = prependDirsToPath([opts.pnpmHomeDir])
@@ -113,16 +159,24 @@ test('self-update', async () => {
test('self-update by exact version', async () => {
const opts = prepare()
const metadata = createMetadata('9.2.0', opts.registries.default, ['9.1.0'])
nock(opts.registries.default)
.persist()
.get('/pnpm')
.reply(200, createMetadata('9.2.0', opts.registries.default, ['9.1.0']))
.reply(200, metadata)
mockExeMetadata(opts.registries.default, '9.1.0')
nock(opts.registries.default)
.get('/pnpm/-/pnpm-9.1.0.tgz')
.replyWithFile(200, pnpmTarballPath)
await selfUpdate.handler(opts, ['9.1.0'])
const pnpmPkgJson = JSON.parse(fs.readFileSync(path.join(opts.pnpmHomeDir, '.tools/pnpm/9.1.0/node_modules/pnpm/package.json'), 'utf8'))
// Verify the package was installed in the global dir
const globalDir = path.join(opts.pnpmHomeDir, 'global', 'v11')
const entries = fs.readdirSync(globalDir)
const installDirName = entries.find((e) => fs.statSync(path.join(globalDir, e)).isDirectory())
expect(installDirName).toBeDefined()
const pnpmPkgJson = JSON.parse(fs.readFileSync(path.join(globalDir, installDirName!, 'node_modules/pnpm/package.json'), 'utf8'))
expect(pnpmPkgJson.version).toBe('9.1.0')
const pnpmEnv = prependDirsToPath([opts.pnpmHomeDir])
@@ -154,8 +208,10 @@ test('should update packageManager field when a newer pnpm version is available'
packageManager: 'pnpm@8.0.0',
}), 'utf8')
nock(opts.registries.default)
.persist()
.get('/pnpm')
.reply(200, createMetadata('9.0.0', opts.registries.default))
mockExeMetadata(opts.registries.default, '9.0.0')
const output = await selfUpdate.handler({
...opts,
@@ -193,22 +249,30 @@ test('should not update packageManager field when current version matches latest
expect(JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')).packageManager).toBe('pnpm@9.0.0')
})
test('self-update links pnpm that is already present on the disk', async () => {
test('self-update finds pnpm that is already in the global dir', async () => {
const opts = prepare()
const globalDir = opts.globalPkgDir
// Pre-create a pnpm package in the global dir with a hash symlink
const installDir = path.join(globalDir, 'test-install')
const pkgDir = path.join(installDir, 'node_modules', 'pnpm')
fs.mkdirSync(pkgDir, { recursive: true })
fs.writeFileSync(path.join(installDir, 'package.json'), JSON.stringify({ dependencies: { pnpm: '9.2.0' } }), 'utf8')
fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({ name: 'pnpm', version: '9.2.0', bin: { pnpm: 'bin.js' } }), 'utf8')
fs.writeFileSync(path.join(pkgDir, 'bin.js'), `#!/usr/bin/env node
console.log('9.2.0')`, 'utf8')
// Create a hash symlink pointing to the install dir (like handleGlobalAdd does)
fs.symlinkSync(installDir, path.join(globalDir, 'fake-hash'))
nock(opts.registries.default)
.persist()
.get('/pnpm')
.reply(200, createMetadata('9.2.0', opts.registries.default))
mockExeMetadata(opts.registries.default, '9.2.0')
const baseDir = path.join(opts.pnpmHomeDir, '.tools/pnpm/9.2.0')
fs.mkdirSync(path.join(baseDir, 'bin'), { recursive: true })
const latestPnpmDir = path.join(baseDir, 'node_modules/pnpm')
fs.mkdirSync(latestPnpmDir, { recursive: true })
fs.writeFileSync(path.join(latestPnpmDir, 'package.json'), JSON.stringify({ name: 'pnpm', bin: 'bin.js' }), 'utf8')
fs.writeFileSync(path.join(latestPnpmDir, 'bin.js'), `#!/usr/bin/env node
console.log('9.2.0')`, 'utf8')
const output = await selfUpdate.handler(opts, [])
expect(output).toBe(`The latest version, v9.2.0, is already present on the system. It was activated by linking it from ${path.join(latestPnpmDir, '../..')}.`)
expect(output).toBe(`The latest version, v9.2.0, is already present on the system. It was activated by linking it from ${installDir}.`)
const pnpmEnv = prependDirsToPath([opts.pnpmHomeDir])
const { status, stdout } = spawn.sync('pnpm', ['-v'], {
@@ -221,6 +285,45 @@ console.log('9.2.0')`, 'utf8')
expect(stdout.toString().trim()).toBe('9.2.0')
})
test('self-update works globally without package.json', async () => {
const dir = tempDir(false)
// No package.json in this directory
const pnpmHomeDir = path.join(dir, 'pnpm-home')
fs.mkdirSync(pnpmHomeDir, { recursive: true })
const opts = {
...prepareOptions(dir),
globalPkgDir: path.join(pnpmHomeDir, 'global', 'v11'),
pnpmHomeDir,
bin: pnpmHomeDir,
}
mockRegistryForUpdate(opts.registries.default, '9.1.0', createMetadata('9.1.0', opts.registries.default))
await selfUpdate.handler(opts, [])
// Verify no package.json was created
expect(fs.existsSync(path.join(dir, 'package.json'))).toBe(false)
// Verify pnpm-lock.env.yaml was written to pnpmHomeDir
expect(fs.existsSync(path.join(pnpmHomeDir, 'pnpm-lock.env.yaml'))).toBe(true)
// Verify the package was installed in the global dir
const globalDir = path.join(pnpmHomeDir, 'global', 'v11')
const globalEntries = fs.readdirSync(globalDir)
const globalInstallDir = globalEntries.find((e) => fs.statSync(path.join(globalDir, e)).isDirectory())
expect(globalInstallDir).toBeDefined()
expect(fs.existsSync(path.join(globalDir, globalInstallDir!, 'node_modules', 'pnpm', 'package.json'))).toBe(true)
const pnpmEnv = prependDirsToPath([pnpmHomeDir])
const { status, stdout } = spawn.sync('pnpm', ['-v'], {
env: {
...process.env,
[pnpmEnv.name]: pnpmEnv.value,
},
})
expect(status).toBe(0)
expect(stdout.toString().trim()).toBe('9.1.0')
})
test('self-update updates the packageManager field in package.json', async () => {
prepareWithPkg({
packageManager: 'pnpm@9.0.0',
@@ -234,8 +337,10 @@ test('self-update updates the packageManager field in package.json', async () =>
},
}
nock(opts.registries.default)
.persist()
.get('/pnpm')
.reply(200, createMetadata('9.1.0', opts.registries.default))
mockExeMetadata(opts.registries.default, '9.1.0')
nock(opts.registries.default)
.get('/pnpm/-/pnpm-9.1.0.tgz')
.replyWithFile(200, pnpmTarballPath)
@@ -247,3 +352,21 @@ test('self-update updates the packageManager field in package.json', async () =>
const pkgJson = JSON.parse(fs.readFileSync(path.resolve('package.json'), 'utf8'))
expect(pkgJson.packageManager).toBe('pnpm@9.1.0')
})
test('installPnpm without env lockfile uses resolution path', async () => {
const opts = prepare()
nock(opts.registries.default)
.persist()
.get('/pnpm')
.reply(200, createMetadata('9.1.0', opts.registries.default))
nock(opts.registries.default)
.get('/pnpm/-/pnpm-9.1.0.tgz')
.replyWithFile(200, pnpmTarballPath)
const result = await installPnpm('9.1.0', opts)
expect(result.alreadyExisted).toBe(false)
const pnpmPkgJson = JSON.parse(fs.readFileSync(path.join(result.baseDir, 'node_modules/pnpm/package.json'), 'utf8'))
expect(pnpmPkgJson.version).toBe('9.1.0')
expect(fs.existsSync(result.binDir)).toBe(true)
})

View File

@@ -21,18 +21,36 @@
{
"path": "../../config/config"
},
{
"path": "../../config/deps-installer"
},
{
"path": "../../env/path"
},
{
"path": "../../exec/pnpm-cli-runner"
"path": "../../global/commands"
},
{
"path": "../../global/packages"
},
{
"path": "../../lockfile/types"
},
{
"path": "../../packages/calc-dep-state"
},
{
"path": "../../packages/error"
},
{
"path": "../../packages/types"
},
{
"path": "../../pkg-manager/client"
},
{
"path": "../../pkg-manager/headless"
},
{
"path": "../../pkg-manager/link-bins"
},
@@ -40,7 +58,10 @@
"path": "../../pkg-manifest/read-project-manifest"
},
{
"path": "../path"
"path": "../../store/package-store"
},
{
"path": "../../store/store-connection-manager"
}
]
}