From a8f016ca59ac28194f6e4e9b618a7f14fd229b7d Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Wed, 11 Mar 2026 00:39:37 +0100 Subject: [PATCH] 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**. --- .changeset/config-deps-lockfile.md | 15 + .changeset/five-lies-travel.md | 1 - .changeset/polite-carpets-relax.md | 1 - cli/cli-utils/src/getConfig.ts | 28 +- cli/cli-utils/src/index.ts | 2 +- config/deps-installer/package.json | 10 +- config/deps-installer/src/index.ts | 2 +- .../deps-installer/src/installConfigDeps.ts | 100 ++++- .../deps-installer/src/migrateConfigDeps.ts | 104 +++++ .../deps-installer/src/normalizeConfigDeps.ts | 65 ---- config/deps-installer/src/parseIntegrity.ts | 26 ++ .../deps-installer/src/resolveConfigDeps.ts | 90 +++-- .../src/resolveManifestDependencies.ts | 88 +++++ .../src/resolvePackageManagerIntegrities.ts | 98 +++++ .../deps-installer/test/installConfigDeps.ts | 84 +++- .../test/normalizeConfigDeps.test.ts | 83 ---- .../test/resolveConfigDeps.test.ts | 20 +- config/deps-installer/tsconfig.json | 24 ++ .../src/iteratePkgsForVirtualStore.ts | 33 +- lockfile/fs/src/envLockfile.ts | 85 ++++ lockfile/fs/src/index.ts | 3 +- lockfile/fs/src/write.ts | 22 +- lockfile/types/src/index.ts | 13 + lockfile/utils/src/index.ts | 1 + lockfile/utils/src/toLockfileResolution.ts | 46 +++ packages/calc-dep-state/src/index.ts | 28 +- packages/constants/src/index.ts | 1 + packages/plugin-commands-setup/package.json | 2 - packages/plugin-commands-setup/src/setup.ts | 65 ++-- .../plugin-commands-setup/test/setup.test.ts | 5 - packages/types/src/package.ts | 14 + pkg-manager/resolve-dependencies/package.json | 1 - .../src/updateLockfile.ts | 46 +-- pnpm-lock.yaml | 86 ++-- pnpm/package.json | 4 +- pnpm/src/main.ts | 3 +- pnpm/src/switchCliVersion.ts | 56 ++- pnpm/test/configurationalDependencies.test.ts | 114 +++++- pnpm/test/install/selfUpdate.ts | 27 +- pnpm/test/switchingVersions.test.ts | 21 +- pnpm/tsconfig.json | 8 +- tools/path/CHANGELOG.md | 9 - tools/path/README.md | 15 - tools/path/package.json | 41 -- tools/path/src/index.ts | 13 - tools/path/tsconfig.json | 12 - tools/path/tsconfig.lint.json | 8 - .../plugin-commands-self-updater/package.json | 13 +- .../plugin-commands-self-updater/src/index.ts | 2 +- .../src/installPnpm.ts | 367 ++++++++++++++++++ .../src/installPnpmToTools.ts | 112 ------ .../src/selfUpdate.ts | 53 ++- .../test/selfUpdate.test.ts | 161 +++++++- .../tsconfig.json | 25 +- 54 files changed, 1712 insertions(+), 644 deletions(-) create mode 100644 .changeset/config-deps-lockfile.md create mode 100644 config/deps-installer/src/migrateConfigDeps.ts delete mode 100644 config/deps-installer/src/normalizeConfigDeps.ts create mode 100644 config/deps-installer/src/parseIntegrity.ts create mode 100644 config/deps-installer/src/resolveManifestDependencies.ts create mode 100644 config/deps-installer/src/resolvePackageManagerIntegrities.ts delete mode 100644 config/deps-installer/test/normalizeConfigDeps.test.ts create mode 100644 lockfile/fs/src/envLockfile.ts create mode 100644 lockfile/utils/src/toLockfileResolution.ts delete mode 100644 tools/path/CHANGELOG.md delete mode 100644 tools/path/README.md delete mode 100644 tools/path/package.json delete mode 100644 tools/path/src/index.ts delete mode 100644 tools/path/tsconfig.json delete mode 100644 tools/path/tsconfig.lint.json create mode 100644 tools/plugin-commands-self-updater/src/installPnpm.ts delete mode 100644 tools/plugin-commands-self-updater/src/installPnpmToTools.ts diff --git a/.changeset/config-deps-lockfile.md b/.changeset/config-deps-lockfile.md new file mode 100644 index 0000000000..1468ff0b33 --- /dev/null +++ b/.changeset/config-deps-lockfile.md @@ -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. diff --git a/.changeset/five-lies-travel.md b/.changeset/five-lies-travel.md index 57bd554a59..8bfcf859fd 100644 --- a/.changeset/five-lies-travel.md +++ b/.changeset/five-lies-travel.md @@ -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 diff --git a/.changeset/polite-carpets-relax.md b/.changeset/polite-carpets-relax.md index bbe390e0e0..28fd225d5b 100644 --- a/.changeset/polite-carpets-relax.md +++ b/.changeset/polite-carpets-relax.md @@ -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 diff --git a/cli/cli-utils/src/getConfig.ts b/cli/cli-utils/src/getConfig.ts index bb5fda597f..ac496bc7a8 100644 --- a/cli/cli-utils/src/getConfig.ts +++ b/cli/cli-utils/src/getConfig.ts @@ -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 { 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 } diff --git a/cli/cli-utils/src/index.ts b/cli/cli-utils/src/index.ts index 86b4f50442..8f2ac90497 100644 --- a/cli/cli-utils/src/index.ts +++ b/cli/cli-utils/src/index.ts @@ -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' diff --git a/config/deps-installer/package.json b/config/deps-installer/package.json index 5a0904be9e..9fcaa6f620 100644 --- a/config/deps-installer/package.json +++ b/config/deps-installer/package.json @@ -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:*", diff --git a/config/deps-installer/src/index.ts b/config/deps-installer/src/index.ts index 3aa13944b9..3ab6c04216 100644 --- a/config/deps-installer/src/index.ts +++ b/config/deps-installer/src/index.ts @@ -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' diff --git a/config/deps-installer/src/installConfigDeps.ts b/config/deps-installer/src/installConfigDeps.ts index 1b87c8f336..0885ebd2f4 100644 --- a/config/deps-installer/src/installConfigDeps.ts +++ b/config/deps-installer/src/installConfigDeps.ts @@ -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 { +/** + * 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 { + 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> { + // 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 { + const deps: Record = {} + 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 +} diff --git a/config/deps-installer/src/migrateConfigDeps.ts b/config/deps-installer/src/migrateConfigDeps.ts new file mode 100644 index 0000000000..c17698d314 --- /dev/null +++ b/config/deps-installer/src/migrateConfigDeps.ts @@ -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> { + const envLockfile = createEnvLockfile() + const cleanSpecifiers: ConfigDependencySpecifiers = {} + const normalizedDeps: Record = {} + + 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 +} diff --git a/config/deps-installer/src/normalizeConfigDeps.ts b/config/deps-installer/src/normalizeConfigDeps.ts deleted file mode 100644 index b79826b7dd..0000000000 --- a/config/deps-installer/src/normalizeConfigDeps.ts +++ /dev/null @@ -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 - -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 } -} diff --git a/config/deps-installer/src/parseIntegrity.ts b/config/deps-installer/src/parseIntegrity.ts new file mode 100644 index 0000000000..7ffa970938 --- /dev/null +++ b/config/deps-installer/src/parseIntegrity.ts @@ -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 } +} diff --git a/config/deps-installer/src/resolveConfigDeps.ts b/config/deps-installer/src/resolveConfigDeps.ts index a9ad70dcd4..c0e0f220aa 100644 --- a/config/deps-installer/src/resolveConfigDeps.ts +++ b/config/deps-installer/src/resolveConfigDeps.ts @@ -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 } diff --git a/config/deps-installer/src/resolveManifestDependencies.ts b/config/deps-installer/src/resolveManifestDependencies.ts new file mode 100644 index 0000000000..c4a5687387 --- /dev/null +++ b/config/deps-installer/src/resolveManifestDependencies.ts @@ -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 { + 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 +} diff --git a/config/deps-installer/src/resolvePackageManagerIntegrities.ts b/config/deps-installer/src/resolvePackageManagerIntegrities.ts new file mode 100644 index 0000000000..efc74070e5 --- /dev/null +++ b/config/deps-installer/src/resolvePackageManagerIntegrities.ts @@ -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 { + 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 = {} + 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 +} diff --git a/config/deps-installer/test/installConfigDeps.ts b/config/deps-installer/test/installConfigDeps.ts index c0b13c9b98..9540e30c64 100644 --- a/config/deps-installer/test/installConfigDeps.ts +++ b/config/deps-installer/test/installConfigDeps.ts @@ -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): 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 = { - '@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 = { - '@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 = { + '@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('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') }) diff --git a/config/deps-installer/test/normalizeConfigDeps.test.ts b/config/deps-installer/test/normalizeConfigDeps.test.ts deleted file mode 100644 index 8cdd865152..0000000000 --- a/config/deps-installer/test/normalizeConfigDeps.test.ts +++ /dev/null @@ -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") -}) diff --git a/config/deps-installer/test/resolveConfigDeps.test.ts b/config/deps-installer/test/resolveConfigDeps.test.ts index 928f34e33a..529d0dc027 100644 --- a/config/deps-installer/test/resolveConfigDeps.test.ts +++ b/config/deps-installer/test/resolveConfigDeps.test.ts @@ -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 }>('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({}) }) diff --git a/config/deps-installer/tsconfig.json b/config/deps-installer/tsconfig.json index 28e39985d9..97bb69778a 100644 --- a/config/deps-installer/tsconfig.json +++ b/config/deps-installer/tsconfig.json @@ -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" }, diff --git a/deps/graph-builder/src/iteratePkgsForVirtualStore.ts b/deps/graph-builder/src/iteratePkgsForVirtualStore.ts index dcaac08c1c..6c2caff9d5 100644 --- a/deps/graph-builder/src/iteratePkgsForVirtualStore.ts +++ b/deps/graph-builder/src/iteratePkgsForVirtualStore.ts @@ -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> { const graph = lockfileToDepGraph(lockfile) return iterateHashedGraphNodes(graph, iteratePkgMeta(lockfile, graph), allowBuild) } - -function * iteratePkgMeta (lockfile: LockfileObject, graph: DepsGraph): PkgMetaIterator { - 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, - } - } -} diff --git a/lockfile/fs/src/envLockfile.ts b/lockfile/fs/src/envLockfile.ts new file mode 100644 index 0000000000..bc918b800c --- /dev/null +++ b/lockfile/fs/src/envLockfile.ts @@ -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 { + 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 + 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 { + 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), + } +} diff --git a/lockfile/fs/src/index.ts b/lockfile/fs/src/index.ts index bda604bd9e..96afa09b41 100644 --- a/lockfile/fs/src/index.ts +++ b/lockfile/fs/src/index.ts @@ -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' diff --git a/lockfile/fs/src/write.ts b/lockfile/fs/src/write.ts index d23e67d685..867e335c04 100644 --- a/lockfile/fs/src/write.ts +++ b/lockfile/fs/src/write.ts @@ -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 { - return new Promise((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 { diff --git a/lockfile/types/src/index.ts b/lockfile/types/src/index.ts index 728af8a1a3..b6d4fcef16 100644 --- a/lockfile/types/src/index.ts +++ b/lockfile/types/src/index.ts @@ -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 +export interface EnvLockfile { + lockfileVersion: string + importers: { + '.': { + configDependencies: Record + packageManagerDependencies?: Record + } + } + packages: Record + snapshots: Record +} + export interface CatalogSnapshots { [catalogName: string]: { [dependencyName: string]: ResolvedCatalogEntry } } diff --git a/lockfile/utils/src/index.ts b/lockfile/utils/src/index.ts index d3f5486402..fc59f85ed2 100644 --- a/lockfile/utils/src/index.ts +++ b/lockfile/utils/src/index.ts @@ -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 diff --git a/lockfile/utils/src/toLockfileResolution.ts b/lockfile/utils/src/toLockfileResolution.ts new file mode 100644 index 0000000000..22c89e7dcb --- /dev/null +++ b/lockfile/utils/src/toLockfileResolution.ts @@ -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] +} diff --git a/packages/calc-dep-state/src/index.ts b/packages/calc-dep-state/src/index.ts index b413bbf6df..ac75ed26db 100644 --- a/packages/calc-dep-state/src/index.ts +++ b/packages/calc-dep-state/src/index.ts @@ -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 = Record> @@ -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): PkgMetaIterator { + 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 { const graph: DepsGraph = {} if (lockfile.packages != null) { diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index 953d272060..b0c481bd16 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -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` diff --git a/packages/plugin-commands-setup/package.json b/packages/plugin-commands-setup/package.json index 4b82894680..48e0bdcce0 100644 --- a/packages/plugin-commands-setup/package.json +++ b/packages/plugin-commands-setup/package.json @@ -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": { diff --git a/packages/plugin-commands-setup/src/setup.ts b/packages/plugin-commands-setup/src/setup.ts index 2048960725..c0739dd556 100644 --- a/packages/plugin-commands-setup/src/setup.ts +++ b/packages/plugin-commands-setup/src/setup.ts @@ -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 => ({}) @@ -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\\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:`. + * This places pnpm in the standard global directory alongside other + * globally installed packages. */ -async function copyCli (currentLocation: string, targetDir: string): Promise { - 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 { const execPath = getExecPath() if (execPath.match(/\.[cm]?js$/) == null) { - await copyCli(execPath, opts.pnpmHomeDir) + installCliGlobally(execPath, opts.pnpmHomeDir) createPnpxScripts(opts.pnpmHomeDir) } try { diff --git a/packages/plugin-commands-setup/test/setup.test.ts b/packages/plugin-commands-setup/test/setup.test.ts index 948747d4aa..ef31c384c0 100644 --- a/packages/plugin-commands-setup/test/setup.test.ts +++ b/packages/plugin-commands-setup/test/setup.test.ts @@ -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') diff --git a/packages/types/src/package.ts b/packages/types/src/package.ts index ba1778d0fe..4599131e22 100644 --- a/packages/types/src/package.ts +++ b/packages/types/src/package.ts @@ -143,11 +143,25 @@ export type AllowedDeprecatedVersions = Record 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 +/** + * Clean specifiers for configDependencies in pnpm-workspace.yaml (new format). + * Integrity info is stored in pnpm-lock.env.yaml instead. + */ +export type ConfigDependencySpecifiers = Record + export interface AuditConfig { ignoreCves?: string[] ignoreGhsas?: string[] diff --git a/pkg-manager/resolve-dependencies/package.json b/pkg-manager/resolve-dependencies/package.json index ebfd5c8429..502d1d4616 100644 --- a/pkg-manager/resolve-dependencies/package.json +++ b/pkg-manager/resolve-dependencies/package.json @@ -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:", diff --git a/pkg-manager/resolve-dependencies/src/updateLockfile.ts b/pkg-manager/resolve-dependencies/src/updateLockfile.ts index 9604882345..d973abac0b 100644 --- a/pkg-manager/resolve-dependencies/src/updateLockfile.ts +++ b/pkg-manager/resolve-dependencies/src/updateLockfile.ts @@ -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] -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22737b9112..37191e2a73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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' diff --git a/pnpm/package.json b/pnpm/package.json index 4dac54a2af..978791799d 100644 --- a/pnpm/package.json +++ b/pnpm/package.json @@ -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:", diff --git a/pnpm/src/main.ts b/pnpm/src/main.ts index 0a0494b728..906bedb6dd 100644 --- a/pnpm/src/main.ts +++ b/pnpm/src/main.ts @@ -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 { } } } + config = await installConfigDepsAndLoadHooks(config) as typeof config if (isDlxOrCreateCommand) { config.useStderr = true } diff --git a/pnpm/src/switchCliVersion.ts b/pnpm/src/switchCliVersion.ts index 7e4c723900..e53fd6cbce 100644 --- a/pnpm/src/switchCliVersion.ts +++ b/pnpm/src/switchCliVersion.ts @@ -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 { 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 { 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> | 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. diff --git a/pnpm/test/configurationalDependencies.test.ts b/pnpm/test/configurationalDependencies.test.ts index 047024aea3..fcad9df6a7 100644 --- a/pnpm/test/configurationalDependencies.test.ts +++ b/pnpm/test/configurationalDependencies.test.ts @@ -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 }>('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') + ) }) diff --git a/pnpm/test/install/selfUpdate.ts b/pnpm/test/install/selfUpdate.ts index 4958d7cf30..67d4e18fc7 100644 --- a/pnpm/test/install/selfUpdate.ts +++ b/pnpm/test/install/selfUpdate.ts @@ -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('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') +}) diff --git a/pnpm/test/switchingVersions.test.ts b/pnpm/test/switchingVersions.test.ts index 3ece56b7e5..32764189a2 100644 --- a/pnpm/test/switchingVersions.test.ts +++ b/pnpm/test/switchingVersions.test.ts @@ -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 }) diff --git a/pnpm/tsconfig.json b/pnpm/tsconfig.json index 95aa872717..e593aaa4ba 100644 --- a/pnpm/tsconfig.json +++ b/pnpm/tsconfig.json @@ -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" diff --git a/tools/path/CHANGELOG.md b/tools/path/CHANGELOG.md deleted file mode 100644 index b3c82ec41b..0000000000 --- a/tools/path/CHANGELOG.md +++ /dev/null @@ -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. diff --git a/tools/path/README.md b/tools/path/README.md deleted file mode 100644 index 5a4b614d90..0000000000 --- a/tools/path/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# @pnpm/tools.path - -> Path to tools - -[![npm version](https://img.shields.io/npm/v/@pnpm/tools.path.svg)](https://www.npmjs.com/package/@pnpm/tools.path) - -## Installation - -``` -pnpm add @pnpm/tools.path -``` - -## License - -MIT diff --git a/tools/path/package.json b/tools/path/package.json deleted file mode 100644 index 98d89c40bd..0000000000 --- a/tools/path/package.json +++ /dev/null @@ -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" - } -} diff --git a/tools/path/src/index.ts b/tools/path/src/index.ts deleted file mode 100644 index e8b0992d95..0000000000 --- a/tools/path/src/index.ts +++ /dev/null @@ -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) -} diff --git a/tools/path/tsconfig.json b/tools/path/tsconfig.json deleted file mode 100644 index c6f0399f60..0000000000 --- a/tools/path/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "@pnpm/tsconfig", - "compilerOptions": { - "outDir": "lib", - "rootDir": "src" - }, - "include": [ - "src/**/*.ts", - "../../__typings__/**/*.d.ts" - ], - "references": [] -} diff --git a/tools/path/tsconfig.lint.json b/tools/path/tsconfig.lint.json deleted file mode 100644 index 1bbe711971..0000000000 --- a/tools/path/tsconfig.lint.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "./tsconfig.json", - "include": [ - "src/**/*.ts", - "test/**/*.ts", - "../../__typings__/**/*.d.ts" - ] -} diff --git a/tools/plugin-commands-self-updater/package.json b/tools/plugin-commands-self-updater/package.json index 54a524cb3e..784c8f5de0 100644 --- a/tools/plugin-commands-self-updater/package.json +++ b/tools/plugin-commands-self-updater/package.json @@ -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:" diff --git a/tools/plugin-commands-self-updater/src/index.ts b/tools/plugin-commands-self-updater/src/index.ts index 0d1a021c87..5062bbb86b 100644 --- a/tools/plugin-commands-self-updater/src/index.ts +++ b/tools/plugin-commands-self-updater/src/index.ts @@ -1,4 +1,4 @@ import * as selfUpdate from './selfUpdate.js' -export { installPnpmToTools } from './installPnpmToTools.js' +export { installPnpm, installPnpmToStore } from './installPnpm.js' export { selfUpdate } diff --git a/tools/plugin-commands-self-updater/src/installPnpm.ts b/tools/plugin-commands-self-updater/src/installPnpm.ts new file mode 100644 index 0000000000..381947d4a1 --- /dev/null +++ b/tools/plugin-commands-self-updater/src/installPnpm.ts @@ -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 { + 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 { + 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 { + 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 { + 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 = {} + dependencies[pkgName] = version + + const packages: Record = {} + 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, + } +} diff --git a/tools/plugin-commands-self-updater/src/installPnpmToTools.ts b/tools/plugin-commands-self-updater/src/installPnpmToTools.ts deleted file mode 100644 index 1fc000562b..0000000000 --- a/tools/plugin-commands-self-updater/src/installPnpmToTools.ts +++ /dev/null @@ -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 { - 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)) - } -} diff --git a/tools/plugin-commands-self-updater/src/selfUpdate.ts b/tools/plugin-commands-self-updater/src/selfUpdate.ts index e56ccdb408..6b4221d958 100644 --- a/tools/plugin-commands-self-updater/src/selfUpdate.ts +++ b/tools/plugin-commands-self-updater/src/selfUpdate.ts @@ -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 { return pick([], allTypes) @@ -37,15 +39,12 @@ export function help (): string { }) } -export type SelfUpdateCommandOptions = Pick @@ -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 } diff --git a/tools/plugin-commands-self-updater/test/selfUpdate.test.ts b/tools/plugin-commands-self-updater/test/selfUpdate.test.ts index fc9701b4f3..6e72e24d18 100644 --- a/tools/plugin-commands-self-updater/test/selfUpdate.test.ts +++ b/tools/plugin-commands-self-updater/test/selfUpdate.test.ts @@ -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) +}) diff --git a/tools/plugin-commands-self-updater/tsconfig.json b/tools/plugin-commands-self-updater/tsconfig.json index d7e5a9891e..dadafa20af 100644 --- a/tools/plugin-commands-self-updater/tsconfig.json +++ b/tools/plugin-commands-self-updater/tsconfig.json @@ -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" } ] }