Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions src/nodejs/supply/mocks_test.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

79 changes: 74 additions & 5 deletions src/nodejs/supply/supply.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ type NPM interface {
}

type Yarn interface {
Build(string, string) error
Build(buildDir, cacheDir string, isBerry bool) error
}

type Stager interface {
Expand Down Expand Up @@ -77,6 +77,7 @@ type Supplier struct {
PostBuild string
UseYarn bool
UsesYarnWorkspaces bool
UsesYarnBerry bool
IsVendored bool
Yarn Yarn
NPM NPM
Expand Down Expand Up @@ -124,9 +125,16 @@ func Run(s *Supplier) error {
return err
}

if err := s.InstallYarn(); err != nil {
s.Log.Error("Unable to install yarn: %s", err.Error())
return err
if s.UsesYarnBerry {
if err := s.InstallYarnBerry(); err != nil {
s.Log.Error("Unable to install yarn (berry): %s", err.Error())
return err
}
} else {
if err := s.InstallYarn(); err != nil {
s.Log.Error("Unable to install yarn: %s", err.Error())
return err
}
}

if err := s.CreateDefaultEnv(); err != nil {
Expand Down Expand Up @@ -317,7 +325,7 @@ func (s *Supplier) BuildDependencies() error {

switch {
case s.UseYarn:
if err := s.Yarn.Build(s.Stager.BuildDir(), s.Stager.CacheDir()); err != nil {
if err := s.Yarn.Build(s.Stager.BuildDir(), s.Stager.CacheDir(), s.UsesYarnBerry); err != nil {
return err
}

Expand Down Expand Up @@ -400,6 +408,16 @@ func (s *Supplier) ReadPackageJSON() error {
return err
}

if s.UseYarn {
// .yarnrc.yml is the marker file Yarn Berry (2.x/3.x/4.x) itself uses to
// identify a project as using the "modern" (non-Classic) CLI/protocol -
// Yarn Classic (1.x) projects never have this file. See
// https://yarnpkg.com/configuration/yarnrc.
if s.UsesYarnBerry, err = libbuildpack.FileExists(filepath.Join(s.Stager.BuildDir(), ".yarnrc.yml")); err != nil {
return err
}
}

if s.IsVendored, err = libbuildpack.FileExists(filepath.Join(s.Stager.BuildDir(), "node_modules")); err != nil {
return err
}
Expand Down Expand Up @@ -799,6 +817,57 @@ func (s *Supplier) InstallYarn() error {
return nil
}

// InstallYarnBerry installs the "yarn-berry" dependency (Yarn 2.x/3.x/4.x) for
// projects whose .yarnrc.yml marks them as using the modern Yarn CLI.
//
// Unlike Yarn Classic, Yarn Berry is not distributed as a self-contained
// installable tarball with a bin/ directory - it ships as a single bundled
// CLI script (see https://repo.yarnpkg.com). InstallOnlyVersion therefore
// just copies that file as-is into the install dir (libbuildpack falls back
// to a plain copy for any dependency URI it doesn't recognize as an
// archive), so a small wrapper script is created here to expose it on PATH
// as a normal "yarn" executable via `node <script> "$@"`.
func (s *Supplier) InstallYarnBerry() error {
yarnBerryInstallDir := filepath.Join(s.Stager.DepDir(), "yarn-berry")

if err := s.Installer.InstallOnlyVersion("yarn-berry", yarnBerryInstallDir); err != nil {
return err
}

matches, err := filepath.Glob(filepath.Join(yarnBerryInstallDir, "*.js"))
if err != nil {
return err
}
if len(matches) == 0 {
return fmt.Errorf("could not locate yarn-berry CLI script in %s", yarnBerryInstallDir)
}
yarnBerryScript := matches[0]

binDir := filepath.Join(yarnBerryInstallDir, "bin")
if err := os.MkdirAll(binDir, 0755); err != nil {
return err
}

shim := fmt.Sprintf("#!/usr/bin/env bash\nexec node \"%s\" \"$@\"\n", yarnBerryScript)
if err := os.WriteFile(filepath.Join(binDir, "yarn"), []byte(shim), 0755); err != nil {
return err
}

if err := s.Stager.LinkDirectoryInDepDir(binDir, "bin"); err != nil {
return err
}

buffer := new(bytes.Buffer)
if err := s.Command.Execute(s.Stager.BuildDir(), buffer, buffer, "yarn", "--version"); err != nil {
return err
}

yarnVersion := strings.TrimSpace(buffer.String())
s.Log.Info("Installed yarn (berry) %s", yarnVersion)

return nil
}

func (s *Supplier) CreateDefaultEnv() error {
var environmentDefaults = map[string]string{
"NODE_ENV": "production",
Expand Down
90 changes: 89 additions & 1 deletion src/nodejs/supply/supply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,46 @@ var _ = Describe("Supply", func() {
})
})

Describe("InstallYarnBerry", func() {
var yarnBerryInstallDir string

BeforeEach(func() {
yarnBerryInstallDir = filepath.Join(depsDir, depsIdx, "yarn-berry")

mockInstaller.EXPECT().InstallOnlyVersion("yarn-berry", yarnBerryInstallDir).Do(func(_ string, installDir string) {
Expect(os.MkdirAll(installDir, 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(installDir, "yarn-berry-4.18.0.js"), []byte("#!/usr/bin/env node\n"), 0644)).To(Succeed())
}).Return(nil)

mockCommand.EXPECT().Execute(buildDir, gomock.Any(), gomock.Any(), "yarn", "--version").Do(func(_ string, buffer io.Writer, _ io.Writer, _ string, _ ...string) {
buffer.Write([]byte("4.18.0\n"))
}).Return(nil)
})

It("installs the yarn-berry CLI script", func() {
err = supplier.InstallYarnBerry()
Expect(err).To(BeNil())
Expect(buffer.String()).To(ContainSubstring("Installed yarn (berry) 4.18.0"))
})

It("creates an executable wrapper script in <depDir>/bin that execs the CLI script with node", func() {
err = supplier.InstallYarnBerry()
Expect(err).To(BeNil())

link, err := os.Readlink(filepath.Join(depsDir, depsIdx, "bin", "yarn"))
Expect(err).To(BeNil())
Expect(link).To(Equal("../yarn-berry/bin/yarn"))

shimContents, err := os.ReadFile(filepath.Join(yarnBerryInstallDir, "bin", "yarn"))
Expect(err).To(BeNil())
Expect(string(shimContents)).To(ContainSubstring(`exec node "` + filepath.Join(yarnBerryInstallDir, "yarn-berry-4.18.0.js") + `" "$@"`))

info, err := os.Stat(filepath.Join(yarnBerryInstallDir, "bin", "yarn"))
Expect(err).To(BeNil())
Expect(info.Mode().Perm()&0111).ToNot(BeZero(), "shim script should be executable")
})
})

Describe("InstallNPM", func() {
BeforeEach(func() {
mockCommand.EXPECT().Execute(buildDir, gomock.Any(), gomock.Any(), "npm", "--version", "--loglevel", "notice").Do(func(_ string, buffer io.Writer, _ io.Writer, _ string, _ ...string) {
Expand Down Expand Up @@ -796,6 +836,39 @@ var _ = Describe("Supply", func() {
})
})

Context("yarn.lock and .yarnrc.yml both exist", func() {
BeforeEach(func() {
Expect(os.WriteFile(filepath.Join(buildDir, "yarn.lock"), []byte("{}"), 0644)).To(Succeed())
Expect(os.WriteFile(filepath.Join(buildDir, ".yarnrc.yml"), []byte("yarnPath: .yarn/releases/yarn-4.18.0.cjs\n"), 0644)).To(Succeed())
})
It("sets UsesYarnBerry to true", func() {
Expect(supplier.ReadPackageJSON()).To(Succeed())
Expect(supplier.UseYarn).To(BeTrue())
Expect(supplier.UsesYarnBerry).To(BeTrue())
})
})

Context("yarn.lock exists but .yarnrc.yml does not", func() {
BeforeEach(func() {
Expect(os.WriteFile(filepath.Join(buildDir, "yarn.lock"), []byte("{}"), 0644)).To(Succeed())
})
It("sets UsesYarnBerry to false (Yarn Classic)", func() {
Expect(supplier.ReadPackageJSON()).To(Succeed())
Expect(supplier.UsesYarnBerry).To(BeFalse())
})
})

Context(".yarnrc.yml exists but yarn.lock does not", func() {
BeforeEach(func() {
Expect(os.WriteFile(filepath.Join(buildDir, ".yarnrc.yml"), []byte("yarnPath: .yarn/releases/yarn-4.18.0.cjs\n"), 0644)).To(Succeed())
})
It("sets UsesYarnBerry to false (not a yarn project)", func() {
Expect(supplier.ReadPackageJSON()).To(Succeed())
Expect(supplier.UseYarn).To(BeFalse())
Expect(supplier.UsesYarnBerry).To(BeFalse())
})
})

Context("node_modules exists", func() {
BeforeEach(func() {
Expect(os.MkdirAll(filepath.Join(buildDir, "node_modules"), 0755)).To(Succeed())
Expand Down Expand Up @@ -1088,7 +1161,7 @@ var _ = Describe("Supply", func() {
Context("using yarn", func() {
BeforeEach(func() {
supplier.UseYarn = true
mockYarn.EXPECT().Build(buildDir, cacheDir).DoAndReturn(func(string, string) error {
mockYarn.EXPECT().Build(buildDir, cacheDir, false).DoAndReturn(func(string, string, bool) error {
Expect(os.MkdirAll(filepath.Join(buildDir, "node_modules"), 0755)).To(Succeed())
return nil
})
Expand All @@ -1113,6 +1186,21 @@ var _ = Describe("Supply", func() {
})
})

Context("using yarn berry", func() {
BeforeEach(func() {
supplier.UseYarn = true
supplier.UsesYarnBerry = true
mockYarn.EXPECT().Build(buildDir, cacheDir, true).DoAndReturn(func(string, string, bool) error {
Expect(os.MkdirAll(filepath.Join(buildDir, "node_modules"), 0755)).To(Succeed())
return nil
})
})

It("passes isBerry=true through to Yarn.Build", func() {
Expect(supplier.BuildDependencies()).To(Succeed())
})
})

Describe("using npm", func() {
BeforeEach(func() {
supplier.UseYarn = false
Expand Down
28 changes: 27 additions & 1 deletion src/nodejs/yarn/yarn.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,33 @@ type Yarn struct {
Log *libbuildpack.Logger
}

func (y *Yarn) Build(buildDir, cacheDir string) error {
func (y *Yarn) Build(buildDir, cacheDir string, isBerry bool) error {
if isBerry {
return y.buildBerry(buildDir)
}
return y.buildClassic(buildDir, cacheDir)
}

// buildBerry installs dependencies using the Yarn Berry (2.x/3.x/4.x) CLI.
//
// Berry's CLI is not backwards compatible with Classic's flags: there is no
// "yarn config set", "--pure-lockfile", "--ignore-engines", or
// "--cache-folder" equivalent. Berry manages its own cache/linker behavior
// via the project's own .yarnrc.yml, which is left untouched here. The
// closest equivalent to Classic's "--pure-lockfile" (fail rather than
// silently update the lockfile) is Berry's "--immutable" flag.
func (y *Yarn) buildBerry(buildDir string) error {
y.Log.Info("Installing node modules (yarn.lock) [yarn berry]")

cmd := exec.Command("yarn", "install", "--immutable")
cmd.Dir = buildDir
cmd.Stdout = y.Log.Output()
cmd.Stderr = y.Log.Output()
cmd.Env = append(os.Environ(), "npm_config_nodedir="+os.Getenv("NODE_HOME"))
return y.Command.Run(cmd)
}

func (y *Yarn) buildClassic(buildDir, cacheDir string) error {
y.Log.Info("Installing node modules (yarn.lock)")

offline, err := libbuildpack.FileExists(filepath.Join(buildDir, "npm-packages-offline-cache"))
Expand Down
12 changes: 6 additions & 6 deletions src/nodejs/yarn/yarn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,22 +89,22 @@ var _ = Describe("Yarn", func() {
})

It("tells the user it is running in offline mode", func() {
Expect(y.Build(buildDir, cacheDir)).To(Succeed())
Expect(y.Build(buildDir, cacheDir, false)).To(Succeed())
Expect(buffer.String()).To(ContainSubstring("Installing node modules (yarn.lock)"))
Expect(buffer.String()).To(ContainSubstring("Found yarn mirror directory " + filepath.Join(buildDir, "npm-packages-offline-cache")))
Expect(buffer.String()).To(ContainSubstring("Running yarn in offline mode"))
})

It("runs yarn config", func() {
Expect(y.Build(buildDir, cacheDir)).To(Succeed())
Expect(y.Build(buildDir, cacheDir, false)).To(Succeed())
Expect(yarnConfig).To(Equal(map[string]string{
"yarn-offline-mirror": filepath.Join(buildDir, "npm-packages-offline-cache"),
"yarn-offline-mirror-pruning": "false",
}))
})

It("runs yarn install with offline arguments and npm_config_nodedir", func() {
Expect(y.Build(buildDir, cacheDir)).To(Succeed())
Expect(y.Build(buildDir, cacheDir, false)).To(Succeed())
Expect(yarnInstallArgs).To(Equal([]string{
"yarn", "install",
"--pure-lockfile",
Expand All @@ -118,22 +118,22 @@ var _ = Describe("Yarn", func() {

Context("NO npm-packages-offline-cache directory", func() {
It("tells the user it is running in online mode", func() {
Expect(y.Build(buildDir, cacheDir)).To(Succeed())
Expect(y.Build(buildDir, cacheDir, false)).To(Succeed())
Expect(buffer.String()).To(ContainSubstring("Installing node modules (yarn.lock)"))
Expect(buffer.String()).To(ContainSubstring("Running yarn in online mode"))
Expect(buffer.String()).To(ContainSubstring("To run yarn in offline mode, see: https://yarnpkg.com/blog/2016/11/24/offline-mirror"))
})

It("runs yarn config", func() {
Expect(y.Build(buildDir, cacheDir)).To(Succeed())
Expect(y.Build(buildDir, cacheDir, false)).To(Succeed())
Expect(yarnConfig).To(Equal(map[string]string{
"yarn-offline-mirror": filepath.Join(cacheDir, "npm-packages-offline-cache"),
"yarn-offline-mirror-pruning": "true",
}))
})

It("runs yarn install", func() {
Expect(y.Build(buildDir, cacheDir)).To(Succeed())
Expect(y.Build(buildDir, cacheDir, false)).To(Succeed())
Expect(yarnInstallArgs).To(Equal([]string{
"yarn", "install",
"--pure-lockfile",
Expand Down
Loading