Skip to content

Commit 1a1b7ee

Browse files
committed
Install pnpm via buildpack dependency
1 parent 7001ce7 commit 1a1b7ee

File tree

3 files changed

+125
-32
lines changed

3 files changed

+125
-32
lines changed

manifest.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,4 +114,13 @@ dependencies:
114114
- cflinuxfs3
115115
source: https://github.com/yarnpkg/yarn/releases/download/v1.22.22/yarn-v1.22.22.tar.gz
116116
source_sha256: 88268464199d1611fcf73ce9c0a6c4d44c7d5363682720d8506f6508addf36a0
117+
- name: pnpm
118+
version: 10.28.2
119+
uri: https://github.com/pnpm/pnpm/releases/download/v10.28.2/pnpm-linux-x64
120+
sha256: 9f38396f660cd3b1f71e0b0af0b7ba22dd1fd446dc37840a1b6a025868557390
121+
cf_stacks:
122+
- cflinuxfs4
123+
- cflinuxfs3
124+
source: https://github.com/pnpm/pnpm/archive/refs/tags/v10.28.2.tar.gz
125+
source_sha256: f5078e3376b449ee18842fce49d3c26db2f04d164b0da674b40234ee8d4db583
117126
pre_package: scripts/build.sh

src/nodejs/supply/supply.go

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -502,7 +502,7 @@ func (s *Supplier) NoPackageLockTip() error {
502502
}
503503

504504
if s.IsVendored {
505-
s.Log.Protip("Warning: package-lock.json not found. The buildpack may reach out to the internet to download module updates, even if they are vendored.", "https://docs.cloudfoundry.org/buildpacks/node/index.html#offline_environments")
505+
s.Log.Protip(fmt.Sprintf("Warning: %s not found. The buildpack may reach out to the internet to download module updates, even if they are vendored.", strings.Join(lockFiles, " or ")), "https://docs.cloudfoundry.org/buildpacks/node/index.html#offline_environments")
506506
}
507507
}
508508

@@ -853,31 +853,70 @@ func (s *Supplier) InstallPNPM() error {
853853
return nil
854854
}
855855

