fix(installing.commands): key selectProjectByDir graph by project.rootDir (#12380)

* fix(installing.commands): key selectProjectByDir graph by project.rootDir

`selectProjectByDir` constructs a single-entry `ProjectsGraph` for the
non-workspace install path. It was using `searchedDir` (`opts.dir`) as
the key, but downstream `recursive()` builds `manifestsByPath` from the
projects array (keyed by `project.rootDir`) and then looks up entries
via `manifestsByPath[rootDir]` where `rootDir` is drawn from
`Object.keys(selectedProjectsGraph)`. When `opts.dir` and
`project.rootDir` differ in platform-normalized form (most often on
Windows due to drive-letter casing), the lookup falls through as
`undefined` and `pnpm add <pkg>` crashes with:

  Cannot destructure property 'manifest' of 'manifestsByPath[rootDir]' as it is undefined

Pin the graph key to `project.rootDir` in both `installing/commands/src/installDeps.ts`
and `installing/commands/src/import/index.ts`, so the keys stay in sync
with `manifestsByPath`. Closes https://github.com/pnpm/pnpm/issues/12379

Written by an agent (Claude Code, claude-opus-4-7).

* docs: remove redundant comments

* test(installing.commands): cover project graph keying

* Revert "test(installing.commands): cover project graph keying"

This reverts commit 426fae9434.

* test(installing.commands): cover add with mismatched project dir

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
tsushanth
2026-06-14 06:08:42 -07:00
committed by GitHub
parent 23716ed9b0
commit 86e70d2896
4 changed files with 44 additions and 3 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/installing.commands": patch
"pnpm": patch
---
Fixed `Cannot destructure property 'manifest' of 'manifestsByPath[rootDir]' as it is undefined` regression introduced in 11.6.0 when running `pnpm add <pkg>` outside a workspace on Windows. `selectProjectByDir` was keying the resulting `ProjectsGraph` by `opts.dir` instead of `project.rootDir`, so downstream `manifestsByPath` lookups missed when the two paths normalized differently (typically drive-letter casing). [pnpm/pnpm#12379](https://github.com/pnpm/pnpm/issues/12379)

View File

@@ -337,7 +337,7 @@ function getAllVersionsFromYarnLockFile (
function selectProjectByDir (projects: Project[], searchedDir: string): ProjectsGraph | undefined {
const project = projects.find(({ rootDir }) => path.relative(rootDir, searchedDir) === '')
if (project == null) return undefined
return { [searchedDir]: { dependencies: [], package: project } }
return { [project.rootDir]: { dependencies: [], package: project } }
}
function getYarnLockfileType (

View File

@@ -519,7 +519,7 @@ export async function installDeps (
function selectProjectByDir (projects: Project[], searchedDir: string): ProjectsGraph | undefined {
const project = projects.find(({ rootDir }) => path.relative(rootDir, searchedDir) === '')
if (project == null) return undefined
return { [searchedDir]: { dependencies: [], package: project } }
return { [project.rootDir]: { dependencies: [], package: project } }
}
async function recursiveInstallThenUpdateWorkspaceState (

View File

@@ -6,7 +6,7 @@ import type { PnpmError } from '@pnpm/error'
import { add, remove } from '@pnpm/installing.commands'
import { prepare, prepareEmpty, preparePackages } from '@pnpm/prepare'
import { REGISTRY_MOCK_PORT } from '@pnpm/testing.registry-mock'
import type { ProjectManifest } from '@pnpm/types'
import type { Project, ProjectManifest, ProjectRootDir, ProjectRootDirRealPath } from '@pnpm/types'
import { loadJsonFile } from 'load-json-file'
import { temporaryDirectory } from 'tempy'
@@ -308,6 +308,41 @@ test('pnpm add automatically installs missing peer dependencies', async () => {
expect(Object.keys(lockfile.packages)).toHaveLength(5)
})
test('pnpm add handles matching workspace project when dir differs from project.rootDir', async () => {
const rootProjectManifest: ProjectManifest = {
name: 'project',
version: '0.0.0',
}
const project = prepare(rootProjectManifest)
const rootDir = project.dir() as ProjectRootDir
const allProjects: Project[] = [
{
manifest: rootProjectManifest,
rootDir,
rootDirRealPath: fs.realpathSync(rootDir) as ProjectRootDirRealPath,
writeProjectManifest: async (manifest) => project.writePackageJson(manifest),
},
]
await expect(add.handler({
...DEFAULT_OPTIONS,
allProjects,
dir: `${rootDir}${path.sep}`,
linkWorkspacePackages: false,
lockfileDir: rootDir,
rootProjectManifest,
rootProjectManifestDir: rootDir,
sharedWorkspaceLockfile: true,
workspaceDir: rootDir,
}, ['is-positive@1.0.0'])).resolves.toBeUndefined()
const manifest = await loadJsonFile<ProjectManifest>(path.resolve('package.json'))
expect(manifest.dependencies).toStrictEqual({
'is-positive': '1.0.0',
})
project.has('is-positive')
})
test('add: fail when global bin directory is not found', async () => {
prepareEmpty()