mirror of
https://github.com/pnpm/pnpm.git
synced 2026-04-27 18:46:18 -04:00
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:
15
.changeset/config-deps-lockfile.md
Normal file
15
.changeset/config-deps-lockfile.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
104
config/deps-installer/src/migrateConfigDeps.ts
Normal file
104
config/deps-installer/src/migrateConfigDeps.ts
Normal 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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
26
config/deps-installer/src/parseIntegrity.ts
Normal file
26
config/deps-installer/src/parseIntegrity.ts
Normal 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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
88
config/deps-installer/src/resolveManifestDependencies.ts
Normal file
88
config/deps-installer/src/resolveManifestDependencies.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
@@ -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({})
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
85
lockfile/fs/src/envLockfile.ts
Normal file
85
lockfile/fs/src/envLockfile.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
46
lockfile/utils/src/toLockfileResolution.ts
Normal file
46
lockfile/utils/src/toLockfileResolution.ts
Normal 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]
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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
86
pnpm-lock.yaml
generated
@@ -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'
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -1,15 +0,0 @@
|
||||
# @pnpm/tools.path
|
||||
|
||||
> Path to tools
|
||||
|
||||
[](https://www.npmjs.com/package/@pnpm/tools.path)
|
||||
|
||||
## Installation
|
||||
|
||||
```
|
||||
pnpm add @pnpm/tools.path
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"extends": "@pnpm/tsconfig",
|
||||
"compilerOptions": {
|
||||
"outDir": "lib",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"../../__typings__/**/*.d.ts"
|
||||
],
|
||||
"references": []
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts",
|
||||
"../../__typings__/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -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:"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as selfUpdate from './selfUpdate.js'
|
||||
export { installPnpmToTools } from './installPnpmToTools.js'
|
||||
export { installPnpm, installPnpmToStore } from './installPnpm.js'
|
||||
|
||||
export { selfUpdate }
|
||||
|
||||
367
tools/plugin-commands-self-updater/src/installPnpm.ts
Normal file
367
tools/plugin-commands-self-updater/src/installPnpm.ts
Normal 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>,
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user