diff --git a/.changeset/plenty-spoons-search.md b/.changeset/plenty-spoons-search.md new file mode 100644 index 0000000000..c77129c1cf --- /dev/null +++ b/.changeset/plenty-spoons-search.md @@ -0,0 +1,8 @@ +--- +"@pnpm/headless": patch +"@pnpm/core": patch +"pnpm": patch +--- + +When the `node-linker` is set to `hoisted`, the `package.json` files of the existing dependencies inside `node_modules` are checked to verify their actual versions. The data in the `node_modules/.modules.yaml` and `node_modules/.pnpm/lock.yaml` may not be fully reliable, as an installation may fail after changes to dependencies were made but before those state files were updated. + diff --git a/pkg-manager/core/test/packageImportMethods.ts b/pkg-manager/core/test/packageImportMethods.ts index f140f15f76..5efc5331b3 100644 --- a/pkg-manager/core/test/packageImportMethods.ts +++ b/pkg-manager/core/test/packageImportMethods.ts @@ -1,5 +1,7 @@ +import fs from 'fs' import { prepareEmpty } from '@pnpm/prepare' import { addDependenciesToPackage } from '@pnpm/core' +import { sync as loadJsonFile } from 'load-json-file' import { testDefaults } from './utils' test('packageImportMethod can be set to copy', async () => { @@ -22,3 +24,27 @@ test('copy does not fail on package that self-requires itself', async () => { const lockfile = await project.readLockfile() expect(lockfile.packages['/@pnpm.e2e/requires-itself@1.0.0'].dependencies).toStrictEqual({ 'is-positive': '1.0.0' }) }) + +test('packages are updated in node_modules, when packageImportMethod is set to copy and modules manifest and current lockfile are incorrect', async () => { + prepareEmpty() + const opts = await testDefaults({ fastUnpack: false, force: false, nodeLinker: 'hoisted' }, {}, {}, { packageImportMethod: 'copy' }) + + await addDependenciesToPackage({}, ['is-negative@1.0.0'], opts) + const modulesManifestContent = fs.readFileSync('node_modules/.modules.yaml') + const currentLockfile = fs.readFileSync('node_modules/.pnpm/lock.yaml') + { + const pkg = loadJsonFile('node_modules/is-negative/package.json') // eslint-disable-line + expect(pkg.version).toBe('1.0.0') + } + await addDependenciesToPackage({}, ['is-negative@2.0.0'], opts) + { + const pkg = loadJsonFile('node_modules/is-negative/package.json') // eslint-disable-line + expect(pkg.version).toBe('2.0.0') + } + fs.writeFileSync('node_modules/.modules.yaml', modulesManifestContent, 'utf8') + fs.writeFileSync('node_modules/.pnpm/lock.yaml', currentLockfile, 'utf8') + await addDependenciesToPackage({}, ['is-negative@1.0.0'], opts) + + const pkg = loadJsonFile('node_modules/is-negative/package.json') // eslint-disable-line + expect(pkg.version).toBe('1.0.0') +}) diff --git a/pkg-manager/headless/src/linkHoistedModules.ts b/pkg-manager/headless/src/linkHoistedModules.ts index 7c97974316..a7bdbe0364 100644 --- a/pkg-manager/headless/src/linkHoistedModules.ts +++ b/pkg-manager/headless/src/linkHoistedModules.ts @@ -124,7 +124,7 @@ async function linkAllPkgsInOrder ( await limitLinking(async () => { const { importMethod, isBuilt } = await storeController.importPackage(depNode.dir, { filesResponse, - force: opts.force || depNode.depPath !== prevGraph[dir]?.depPath, + force: true, disableRelinkLocalDirDeps: opts.disableRelinkLocalDirDeps, keepModulesDir: true, requiresBuild: depNode.requiresBuild || depNode.patchFile != null, diff --git a/pkg-manager/headless/src/lockfileToHoistedDepGraph.ts b/pkg-manager/headless/src/lockfileToHoistedDepGraph.ts index 63fccecd17..108d9bf590 100644 --- a/pkg-manager/headless/src/lockfileToHoistedDepGraph.ts +++ b/pkg-manager/headless/src/lockfileToHoistedDepGraph.ts @@ -12,6 +12,7 @@ import { } from '@pnpm/lockfile-utils' import { type IncludedDependencies } from '@pnpm/modules-yaml' import { packageIsInstallable } from '@pnpm/package-is-installable' +import { safeReadPackageJsonFromDir } from '@pnpm/read-package-json' import { type PatchFile, type Registries } from '@pnpm/types' import { type FetchPackageToStoreFunction, @@ -193,8 +194,12 @@ async function fetchDeps ( // It will only be missing if the user manually removed it. // That shouldn't normally happen but Bit CLI does remove node_modules in component directories: // https://github.com/teambit/bit/blob/5e1eed7cd122813ad5ea124df956ee89d661d770/scopes/dependencies/dependency-resolver/dependency-installer.ts#L169 + // + // We also verify that the package that is present has the expected version. + // This check is required because there is no guarantee the modules manifest and current lockfile were + // successfully saved after node_modules was changed during installation. const skipFetch = opts.currentHoistedLocations?.[depPath]?.includes(depLocation) && - await pathExists(path.join(opts.lockfileDir, depLocation)) + await dirHasPackageJsonWithVersion(path.join(opts.lockfileDir, depLocation), pkgVersion) const pkgResolution = { id: packageId, resolution, @@ -254,6 +259,19 @@ async function fetchDeps ( return depHierarchy } +async function dirHasPackageJsonWithVersion (dir: string, expectedVersion?: string): Promise { + if (!expectedVersion) return pathExists(dir) + try { + const manifest = await safeReadPackageJsonFromDir(dir) + return manifest?.version === expectedVersion + } catch (err: any) { // eslint-disable-line + if (err.code === 'ENOENT') { + return pathExists(dir) + } + throw err + } +} + function getChildren ( pkgSnapshot: PackageSnapshot, pkgLocationsByDepPath: Record,