856-
installTarget := "pnpm"
856+
versions := s.Manifest.AllDependencyVersions("pnpm")
857+
selectedVersion := ""
857858
if s.PNPMVersion != "" {
858-
// Basic security validation for version string to prevent command injection
859-
// Allow digits, dots, 'v' prefix, alphabetic tags (beta, rc), hyphens, and asterisk.
860-
validVersion := regexp.MustCompile(`^[v0-9a-zA-Z\.\-\*]+$`)
861-
if !validVersion.MatchString(s.PNPMVersion) {
862-
s.Log.Warning("Invalid pnpm version specified in package.json: '%s'. Ignoring and using default.", s.PNPMVersion)
863-
} else {
864-
installTarget = "pnpm@" + s.PNPMVersion
859+
matchedVersion, err := libbuildpack.FindMatchingVersion(s.PNPMVersion, versions)
860+
if err != nil {
861+
return fmt.Errorf("package.json requested %s, buildpack only includes pnpm version %s", s.PNPMVersion, strings.Join(versions, ", "))
862+
}
863+
selectedVersion = matchedVersion
864+
} else {
865+
if len(versions) == 0 {
866+
return fmt.Errorf("no versions of pnpm found")
865867
}
868+
if len(versions) > 1 {
869+
return fmt.Errorf("pnpm version not specified and more than one version available: %s", strings.Join(versions, ", "))
870+
}
871+
selectedVersion = versions[0]
872+
}
873+
874+
pnpmInstallDir := filepath.Join(s.Stager.DepDir(), "pnpm")
875+
if err := s.Installer.InstallOnlyVersion("pnpm", pnpmInstallDir); err != nil {
876+
return err
866877
}
867878

868-
s.Log.Info("Installing %s via npm...", installTarget)
879+
binDir := filepath.Join(pnpmInstallDir, "bin")
880+
pnpmBin := filepath.Join(binDir, "pnpm")
869881

870-
nodeDir := filepath.Join(s.Stager.DepDir(), "node")
871-
npmArgs := []string{"install", "--unsafe-perm", "--quiet", "-g", installTarget, "--prefix", nodeDir, "--userconfig", filepath.Join(s.Stager.BuildDir(), ".npmrc")}
872-
if err := s.Command.Execute(s.Stager.BuildDir(), s.Log.Output(), s.Log.Output(), "npm", npmArgs...); err != nil {
873-
s.Log.Error("Unable to install pnpm: %s", err.Error())
882+
if exists, err := libbuildpack.FileExists(pnpmBin); err != nil {
883+
return err
884+
} else if !exists {
885+
entry, err := s.Manifest.GetEntry(libbuildpack.Dependency{Name: "pnpm", Version: selectedVersion})
886+
if err != nil {
887+
return err
888+
}
889+
candidate := filepath.Join(pnpmInstallDir, filepath.Base(entry.URI))
890+
candidateExists, err := libbuildpack.FileExists(candidate)
891+
if err != nil {
892+
return err
893+
}
894+
if !candidateExists {
895+
return fmt.Errorf("pnpm binary not found after install")
896+
}
897+
if err := os.MkdirAll(binDir, 0755); err != nil {
898+
return err
899+
}
900+
if err := os.Rename(candidate, pnpmBin); err != nil {
901+
return err
902+
}
903+
if err := os.Chmod(pnpmBin, 0755); err != nil {
904+
return err
905+
}
906+
}
907+
908+
if err := s.Stager.LinkDirectoryInDepDir(binDir, "bin"); err != nil {
874909
return err
875910
}
876911

877-
if err := s.Stager.LinkDirectoryInDepDir(filepath.Join(nodeDir, "bin"), "bin"); err != nil {
912+
buffer := new(bytes.Buffer)
913+
if err := s.Command.Execute(s.Stager.BuildDir(), buffer, buffer, "pnpm", "--version"); err != nil {
878914
return err
879915
}
880916

917+
pnpmVersion := strings.TrimSpace(buffer.String())
918+
s.Log.Info("Installed pnpm %s", pnpmVersion)
919+
881920
return nil
882921
}
883922

src/nodejs/supply/supply_test.go

Lines changed: 62 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ var _ = Describe("Supply", func() {
3838
mockCommand *MockCommand
3939
installNode func(libbuildpack.Dependency, string)
4040
installOnlyYarn func(string, string)
41+
installOnlyPNPM func(string, string)
4142
)
4243

4344
BeforeEach(func() {
@@ -91,6 +92,14 @@ var _ = Describe("Supply", func() {
9192
Expect(err).To(BeNil())
9293
}
9394

95+
installOnlyPNPM = func(_ string, pnpmDir string) {
96+
err := os.MkdirAll(filepath.Join(pnpmDir, "bin"), 0755)
97+
Expect(err).To(BeNil())
98+
99+
err = os.WriteFile(filepath.Join(pnpmDir, "bin", "pnpm"), []byte("pnpm exe"), 0644)
100+
Expect(err).To(BeNil())
101+
}
102+
94103
args := []string{buildDir, cacheDir, depsDir, depsIdx}
95104
stager := libbuildpack.NewStager(args, logger, &libbuildpack.Manifest{})
96105

@@ -689,36 +698,72 @@ var _ = Describe("Supply", func() {
689698
})
690699

691700
Describe("InstallPNPM", func() {
692-
Context("pnpm version is not set", func() {
693-
It("installs latest pnpm via npm", func() {
694-
supplier.UsePNPM = true
701+
var pnpmInstallDir string
695702

696-
// Mock corepack check failure to fallback to npm
697-
mockCommand.EXPECT().Execute(buildDir, gomock.Any(), gomock.Any(), "corepack", "enable").Return(fmt.Errorf("not found"))
703+
BeforeEach(func() {
704+
pnpmInstallDir = filepath.Join(depsDir, depsIdx, "pnpm")
705+
})
698706

699-
mockCommand.EXPECT().Execute(buildDir, gomock.Any(), gomock.Any(),
700-
"npm", "install", "--unsafe-perm", "--quiet", "-g", "pnpm",
701-
"--userconfig", filepath.Join(buildDir, ".npmrc")).Return(nil)
707+
Context("pnpm version is unset", func() {
708+
BeforeEach(func() {
709+
mockInstaller.EXPECT().InstallOnlyVersion("pnpm", pnpmInstallDir).Do(installOnlyPNPM).Return(nil)
702710

711+
mockCommand.EXPECT().Execute(buildDir, gomock.Any(), gomock.Any(), "pnpm", "--version").Do(func(_ string, buffer io.Writer, _ io.Writer, _ string, _ ...string) {
712+
buffer.Write([]byte("10.28.2\n"))
713+
}).Return(nil)
714+
})
715+
716+
It("installs the only version in the manifest", func() {
717+
supplier.UsePNPM = true
718+
supplier.PNPMVersion = ""
703719
err = supplier.InstallPNPM()
704720
Expect(err).To(BeNil())
721+
Expect(buffer.String()).To(ContainSubstring("Installed pnpm 10.28.2"))
705722
})
706-
})
707723

708-
Context("pnpm version is set", func() {
709-
It("installs requested pnpm version via npm", func() {
724+
It("creates a symlink in <depDir>/bin", func() {
710725
supplier.UsePNPM = true
726+
err = supplier.InstallPNPM()
727+
Expect(err).To(BeNil())
711728

712-
// Mock corepack check failure to fallback to npm
713-
mockCommand.EXPECT().Execute(buildDir, gomock.Any(), gomock.Any(), "corepack", "enable").Return(fmt.Errorf("not found"))
729+
link, err := os.Readlink(filepath.Join(depsDir, depsIdx, "bin", "pnpm"))
730+
Expect(err).To(BeNil())
731+
Expect(link).To(Equal("../pnpm/bin/pnpm"))
732+
})
733+
})
714734

715-
mockCommand.EXPECT().Execute(buildDir, gomock.Any(), gomock.Any(),
716-
"npm", "install", "--unsafe-perm", "--quiet", "-g", "pnpm@1.2.3",
717-
"--userconfig", filepath.Join(buildDir, ".npmrc")).Return(nil)
735+
Context("requested pnpm version is in manifest", func() {
736+
BeforeEach(func() {
737+
versions := []string{"10.28.2"}
738+
mockManifest.EXPECT().AllDependencyVersions("pnpm").Return(versions)
739+
mockInstaller.EXPECT().InstallOnlyVersion("pnpm", pnpmInstallDir).Do(installOnlyPNPM).Return(nil)
718740

719-
supplier.PNPMVersion = "1.2.3"
741+
mockCommand.EXPECT().Execute(buildDir, gomock.Any(), gomock.Any(), "pnpm", "--version").Do(func(_ string, buffer io.Writer, _ io.Writer, _ string, _ ...string) {
742+
buffer.Write([]byte("10.28.2\n"))
743+
}).Return(nil)
744+
})
745+
746+
It("installs the correct version from the manifest", func() {
747+
supplier.UsePNPM = true
748+
supplier.PNPMVersion = "10.28.x"
720749
err = supplier.InstallPNPM()
721750
Expect(err).To(BeNil())
751+
Expect(buffer.String()).To(ContainSubstring("Installed pnpm 10.28.2"))
752+
})
753+
})
754+
755+
Context("requested pnpm version is not in manifest", func() {
756+
BeforeEach(func() {
757+
versions := []string{"10.28.2"}
758+
mockManifest.EXPECT().AllDependencyVersions("pnpm").Return(versions)
759+
})
760+
761+
It("returns an error", func() {
762+
supplier.UsePNPM = true
763+
supplier.PNPMVersion = "9.0.x"
764+
err = supplier.InstallPNPM()
765+
Expect(err).ToNot(BeNil())
766+
Expect(err.Error()).To(Equal("package.json requested 9.0.x, buildpack only includes pnpm version 10.28.2"))
722767
})
723768
})
724769

0 commit comments

Comments
 (0)