From 17344ca27fbef29643012444bdcb07d7b4eafcbd Mon Sep 17 00:00:00 2001 From: Trevor Burnham Date: Thu, 6 Nov 2025 09:13:51 -0500 Subject: [PATCH] fix(update): prevent package.json updates when updating indirect dependencies (#5118) (#10155) close #5118 --- .changeset/every-trains-shake.md | 6 +++ .../src/installDeps.ts | 12 +++++- pnpm/test/update.ts | 38 +++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 .changeset/every-trains-shake.md diff --git a/.changeset/every-trains-shake.md b/.changeset/every-trains-shake.md new file mode 100644 index 0000000000..2ebbb06604 --- /dev/null +++ b/.changeset/every-trains-shake.md @@ -0,0 +1,6 @@ +--- +"@pnpm/plugin-commands-installation": patch +"pnpm": patch +--- + +When a user runs `pnpm update` on a dependency that is not directly listed in `package.json`, none of the direct dependencies should be updated [#10155](https://github.com/pnpm/pnpm/pull/10155). diff --git a/pkg-manager/plugin-commands-installation/src/installDeps.ts b/pkg-manager/plugin-commands-installation/src/installDeps.ts index 889e5d3dfb..9dc7c40bd3 100644 --- a/pkg-manager/plugin-commands-installation/src/installDeps.ts +++ b/pkg-manager/plugin-commands-installation/src/installDeps.ts @@ -272,6 +272,8 @@ when running add/update with the --workspace option') } let updateMatch: UpdateDepsMatcher | null + let updatePackageManifest = opts.updatePackageManifest + let updateMatching: ((pkgName: string) => boolean) | undefined if (opts.update) { if (params.length === 0) { const ignoreDeps = opts.updateConfig?.ignoreDependencies @@ -291,6 +293,10 @@ when running add/update with the --workspace option') throw new PnpmError('NO_PACKAGE_IN_DEPENDENCIES', 'None of the specified packages were found in the dependencies.') } + // No direct dependencies matched, so we're updating indirect dependencies only + // Don't update package.json in this case, and limit updates to only matching dependencies + updatePackageManifest = false + updateMatching = (pkgName: string) => updateMatch!(pkgName) != null } } @@ -343,7 +349,11 @@ when running add/update with the --workspace option') return } - const { updatedCatalogs, updatedManifest, ignoredBuilds } = await install(manifest, installOpts) + const { updatedCatalogs, updatedManifest, ignoredBuilds } = await install(manifest, { + ...installOpts, + updatePackageManifest, + updateMatching, + }) if (opts.update === true && opts.save !== false) { await Promise.all([ writeProjectManifest(updatedManifest), diff --git a/pnpm/test/update.ts b/pnpm/test/update.ts index 4480e50ba9..4dd295a858 100644 --- a/pnpm/test/update.ts +++ b/pnpm/test/update.ts @@ -684,6 +684,44 @@ test('update with tag @latest will downgrade prerelease', async function () { expect(lockfile2).toHaveProperty(['packages', '@pnpm.e2e/has-prerelease@2.0.0']) }) +test('update indirect dependency should not update package.json', async function () { + const project = prepare({ + dependencies: { + '@pnpm.e2e/pkg-with-1-dep': '^100.0.0', + }, + }) + + // Ensure the initial versions + await addDistTag('@pnpm.e2e/pkg-with-1-dep', '100.0.0', 'latest') + await addDistTag('@pnpm.e2e/dep-of-pkg-with-1-dep', '100.0.0', 'latest') + + await execPnpm(['install']) + + const pkg1 = await readPackageJsonFromDir(process.cwd()) + expect(pkg1.dependencies?.['@pnpm.e2e/pkg-with-1-dep']).toBe('^100.0.0') + + const lockfile1 = project.readLockfile() + expect(lockfile1.importers['.'].dependencies?.['@pnpm.e2e/pkg-with-1-dep'].version).toBe('100.0.0') + + // Now publish a new version of the direct dependency and update the indirect dependency + await addDistTag('@pnpm.e2e/pkg-with-1-dep', '100.1.0', 'latest') + await addDistTag('@pnpm.e2e/dep-of-pkg-with-1-dep', '100.1.0', 'latest') + + // Update the indirect dependency only + await execPnpm(['update', '@pnpm.e2e/dep-of-pkg-with-1-dep@latest']) + + // The direct dependency in package.json should remain unchanged at ^100.0.0 + const pkg2 = await readPackageJsonFromDir(process.cwd()) + expect(pkg2.dependencies?.['@pnpm.e2e/pkg-with-1-dep']).toBe('^100.0.0') + + // But the lockfile should have the updated indirect dependency + const lockfile2 = project.readLockfile() + expect(Object.keys(lockfile2.packages ?? {})).toContain('@pnpm.e2e/dep-of-pkg-with-1-dep@100.1.0') + + // The direct dependency should remain at 100.0.0 in the lockfile (not upgraded to 100.1.0) + expect(lockfile2.importers['.'].dependencies?.['@pnpm.e2e/pkg-with-1-dep'].version).toBe('100.0.0') +}) + test('update to latest recursive workspace (outdated, updated, prerelease, outdated)', async function () { await addDistTag('@pnpm.e2e/has-prerelease', '2.0.0', 'latest')