aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/cli/internal/packagemanager
diff options
context:
space:
mode:
Diffstat (limited to 'cli/internal/packagemanager')
-rw-r--r--cli/internal/packagemanager/berry.go156
-rw-r--r--cli/internal/packagemanager/fixtures/package.json7
-rw-r--r--cli/internal/packagemanager/fixtures/pnpm-patches.json11
-rw-r--r--cli/internal/packagemanager/fixtures/pnpm-workspace.yaml3
-rw-r--r--cli/internal/packagemanager/infer_root.go146
-rw-r--r--cli/internal/packagemanager/infer_root_test.go347
-rw-r--r--cli/internal/packagemanager/npm.go59
-rw-r--r--cli/internal/packagemanager/packagemanager.go197
-rw-r--r--cli/internal/packagemanager/packagemanager_test.go411
-rw-r--r--cli/internal/packagemanager/pnpm.go168
-rw-r--r--cli/internal/packagemanager/pnpm6.go63
-rw-r--r--cli/internal/packagemanager/pnpm_test.go57
-rw-r--r--cli/internal/packagemanager/yarn.go116
13 files changed, 1741 insertions, 0 deletions
diff --git a/cli/internal/packagemanager/berry.go b/cli/internal/packagemanager/berry.go
new file mode 100644
index 0000000..d6264b1
--- /dev/null
+++ b/cli/internal/packagemanager/berry.go
@@ -0,0 +1,156 @@
+package packagemanager
+
+import (
+ "fmt"
+ "os/exec"
+ "strings"
+
+ "github.com/Masterminds/semver"
+ "github.com/pkg/errors"
+ "github.com/vercel/turbo/cli/internal/fs"
+ "github.com/vercel/turbo/cli/internal/lockfile"
+ "github.com/vercel/turbo/cli/internal/turbopath"
+ "github.com/vercel/turbo/cli/internal/util"
+)
+
+var nodejsBerry = PackageManager{
+ Name: "nodejs-berry",
+ Slug: "yarn",
+ Command: "yarn",
+ Specfile: "package.json",
+ Lockfile: "yarn.lock",
+ PackageDir: "node_modules",
+
+ getWorkspaceGlobs: func(rootpath turbopath.AbsoluteSystemPath) ([]string, error) {
+ pkg, err := fs.ReadPackageJSON(rootpath.UntypedJoin("package.json"))
+ if err != nil {
+ return nil, fmt.Errorf("package.json: %w", err)
+ }
+ if len(pkg.Workspaces) == 0 {
+ return nil, fmt.Errorf("package.json: no workspaces found. Turborepo requires Yarn workspaces to be defined in the root package.json")
+ }
+ return pkg.Workspaces, nil
+ },
+
+ getWorkspaceIgnores: func(pm PackageManager, rootpath turbopath.AbsoluteSystemPath) ([]string, error) {
+ // Matches upstream values:
+ // Key code: https://github.com/yarnpkg/berry/blob/8e0c4b897b0881878a1f901230ea49b7c8113fbe/packages/yarnpkg-core/sources/Workspace.ts#L64-L70
+ return []string{
+ "**/node_modules",
+ "**/.git",
+ "**/.yarn",
+ }, nil
+ },
+
+ canPrune: func(cwd turbopath.AbsoluteSystemPath) (bool, error) {
+ if isNMLinker, err := util.IsNMLinker(cwd.ToStringDuringMigration()); err != nil {
+ return false, errors.Wrap(err, "could not determine if yarn is using `nodeLinker: node-modules`")
+ } else if !isNMLinker {
+ return false, errors.New("only yarn v2/v3 with `nodeLinker: node-modules` is supported at this time")
+ }
+ return true, nil
+ },
+
+ // Versions newer than 2.0 are berry, and before that we simply call them yarn.
+ Matches: func(manager string, version string) (bool, error) {
+ if manager != "yarn" {
+ return false, nil
+ }
+
+ v, err := semver.NewVersion(version)
+ if err != nil {
+ return false, fmt.Errorf("could not parse yarn version: %w", err)
+ }
+ // -0 allows pre-releases versions to be considered valid
+ c, err := semver.NewConstraint(">=2.0.0-0")
+ if err != nil {
+ return false, fmt.Errorf("could not create constraint: %w", err)
+ }
+
+ return c.Check(v), nil
+ },
+
+ // Detect for berry needs to identify which version of yarn is running on the system.
+ // Further, berry can be configured in an incompatible way, so we check for compatibility here as well.
+ detect: func(projectDirectory turbopath.AbsoluteSystemPath, packageManager *PackageManager) (bool, error) {
+ specfileExists := projectDirectory.UntypedJoin(packageManager.Specfile).FileExists()
+ lockfileExists := projectDirectory.UntypedJoin(packageManager.Lockfile).FileExists()
+
+ // Short-circuit, definitely not Yarn.
+ if !specfileExists || !lockfileExists {
+ return false, nil
+ }
+
+ cmd := exec.Command("yarn", "--version")
+ cmd.Dir = projectDirectory.ToString()
+ out, err := cmd.Output()
+ if err != nil {
+ return false, fmt.Errorf("could not detect yarn version: %w", err)
+ }
+
+ // See if we're a match when we compare these two things.
+ matches, _ := packageManager.Matches(packageManager.Slug, string(out))
+
+ // Short-circuit, definitely not Berry because version number says we're Yarn.
+ if !matches {
+ return false, nil
+ }
+
+ // We're Berry!
+
+ // Check for supported configuration.
+ isNMLinker, err := util.IsNMLinker(projectDirectory.ToStringDuringMigration())
+
+ if err != nil {
+ // Failed to read the linker state, so we treat an unknown configuration as a failure.
+ return false, fmt.Errorf("could not check if yarn is using nm-linker: %w", err)
+ } else if !isNMLinker {
+ // Not using nm-linker, so unsupported configuration.
+ return false, fmt.Errorf("only yarn nm-linker is supported")
+ }
+
+ // Berry, supported configuration.
+ return true, nil
+ },
+
+ UnmarshalLockfile: func(_rootPackageJSON *fs.PackageJSON, contents []byte) (lockfile.Lockfile, error) {
+ return lockfile.DecodeBerryLockfile(contents)
+ },
+
+ prunePatches: func(pkgJSON *fs.PackageJSON, patches []turbopath.AnchoredUnixPath) error {
+ pkgJSON.Mu.Lock()
+ defer pkgJSON.Mu.Unlock()
+
+ keysToDelete := []string{}
+ resolutions, ok := pkgJSON.RawJSON["resolutions"].(map[string]interface{})
+ if !ok {
+ return fmt.Errorf("Invalid structure for resolutions field in package.json")
+ }
+
+ for dependency, untypedPatch := range resolutions {
+ inPatches := false
+ patch, ok := untypedPatch.(string)
+ if !ok {
+ return fmt.Errorf("Expected value of %s in package.json to be a string, got %v", dependency, untypedPatch)
+ }
+
+ for _, wantedPatch := range patches {
+ if strings.HasSuffix(patch, wantedPatch.ToString()) {
+ inPatches = true
+ break
+ }
+ }
+
+ // We only want to delete unused patches as they are the only ones that throw if unused
+ if !inPatches && strings.HasSuffix(patch, ".patch") {
+ keysToDelete = append(keysToDelete, dependency)
+ }
+ }
+
+ for _, key := range keysToDelete {
+ delete(resolutions, key)
+ }
+
+ return nil
+ },
+}
diff --git a/cli/internal/packagemanager/fixtures/package.json b/cli/internal/packagemanager/fixtures/package.json
new file mode 100644
index 0000000..6b27f7c
--- /dev/null
+++ b/cli/internal/packagemanager/fixtures/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "fixture",
+ "workspaces": [
+ "apps/*",
+ "packages/*"
+ ]
+}
diff --git a/cli/internal/packagemanager/fixtures/pnpm-patches.json b/cli/internal/packagemanager/fixtures/pnpm-patches.json
new file mode 100644
index 0000000..f772bc3
--- /dev/null
+++ b/cli/internal/packagemanager/fixtures/pnpm-patches.json
@@ -0,0 +1,11 @@
+{
+ "name": "turborepo-prune-removes-patched",
+ "version": "1.0.0",
+ "packageManager": "pnpm@7.15.0",
+ "workspaces": ["packages/*"],
+ "pnpm": {
+ "patchedDependencies": {
+ "is-odd@3.0.1": "patches/is-odd@3.0.1.patch"
+ }
+ }
+}
diff --git a/cli/internal/packagemanager/fixtures/pnpm-workspace.yaml b/cli/internal/packagemanager/fixtures/pnpm-workspace.yaml
new file mode 100644
index 0000000..7fbb770
--- /dev/null
+++ b/cli/internal/packagemanager/fixtures/pnpm-workspace.yaml
@@ -0,0 +1,3 @@
+packages:
+ - "packages/*"
+ - "!packages/skip"
diff --git a/cli/internal/packagemanager/infer_root.go b/cli/internal/packagemanager/infer_root.go
new file mode 100644
index 0000000..7920f12
--- /dev/null
+++ b/cli/internal/packagemanager/infer_root.go
@@ -0,0 +1,146 @@
+package packagemanager
+
+import (
+ "path/filepath"
+
+ "github.com/vercel/turbo/cli/internal/doublestar"
+ "github.com/vercel/turbo/cli/internal/turbopath"
+)
+
+// PackageType represents the mode in which turbo is running.
+type PackageType string
+
+const (
+ // Single is for single-package mode.
+ Single PackageType = "single"
+ // Multi is for monorepo mode.
+ Multi PackageType = "multi"
+)
+
+func candidateDirectoryWorkspaceGlobs(directory turbopath.AbsoluteSystemPath) []string {
+ packageManagers := []PackageManager{
+ nodejsNpm,
+ nodejsPnpm,
+ }
+
+ for _, pm := range packageManagers {
+ globs, err := pm.getWorkspaceGlobs(directory)
+ if err != nil {
+ // Try the other package manager workspace formats.
+ continue
+ }
+
+ return globs
+ }
+
+ return nil
+}
+
+func isOneOfTheWorkspaces(globs []string, nearestPackageJSONDir turbopath.AbsoluteSystemPath, currentPackageJSONDir turbopath.AbsoluteSystemPath) bool {
+ for _, glob := range globs {
+ globpattern := currentPackageJSONDir.UntypedJoin(filepath.FromSlash(glob)).ToString()
+ match, _ := doublestar.PathMatch(globpattern, nearestPackageJSONDir.ToString())
+ if match {
+ return true
+ }
+ }
+
+ return false
+}
+
+// InferRoot identifies which directory we should treat as the root, and which mode
+// turbo should be in when operating at that directory.
+func InferRoot(directory turbopath.AbsoluteSystemPath) (turbopath.AbsoluteSystemPath, PackageType) {
+ // Go doesn't have iterators, so this is very not-elegant.
+
+ // Scenarios:
+ // 0. Has a turbo.json but doesn't have a peer package.json. directory + multi
+ // 1. Nearest turbo.json, check peer package.json/pnpm-workspace.yaml.
+ // A. Has workspaces, multi package mode.
+ // B. No workspaces, single package mode.
+ // 2. If no turbo.json find the closest package.json parent.
+ // A. No parent package.json, default to current behavior.
+ // B. Nearest package.json defines workspaces. Can't be in single-package mode, so we bail. (This could be changed in the future.)
+ // 3. Closest package.json does not define workspaces. Traverse toward the root looking for package.jsons.
+ // A. No parent package.json with workspaces. nearestPackageJson + single
+ // B. Stop at the first one that has workspaces.
+ // i. If we are one of the workspaces, directory + multi. (This could be changed in the future.)
+ // ii. If we're not one of the workspaces, nearestPackageJson + single.
+
+ nearestTurboJSON, findTurboJSONErr := directory.Findup("turbo.json")
+ if nearestTurboJSON == "" || findTurboJSONErr != nil {
+ // We didn't find a turbo.json. We're in situation 2 or 3.
+
+ // Unroll the first loop for Scenario 2
+ nearestPackageJSON, nearestPackageJSONErr := directory.Findup("package.json")
+
+ // If we fail to find any package.json files we aren't in single package mode.
+ // We let things go through our existing failure paths.
+ // Scenario 2A.
+ if nearestPackageJSON == "" || nearestPackageJSONErr != nil {
+ return directory, Multi
+ }
+
+ // If we find a package.json which has workspaces we aren't in single package mode.
+ // We let things go through our existing failure paths.
+ // Scenario 2B.
+ if candidateDirectoryWorkspaceGlobs(nearestPackageJSON.Dir()) != nil {
+ // In a future world we could maybe change this behavior.
+ // return nearestPackageJson.Dir(), Multi
+ return directory, Multi
+ }
+
+ // Scenario 3.
+ // Find the nearest package.json that has workspaces.
+ // If found _and_ the nearestPackageJson is one of the workspaces, thatPackageJson + multi.
+ // Else, nearestPackageJson + single
+ cursor := nearestPackageJSON.Dir().UntypedJoin("..")
+ for {
+ nextPackageJSON, nextPackageJSONErr := cursor.Findup("package.json")
+ if nextPackageJSON == "" || nextPackageJSONErr != nil {
+ // We haven't found a parent defining workspaces.
+ // So we're single package mode at nearestPackageJson.
+ // Scenario 3A.
+ return nearestPackageJSON.Dir(), Single
+ }
+
+ // Found a package.json file, see if it has workspaces.
+ // Workspaces are not allowed to be recursive, so we know what to
+ // return the moment we find something with workspaces.
+ globs := candidateDirectoryWorkspaceGlobs(nextPackageJSON.Dir())
+ if globs != nil {
+ if isOneOfTheWorkspaces(globs, nearestPackageJSON.Dir(), nextPackageJSON.Dir()) {
+ // If it has workspaces, and nearestPackageJson is one of them, we're multi.
+ // We don't infer in this scenario.
+ // Scenario 3BI.
+ // TODO: return nextPackageJson.Dir(), Multi
+ return directory, Multi
+ }
+
+ // We found a parent with workspaces, but we're not one of them.
+ // We choose to operate in single package mode.
+ // Scenario 3BII
+ return nearestPackageJSON.Dir(), Single
+ }
+
+ // Loop around and see if we have another parent.
+ cursor = nextPackageJSON.Dir().UntypedJoin("..")
+ }
+ } else {
+ // If there is no sibling package.json we do no inference.
+ siblingPackageJSONPath := nearestTurboJSON.Dir().UntypedJoin("package.json")
+ if !siblingPackageJSONPath.Exists() {
+ // We do no inference.
+ // Scenario 0
+ return directory, Multi
+ }
+
+ if candidateDirectoryWorkspaceGlobs(nearestTurboJSON.Dir()) != nil {
+ // Scenario 1A.
+ return nearestTurboJSON.Dir(), Multi
+ }
+
+ // Scenario 1B.
+ return nearestTurboJSON.Dir(), Single
+ }
+}
diff --git a/cli/internal/packagemanager/infer_root_test.go b/cli/internal/packagemanager/infer_root_test.go
new file mode 100644
index 0000000..2e37a80
--- /dev/null
+++ b/cli/internal/packagemanager/infer_root_test.go
@@ -0,0 +1,347 @@
+package packagemanager
+
+import (
+ "reflect"
+ "testing"
+
+ "github.com/vercel/turbo/cli/internal/turbopath"
+ "gotest.tools/v3/assert"
+)
+
+func TestInferRoot(t *testing.T) {
+ type file struct {
+ path turbopath.AnchoredSystemPath
+ content []byte
+ }
+
+ tests := []struct {
+ name string
+ fs []file
+ executionDirectory turbopath.AnchoredSystemPath
+ rootPath turbopath.AnchoredSystemPath
+ packageMode PackageType
+ }{
+ // Scenario 0
+ {
+ name: "turbo.json at current dir, no package.json",
+ fs: []file{
+ {path: turbopath.AnchoredUnixPath("turbo.json").ToSystemPath()},
+ },
+ executionDirectory: turbopath.AnchoredUnixPath("").ToSystemPath(),
+ rootPath: turbopath.AnchoredUnixPath("").ToSystemPath(),
+ packageMode: Multi,
+ },
+ {
+ name: "turbo.json at parent dir, no package.json",
+ fs: []file{
+ {path: turbopath.AnchoredUnixPath("execution/path/subdir/.file").ToSystemPath()},
+ {path: turbopath.AnchoredUnixPath("turbo.json").ToSystemPath()},
+ },
+ executionDirectory: turbopath.AnchoredUnixPath("execution/path/subdir").ToSystemPath(),
+ // This is "no inference"
+ rootPath: turbopath.AnchoredUnixPath("execution/path/subdir").ToSystemPath(),
+ packageMode: Multi,
+ },
+ // Scenario 1A
+ {
+ name: "turbo.json at current dir, has package.json, has workspaces key",
+ fs: []file{
+ {path: turbopath.AnchoredUnixPath("turbo.json").ToSystemPath()},
+ {
+ path: turbopath.AnchoredUnixPath("package.json").ToSystemPath(),
+ content: []byte("{ \"workspaces\": [ \"exists\" ] }"),
+ },
+ },
+ executionDirectory: turbopath.AnchoredUnixPath("").ToSystemPath(),
+ rootPath: turbopath.AnchoredUnixPath("").ToSystemPath(),
+ packageMode: Multi,
+ },
+ {
+ name: "turbo.json at parent dir, has package.json, has workspaces key",
+ fs: []file{
+ {path: turbopath.AnchoredUnixPath("execution/path/subdir/.file").ToSystemPath()},
+ {path: turbopath.AnchoredUnixPath("turbo.json").ToSystemPath()},
+ {
+ path: turbopath.AnchoredUnixPath("package.json").ToSystemPath(),
+ content: []byte("{ \"workspaces\": [ \"exists\" ] }"),
+ },
+ },
+ executionDirectory: turbopath.AnchoredUnixPath("execution/path/subdir").ToSystemPath(),
+ rootPath: turbopath.AnchoredUnixPath("").ToSystemPath(),
+ packageMode: Multi,
+ },
+ {
+ name: "turbo.json at parent dir, has package.json, has pnpm workspaces",
+ fs: []file{
+ {path: turbopath.AnchoredUnixPath("execution/path/subdir/.file").ToSystemPath()},
+ {path: turbopath.AnchoredUnixPath("turbo.json").ToSystemPath()},
+ {
+ path: turbopath.AnchoredUnixPath("package.json").ToSystemPath(),
+ content: []byte("{}"),
+ },
+ {
+ path: turbopath.AnchoredUnixPath("pnpm-workspace.yaml").ToSystemPath(),
+ content: []byte("packages:\n - docs"),
+ },
+ },
+ executionDirectory: turbopath.AnchoredUnixPath("execution/path/subdir").ToSystemPath(),
+ rootPath: turbopath.AnchoredUnixPath("").ToSystemPath(),
+ packageMode: Multi,
+ },
+ // Scenario 1A aware of the weird thing we do for packages.
+ {
+ name: "turbo.json at current dir, has package.json, has packages key",
+ fs: []file{
+ {path: turbopath.AnchoredUnixPath("turbo.json").ToSystemPath()},
+ {
+ path: turbopath.AnchoredUnixPath("package.json").ToSystemPath(),
+ content: []byte("{ \"packages\": [ \"exists\" ] }"),
+ },
+ },
+ executionDirectory: turbopath.AnchoredUnixPath("").ToSystemPath(),
+ rootPath: turbopath.AnchoredUnixPath("").ToSystemPath(),
+ packageMode: Single,
+ },
+ {
+ name: "turbo.json at parent dir, has package.json, has packages key",
+ fs: []file{
+ {path: turbopath.AnchoredUnixPath("execution/path/subdir/.file").ToSystemPath()},
+ {path: turbopath.AnchoredUnixPath("turbo.json").ToSystemPath()},
+ {
+ path: turbopath.AnchoredUnixPath("package.json").ToSystemPath(),
+ content: []byte("{ \"packages\": [ \"exists\" ] }"),
+ },
+ },
+ executionDirectory: turbopath.AnchoredUnixPath("execution/path/subdir").ToSystemPath(),
+ rootPath: turbopath.AnchoredUnixPath("").ToSystemPath(),
+ packageMode: Single,
+ },
+ // Scenario 1A aware of the the weird thing we do for packages when both methods of specification exist.
+ {
+ name: "turbo.json at current dir, has package.json, has workspace and packages key",
+ fs: []file{
+ {path: turbopath.AnchoredUnixPath("turbo.json").ToSystemPath()},
+ {
+ path: turbopath.AnchoredUnixPath("package.json").ToSystemPath(),
+ content: []byte("{ \"workspaces\": [ \"clobbered\" ], \"packages\": [ \"exists\" ] }"),
+ },
+ },
+ executionDirectory: turbopath.AnchoredUnixPath("").ToSystemPath(),
+ rootPath: turbopath.AnchoredUnixPath("").ToSystemPath(),
+ packageMode: Multi,
+ },
+ {
+ name: "turbo.json at parent dir, has package.json, has workspace and packages key",
+ fs: []file{
+ {path: turbopath.AnchoredUnixPath("execution/path/subdir/.file").ToSystemPath()},
+ {path: turbopath.AnchoredUnixPath("turbo.json").ToSystemPath()},
+ {
+ path: turbopath.AnchoredUnixPath("package.json").ToSystemPath(),
+ content: []byte("{ \"workspaces\": [ \"clobbered\" ], \"packages\": [ \"exists\" ] }"),
+ },
+ },
+ executionDirectory: turbopath.AnchoredUnixPath("execution/path/subdir").ToSystemPath(),
+ rootPath: turbopath.AnchoredUnixPath("").ToSystemPath(),
+ packageMode: Multi,
+ },
+ // Scenario 1B
+ {
+ name: "turbo.json at current dir, has package.json, no workspaces",
+ fs: []file{
+ {path: turbopath.AnchoredUnixPath("turbo.json").ToSystemPath()},
+ {
+ path: turbopath.AnchoredUnixPath("package.json").ToSystemPath(),
+ content: []byte("{}"),
+ },
+ },
+ executionDirectory: turbopath.AnchoredUnixPath("").ToSystemPath(),
+ rootPath: turbopath.AnchoredUnixPath("").ToSystemPath(),
+ packageMode: Single,
+ },
+ {
+ name: "turbo.json at parent dir, has package.json, no workspaces",
+ fs: []file{
+ {path: turbopath.AnchoredUnixPath("execution/path/subdir/.file").ToSystemPath()},
+ {path: turbopath.AnchoredUnixPath("turbo.json").ToSystemPath()},
+ {
+ path: turbopath.AnchoredUnixPath("package.json").ToSystemPath(),
+ content: []byte("{}"),
+ },
+ },
+ executionDirectory: turbopath.AnchoredUnixPath("execution/path/subdir").ToSystemPath(),
+ rootPath: turbopath.AnchoredUnixPath("").ToSystemPath(),
+ packageMode: Single,
+ },
+ {
+ name: "turbo.json at parent dir, has package.json, no workspaces, includes pnpm",
+ fs: []file{
+ {path: turbopath.AnchoredUnixPath("execution/path/subdir/.file").ToSystemPath()},
+ {path: turbopath.AnchoredUnixPath("turbo.json").ToSystemPath()},
+ {
+ path: turbopath.AnchoredUnixPath("package.json").ToSystemPath(),
+ content: []byte("{}"),
+ },
+ {
+ path: turbopath.AnchoredUnixPath("pnpm-workspace.yaml").ToSystemPath(),
+ content: []byte(""),
+ },
+ },
+ executionDirectory: turbopath.AnchoredUnixPath("execution/path/subdir").ToSystemPath(),
+ rootPath: turbopath.AnchoredUnixPath("").ToSystemPath(),
+ packageMode: Single,
+ },
+ // Scenario 2A
+ {
+ name: "no turbo.json, no package.json at current",
+ fs: []file{},
+ executionDirectory: turbopath.AnchoredUnixPath("").ToSystemPath(),
+ rootPath: turbopath.AnchoredUnixPath("").ToSystemPath(),
+ packageMode: Multi,
+ },
+ {
+ name: "no turbo.json, no package.json at parent",
+ fs: []file{
+ {path: turbopath.AnchoredUnixPath("execution/path/subdir/.file").ToSystemPath()},
+ },
+ executionDirectory: turbopath.AnchoredUnixPath("execution/path/subdir").ToSystemPath(),
+ rootPath: turbopath.AnchoredUnixPath("execution/path/subdir").ToSystemPath(),
+ packageMode: Multi,
+ },
+ // Scenario 2B
+ {
+ name: "no turbo.json, has package.json with workspaces at current",
+ fs: []file{
+ {
+ path: turbopath.AnchoredUnixPath("package.json").ToSystemPath(),
+ content: []byte("{ \"workspaces\": [ \"exists\" ] }"),
+ },
+ },
+ executionDirectory: turbopath.AnchoredUnixPath("").ToSystemPath(),
+ rootPath: turbopath.AnchoredUnixPath("").ToSystemPath(),
+ packageMode: Multi,
+ },
+ {
+ name: "no turbo.json, has package.json with workspaces at parent",
+ fs: []file{
+ {path: turbopath.AnchoredUnixPath("execution/path/subdir/.file").ToSystemPath()},
+ {
+ path: turbopath.AnchoredUnixPath("package.json").ToSystemPath(),
+ content: []byte("{ \"workspaces\": [ \"exists\" ] }"),
+ },
+ },
+ executionDirectory: turbopath.AnchoredUnixPath("execution/path/subdir").ToSystemPath(),
+ rootPath: turbopath.AnchoredUnixPath("execution/path/subdir").ToSystemPath(),
+ packageMode: Multi,
+ },
+ {
+ name: "no turbo.json, has package.json with pnpm workspaces at parent",
+ fs: []file{
+ {path: turbopath.AnchoredUnixPath("execution/path/subdir/.file").ToSystemPath()},
+ {
+ path: turbopath.AnchoredUnixPath("package.json").ToSystemPath(),
+ content: []byte("{ \"workspaces\": [ \"exists\" ] }"),
+ },
+ {
+ path: turbopath.AnchoredUnixPath("pnpm-workspace.yaml").ToSystemPath(),
+ content: []byte("packages:\n - docs"),
+ },
+ },
+ executionDirectory: turbopath.AnchoredUnixPath("execution/path/subdir").ToSystemPath(),
+ rootPath: turbopath.AnchoredUnixPath("execution/path/subdir").ToSystemPath(),
+ packageMode: Multi,
+ },
+ // Scenario 3A
+ {
+ name: "no turbo.json, lots of package.json files but no workspaces",
+ fs: []file{
+ {
+ path: turbopath.AnchoredUnixPath("package.json").ToSystemPath(),
+ content: []byte("{}"),
+ },
+ {
+ path: turbopath.AnchoredUnixPath("one/package.json").ToSystemPath(),
+ content: []byte("{}"),
+ },
+ {
+ path: turbopath.AnchoredUnixPath("one/two/package.json").ToSystemPath(),
+ content: []byte("{}"),
+ },
+ {
+ path: turbopath.AnchoredUnixPath("one/two/three/package.json").ToSystemPath(),
+ content: []byte("{}"),
+ },
+ },
+ executionDirectory: turbopath.AnchoredUnixPath("one/two/three").ToSystemPath(),
+ rootPath: turbopath.AnchoredUnixPath("one/two/three").ToSystemPath(),
+ packageMode: Single,
+ },
+ // Scenario 3BI
+ {
+ name: "no turbo.json, lots of package.json files, and a workspace at the root that matches execution directory",
+ fs: []file{
+ {
+ path: turbopath.AnchoredUnixPath("package.json").ToSystemPath(),
+ content: []byte("{ \"workspaces\": [ \"one/two/three\" ] }"),
+ },
+ {
+ path: turbopath.AnchoredUnixPath("one/package.json").ToSystemPath(),
+ content: []byte("{}"),
+ },
+ {
+ path: turbopath.AnchoredUnixPath("one/two/package.json").ToSystemPath(),
+ content: []byte("{}"),
+ },
+ {
+ path: turbopath.AnchoredUnixPath("one/two/three/package.json").ToSystemPath(),
+ content: []byte("{}"),
+ },
+ },
+ executionDirectory: turbopath.AnchoredUnixPath("one/two/three").ToSystemPath(),
+ rootPath: turbopath.AnchoredUnixPath("one/two/three").ToSystemPath(),
+ packageMode: Multi,
+ },
+ // Scenario 3BII
+ {
+ name: "no turbo.json, lots of package.json files, and a workspace at the root that matches execution directory",
+ fs: []file{
+ {
+ path: turbopath.AnchoredUnixPath("package.json").ToSystemPath(),
+ content: []byte("{ \"workspaces\": [ \"does-not-exist\" ] }"),
+ },
+ {
+ path: turbopath.AnchoredUnixPath("one/package.json").ToSystemPath(),
+ content: []byte("{}"),
+ },
+ {
+ path: turbopath.AnchoredUnixPath("one/two/package.json").ToSystemPath(),
+ content: []byte("{}"),
+ },
+ {
+ path: turbopath.AnchoredUnixPath("one/two/three/package.json").ToSystemPath(),
+ content: []byte("{}"),
+ },
+ },
+ executionDirectory: turbopath.AnchoredUnixPath("one/two/three").ToSystemPath(),
+ rootPath: turbopath.AnchoredUnixPath("one/two/three").ToSystemPath(),
+ packageMode: Single,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ fsRoot := turbopath.AbsoluteSystemPath(t.TempDir())
+ for _, file := range tt.fs {
+ path := file.path.RestoreAnchor(fsRoot)
+ assert.NilError(t, path.Dir().MkdirAll(0777))
+ assert.NilError(t, path.WriteFile(file.content, 0777))
+ }
+
+ turboRoot, packageMode := InferRoot(tt.executionDirectory.RestoreAnchor(fsRoot))
+ if !reflect.DeepEqual(turboRoot, tt.rootPath.RestoreAnchor(fsRoot)) {
+ t.Errorf("InferRoot() turboRoot = %v, want %v", turboRoot, tt.rootPath.RestoreAnchor(fsRoot))
+ }
+ if packageMode != tt.packageMode {
+ t.Errorf("InferRoot() packageMode = %v, want %v", packageMode, tt.packageMode)
+ }
+ })
+ }
+}
diff --git a/cli/internal/packagemanager/npm.go b/cli/internal/packagemanager/npm.go
new file mode 100644
index 0000000..ce2eb8c
--- /dev/null
+++ b/cli/internal/packagemanager/npm.go
@@ -0,0 +1,59 @@
+package packagemanager
+
+import (
+ "fmt"
+
+ "github.com/vercel/turbo/cli/internal/fs"
+ "github.com/vercel/turbo/cli/internal/lockfile"
+ "github.com/vercel/turbo/cli/internal/turbopath"
+)
+
+var nodejsNpm = PackageManager{
+ Name: "nodejs-npm",
+ Slug: "npm",
+ Command: "npm",
+ Specfile: "package.json",
+ Lockfile: "package-lock.json",
+ PackageDir: "node_modules",
+ ArgSeparator: []string{"--"},
+
+ getWorkspaceGlobs: func(rootpath turbopath.AbsoluteSystemPath) ([]string, error) {
+ pkg, err := fs.ReadPackageJSON(rootpath.UntypedJoin("package.json"))
+ if err != nil {
+ return nil, fmt.Errorf("package.json: %w", err)
+ }
+ if len(pkg.Workspaces) == 0 {
+ return nil, fmt.Errorf("package.json: no workspaces found. Turborepo requires npm workspaces to be defined in the root package.json")
+ }
+ return pkg.Workspaces, nil
+ },
+
+ getWorkspaceIgnores: func(pm PackageManager, rootpath turbopath.AbsoluteSystemPath) ([]string, error) {
+ // Matches upstream values:
+ // function: https://github.com/npm/map-workspaces/blob/a46503543982cb35f51cc2d6253d4dcc6bca9b32/lib/index.js#L73
+ // key code: https://github.com/npm/map-workspaces/blob/a46503543982cb35f51cc2d6253d4dcc6bca9b32/lib/index.js#L90-L96
+ // call site: https://github.com/npm/cli/blob/7a858277171813b37d46a032e49db44c8624f78f/lib/workspaces/get-workspaces.js#L14
+ return []string{
+ "**/node_modules/**",
+ }, nil
+ },
+
+ Matches: func(manager string, version string) (bool, error) {
+ return manager == "npm", nil
+ },
+
+ detect: func(projectDirectory turbopath.AbsoluteSystemPath, packageManager *PackageManager) (bool, error) {
+ specfileExists := projectDirectory.UntypedJoin(packageManager.Specfile).FileExists()
+ lockfileExists := projectDirectory.UntypedJoin(packageManager.Lockfile).FileExists()
+
+ return (specfileExists && lockfileExists), nil
+ },
+
+ canPrune: func(cwd turbopath.AbsoluteSystemPath) (bool, error) {
+ return true, nil
+ },
+
+ UnmarshalLockfile: func(_rootPackageJSON *fs.PackageJSON, contents []byte) (lockfile.Lockfile, error) {
+ return lockfile.DecodeNpmLockfile(contents)
+ },
+}
diff --git a/cli/internal/packagemanager/packagemanager.go b/cli/internal/packagemanager/packagemanager.go
new file mode 100644
index 0000000..dc5b966
--- /dev/null
+++ b/cli/internal/packagemanager/packagemanager.go
@@ -0,0 +1,197 @@
+// Adapted from https://github.com/replit/upm
+// Copyright (c) 2019 Neoreason d/b/a Repl.it. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package packagemanager
+
+import (
+ "fmt"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ "github.com/pkg/errors"
+ "github.com/vercel/turbo/cli/internal/fs"
+ "github.com/vercel/turbo/cli/internal/globby"
+ "github.com/vercel/turbo/cli/internal/lockfile"
+ "github.com/vercel/turbo/cli/internal/turbopath"
+ "github.com/vercel/turbo/cli/internal/util"
+)
+
+// PackageManager is an abstraction across package managers
+type PackageManager struct {
+ // The descriptive name of the Package Manager.
+ Name string
+
+ // The unique identifier of the Package Manager.
+ Slug string
+
+ // The command used to invoke the Package Manager.
+ Command string
+
+ // The location of the package spec file used by the Package Manager.
+ Specfile string
+
+ // The location of the package lock file used by the Package Manager.
+ Lockfile string
+
+ // The directory in which package assets are stored by the Package Manager.
+ PackageDir string
+
+ // The location of the file that defines the workspace. Empty if workspaces defined in package.json
+ WorkspaceConfigurationPath string
+
+ // The separator that the Package Manger uses to identify arguments that
+ // should be passed through to the underlying script.
+ ArgSeparator []string
+
+ // Return the list of workspace glob
+ getWorkspaceGlobs func(rootpath turbopath.AbsoluteSystemPath) ([]string, error)
+
+ // Return the list of workspace ignore globs
+ getWorkspaceIgnores func(pm PackageManager, rootpath turbopath.AbsoluteSystemPath) ([]string, error)
+
+ // Detect if Turbo knows how to produce a pruned workspace for the project
+ canPrune func(cwd turbopath.AbsoluteSystemPath) (bool, error)
+
+ // Test a manager and version tuple to see if it is the Package Manager.
+ Matches func(manager string, version string) (bool, error)
+
+ // Detect if the project is using the Package Manager by inspecting the system.
+ detect func(projectDirectory turbopath.AbsoluteSystemPath, packageManager *PackageManager) (bool, error)
+
+ // Read a lockfile for a given package manager
+ UnmarshalLockfile func(rootPackageJSON *fs.PackageJSON, contents []byte) (lockfile.Lockfile, error)
+
+ // Prune the given pkgJSON to only include references to the given patches
+ prunePatches func(pkgJSON *fs.PackageJSON, patches []turbopath.AnchoredUnixPath) error
+}
+
+var packageManagers = []PackageManager{
+ nodejsYarn,
+ nodejsBerry,
+ nodejsNpm,
+ nodejsPnpm,
+ nodejsPnpm6,
+}
+
+var (
+ packageManagerPattern = `(npm|pnpm|yarn)@(\d+)\.\d+\.\d+(-.+)?`
+ packageManagerRegex = regexp.MustCompile(packageManagerPattern)
+)
+
+// ParsePackageManagerString takes a package manager version string parses it into consituent components
+func ParsePackageManagerString(packageManager string) (manager string, version string, err error) {
+ match := packageManagerRegex.FindString(packageManager)
+ if len(match) == 0 {
+ return "", "", fmt.Errorf("We could not parse packageManager field in package.json, expected: %s, received: %s", packageManagerPattern, packageManager)
+ }
+
+ return strings.Split(match, "@")[0], strings.Split(match, "@")[1], nil
+}
+
+// GetPackageManager attempts all methods for identifying the package manager in use.
+func GetPackageManager(projectDirectory turbopath.AbsoluteSystemPath, pkg *fs.PackageJSON) (packageManager *PackageManager, err error) {
+ result, _ := readPackageManager(pkg)
+ if result != nil {
+ return result, nil
+ }
+
+ return detectPackageManager(projectDirectory)
+}
+
+// readPackageManager attempts to read the package manager from the package.json.
+func readPackageManager(pkg *fs.PackageJSON) (packageManager *PackageManager, err error) {
+ if pkg.PackageManager != "" {
+ manager, version, err := ParsePackageManagerString(pkg.PackageManager)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, packageManager := range packageManagers {
+ isResponsible, err := packageManager.Matches(manager, version)
+ if isResponsible && (err == nil) {
+ return &packageManager, nil
+ }
+ }
+ }
+
+ return nil, errors.New(util.Sprintf("We did not find a package manager specified in your root package.json. Please set the \"packageManager\" property in your root package.json (${UNDERLINE}https://nodejs.org/api/packages.html#packagemanager)${RESET} or run `npx @turbo/codemod add-package-manager` in the root of your monorepo."))
+}
+
+// detectPackageManager attempts to detect the package manager by inspecting the project directory state.
+func detectPackageManager(projectDirectory turbopath.AbsoluteSystemPath) (packageManager *PackageManager, err error) {
+ for _, packageManager := range packageManagers {
+ isResponsible, err := packageManager.detect(projectDirectory, &packageManager)
+ if err != nil {
+ return nil, err
+ }
+ if isResponsible {
+ return &packageManager, nil
+ }
+ }
+
+ return nil, errors.New(util.Sprintf("We did not detect an in-use package manager for your project. Please set the \"packageManager\" property in your root package.json (${UNDERLINE}https://nodejs.org/api/packages.html#packagemanager)${RESET} or run `npx @turbo/codemod add-package-manager` in the root of your monorepo."))
+}
+
+// GetWorkspaces returns the list of package.json files for the current repository.
+func (pm PackageManager) GetWorkspaces(rootpath turbopath.AbsoluteSystemPath) ([]string, error) {
+ globs, err := pm.getWorkspaceGlobs(rootpath)
+ if err != nil {
+ return nil, err
+ }
+
+ justJsons := make([]string, len(globs))
+ for i, space := range globs {
+ justJsons[i] = filepath.Join(space, "package.json")
+ }
+
+ ignores, err := pm.getWorkspaceIgnores(pm, rootpath)
+ if err != nil {
+ return nil, err
+ }
+
+ f, err := globby.GlobFiles(rootpath.ToStringDuringMigration(), justJsons, ignores)
+ if err != nil {
+ return nil, err
+ }
+
+ return f, nil
+}
+
+// GetWorkspaceIgnores returns an array of globs not to search for workspaces.
+func (pm PackageManager) GetWorkspaceIgnores(rootpath turbopath.AbsoluteSystemPath) ([]string, error) {
+ return pm.getWorkspaceIgnores(pm, rootpath)
+}
+
+// CanPrune returns if turbo can produce a pruned workspace. Can error if fs issues occur
+func (pm PackageManager) CanPrune(projectDirectory turbopath.AbsoluteSystemPath) (bool, error) {
+ if pm.canPrune != nil {
+ return pm.canPrune(projectDirectory)
+ }
+ return false, nil
+}
+
+// ReadLockfile will read the applicable lockfile into memory
+func (pm PackageManager) ReadLockfile(projectDirectory turbopath.AbsoluteSystemPath, rootPackageJSON *fs.PackageJSON) (lockfile.Lockfile, error) {
+ if pm.UnmarshalLockfile == nil {
+ return nil, nil
+ }
+ contents, err := projectDirectory.UntypedJoin(pm.Lockfile).ReadFile()
+ if err != nil {
+ return nil, fmt.Errorf("reading %s: %w", pm.Lockfile, err)
+ }
+ lf, err := pm.UnmarshalLockfile(rootPackageJSON, contents)
+ if err != nil {
+ return nil, errors.Wrapf(err, "error in %v", pm.Lockfile)
+ }
+ return lf, nil
+}
+
+// PrunePatchedPackages will alter the provided pkgJSON to only reference the provided patches
+func (pm PackageManager) PrunePatchedPackages(pkgJSON *fs.PackageJSON, patches []turbopath.AnchoredUnixPath) error {
+ if pm.prunePatches != nil {
+ return pm.prunePatches(pkgJSON, patches)
+ }
+ return nil
+}
diff --git a/cli/internal/packagemanager/packagemanager_test.go b/cli/internal/packagemanager/packagemanager_test.go
new file mode 100644
index 0000000..a5dc472
--- /dev/null
+++ b/cli/internal/packagemanager/packagemanager_test.go
@@ -0,0 +1,411 @@
+package packagemanager
+
+import (
+ "os"
+ "path/filepath"
+ "reflect"
+ "sort"
+ "testing"
+
+ "github.com/vercel/turbo/cli/internal/fs"
+ "github.com/vercel/turbo/cli/internal/turbopath"
+ "gotest.tools/v3/assert"
+)
+
+func TestParsePackageManagerString(t *testing.T) {
+ tests := []struct {
+ name string
+ packageManager string
+ wantManager string
+ wantVersion string
+ wantErr bool
+ }{
+ {
+ name: "errors with a tag version",
+ packageManager: "npm@latest",
+ wantManager: "",
+ wantVersion: "",
+ wantErr: true,
+ },
+ {
+ name: "errors with no version",
+ packageManager: "npm",
+ wantManager: "",
+ wantVersion: "",
+ wantErr: true,
+ },
+ {
+ name: "requires fully-qualified semver versions (one digit)",
+ packageManager: "npm@1",
+ wantManager: "",
+ wantVersion: "",
+ wantErr: true,
+ },
+ {
+ name: "requires fully-qualified semver versions (two digits)",
+ packageManager: "npm@1.2",
+ wantManager: "",
+ wantVersion: "",
+ wantErr: true,
+ },
+ {
+ name: "supports custom labels",
+ packageManager: "npm@1.2.3-alpha.1",
+ wantManager: "npm",
+ wantVersion: "1.2.3-alpha.1",
+ wantErr: false,
+ },
+ {
+ name: "only supports specified package managers",
+ packageManager: "pip@1.2.3",
+ wantManager: "",
+ wantVersion: "",
+ wantErr: true,
+ },
+ {
+ name: "supports npm",
+ packageManager: "npm@0.0.1",
+ wantManager: "npm",
+ wantVersion: "0.0.1",
+ wantErr: false,
+ },
+ {
+ name: "supports pnpm",
+ packageManager: "pnpm@0.0.1",
+ wantManager: "pnpm",
+ wantVersion: "0.0.1",
+ wantErr: false,
+ },
+ {
+ name: "supports yarn",
+ packageManager: "yarn@111.0.1",
+ wantManager: "yarn",
+ wantVersion: "111.0.1",
+ wantErr: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotManager, gotVersion, err := ParsePackageManagerString(tt.packageManager)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("ParsePackageManagerString() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if gotManager != tt.wantManager {
+ t.Errorf("ParsePackageManagerString() got manager = %v, want manager %v", gotManager, tt.wantManager)
+ }
+ if gotVersion != tt.wantVersion {
+ t.Errorf("ParsePackageManagerString() got version = %v, want version %v", gotVersion, tt.wantVersion)
+ }
+ })
+ }
+}
+
+func TestGetPackageManager(t *testing.T) {
+ cwdRaw, err := os.Getwd()
+ assert.NilError(t, err, "os.Getwd")
+ cwd, err := fs.GetCwd(cwdRaw)
+ assert.NilError(t, err, "GetCwd")
+ tests := []struct {
+ name string
+ projectDirectory turbopath.AbsoluteSystemPath
+ pkg *fs.PackageJSON
+ want string
+ wantErr bool
+ }{
+ {
+ name: "finds npm from a package manager string",
+ projectDirectory: cwd,
+ pkg: &fs.PackageJSON{PackageManager: "npm@1.2.3"},
+ want: "nodejs-npm",
+ wantErr: false,
+ },
+ {
+ name: "finds pnpm6 from a package manager string",
+ projectDirectory: cwd,
+ pkg: &fs.PackageJSON{PackageManager: "pnpm@1.2.3"},
+ want: "nodejs-pnpm6",
+ wantErr: false,
+ },
+ {
+ name: "finds pnpm from a package manager string",
+ projectDirectory: cwd,
+ pkg: &fs.PackageJSON{PackageManager: "pnpm@7.8.9"},
+ want: "nodejs-pnpm",
+ wantErr: false,
+ },
+ {
+ name: "finds yarn from a package manager string",
+ projectDirectory: cwd,
+ pkg: &fs.PackageJSON{PackageManager: "yarn@1.2.3"},
+ want: "nodejs-yarn",
+ wantErr: false,
+ },
+ {
+ name: "finds berry from a package manager string",
+ projectDirectory: cwd,
+ pkg: &fs.PackageJSON{PackageManager: "yarn@2.3.4"},
+ want: "nodejs-berry",
+ wantErr: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotPackageManager, err := GetPackageManager(tt.projectDirectory, tt.pkg)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("GetPackageManager() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if gotPackageManager.Name != tt.want {
+ t.Errorf("GetPackageManager() = %v, want %v", gotPackageManager.Name, tt.want)
+ }
+ })
+ }
+}
+
+func Test_readPackageManager(t *testing.T) {
+ tests := []struct {
+ name string
+ pkg *fs.PackageJSON
+ want string
+ wantErr bool
+ }{
+ {
+ name: "finds npm from a package manager string",
+ pkg: &fs.PackageJSON{PackageManager: "npm@1.2.3"},
+ want: "nodejs-npm",
+ wantErr: false,
+ },
+ {
+ name: "finds pnpm6 from a package manager string",
+ pkg: &fs.PackageJSON{PackageManager: "pnpm@1.2.3"},
+ want: "nodejs-pnpm6",
+ wantErr: false,
+ },
+ {
+ name: "finds pnpm from a package manager string",
+ pkg: &fs.PackageJSON{PackageManager: "pnpm@7.8.9"},
+ want: "nodejs-pnpm",
+ wantErr: false,
+ },
+ {
+ name: "finds yarn from a package manager string",
+ pkg: &fs.PackageJSON{PackageManager: "yarn@1.2.3"},
+ want: "nodejs-yarn",
+ wantErr: false,
+ },
+ {
+ name: "finds berry from a package manager string",
+ pkg: &fs.PackageJSON{PackageManager: "yarn@2.3.4"},
+ want: "nodejs-berry",
+ wantErr: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotPackageManager, err := readPackageManager(tt.pkg)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("readPackageManager() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if gotPackageManager.Name != tt.want {
+ t.Errorf("readPackageManager() = %v, want %v", gotPackageManager.Name, tt.want)
+ }
+ })
+ }
+}
+
+func Test_GetWorkspaces(t *testing.T) {
+ type test struct {
+ name string
+ pm PackageManager
+ rootPath turbopath.AbsoluteSystemPath
+ want []string
+ wantErr bool
+ }
+
+ cwd, _ := os.Getwd()
+
+ repoRoot, err := fs.GetCwd(cwd)
+ assert.NilError(t, err, "GetCwd")
+ rootPath := map[string]turbopath.AbsoluteSystemPath{
+ "nodejs-npm": repoRoot.UntypedJoin("../../../examples/with-yarn"),
+ "nodejs-berry": repoRoot.UntypedJoin("../../../examples/with-yarn"),
+ "nodejs-yarn": repoRoot.UntypedJoin("../../../examples/with-yarn"),
+ "nodejs-pnpm": repoRoot.UntypedJoin("../../../examples/basic"),
+ "nodejs-pnpm6": repoRoot.UntypedJoin("../../../examples/basic"),
+ }
+
+ want := map[string][]string{
+ "nodejs-npm": {
+ filepath.ToSlash(filepath.Join(cwd, "../../../examples/with-yarn/apps/docs/package.json")),
+ filepath.ToSlash(filepath.Join(cwd, "../../../examples/with-yarn/apps/web/package.json")),
+ filepath.ToSlash(filepath.Join(cwd, "../../../examples/with-yarn/packages/eslint-config-custom/package.json")),
+ filepath.ToSlash(filepath.Join(cwd, "../../../examples/with-yarn/packages/tsconfig/package.json")),
+ filepath.ToSlash(filepath.Join(cwd, "../../../examples/with-yarn/packages/ui/package.json")),
+ },
+ "nodejs-berry": {
+ filepath.ToSlash(filepath.Join(cwd, "../../../examples/with-yarn/apps/docs/package.json")),
+ filepath.ToSlash(filepath.Join(cwd, "../../../examples/with-yarn/apps/web/package.json")),
+ filepath.ToSlash(filepath.Join(cwd, "../../../examples/with-yarn/packages/eslint-config-custom/package.json")),
+ filepath.ToSlash(filepath.Join(cwd, "../../../examples/with-yarn/packages/tsconfig/package.json")),
+ filepath.ToSlash(filepath.Join(cwd, "../../../examples/with-yarn/packages/ui/package.json")),
+ },
+ "nodejs-yarn": {
+ filepath.ToSlash(filepath.Join(cwd, "../../../examples/with-yarn/apps/docs/package.json")),
+ filepath.ToSlash(filepath.Join(cwd, "../../../examples/with-yarn/apps/web/package.json")),
+ filepath.ToSlash(filepath.Join(cwd, "../../../examples/with-yarn/packages/eslint-config-custom/package.json")),
+ filepath.ToSlash(filepath.Join(cwd, "../../../examples/with-yarn/packages/tsconfig/package.json")),
+ filepath.ToSlash(filepath.Join(cwd, "../../../examples/with-yarn/packages/ui/package.json")),
+ },
+ "nodejs-pnpm": {
+ filepath.ToSlash(filepath.Join(cwd, "../../../examples/basic/apps/docs/package.json")),
+ filepath.ToSlash(filepath.Join(cwd, "../../../examples/basic/apps/web/package.json")),
+ filepath.ToSlash(filepath.Join(cwd, "../../../examples/basic/packages/eslint-config-custom/package.json")),
+ filepath.ToSlash(filepath.Join(cwd, "../../../examples/basic/packages/tsconfig/package.json")),
+ filepath.ToSlash(filepath.Join(cwd, "../../../examples/basic/packages/ui/package.json")),
+ },
+ "nodejs-pnpm6": {
+ filepath.ToSlash(filepath.Join(cwd, "../../../examples/basic/apps/docs/package.json")),
+ filepath.ToSlash(filepath.Join(cwd, "../../../examples/basic/apps/web/package.json")),
+ filepath.ToSlash(filepath.Join(cwd, "../../../examples/basic/packages/eslint-config-custom/package.json")),
+ filepath.ToSlash(filepath.Join(cwd, "../../../examples/basic/packages/tsconfig/package.json")),
+ filepath.ToSlash(filepath.Join(cwd, "../../../examples/basic/packages/ui/package.json")),
+ },
+ }
+
+ tests := make([]test, len(packageManagers))
+ for i, packageManager := range packageManagers {
+ tests[i] = test{
+ name: packageManager.Name,
+ pm: packageManager,
+ rootPath: rootPath[packageManager.Name],
+ want: want[packageManager.Name],
+ wantErr: false,
+ }
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotWorkspaces, err := tt.pm.GetWorkspaces(tt.rootPath)
+
+ gotToSlash := make([]string, len(gotWorkspaces))
+ for index, workspace := range gotWorkspaces {
+ gotToSlash[index] = filepath.ToSlash(workspace)
+ }
+
+ if (err != nil) != tt.wantErr {
+ t.Errorf("GetWorkspaces() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ sort.Strings(gotToSlash)
+ if !reflect.DeepEqual(gotToSlash, tt.want) {
+ t.Errorf("GetWorkspaces() = %v, want %v", gotToSlash, tt.want)
+ }
+ })
+ }
+}
+
+func Test_GetWorkspaceIgnores(t *testing.T) {
+ type test struct {
+ name string
+ pm PackageManager
+ rootPath turbopath.AbsoluteSystemPath
+ want []string
+ wantErr bool
+ }
+
+ cwdRaw, err := os.Getwd()
+ assert.NilError(t, err, "os.Getwd")
+ cwd, err := fs.GetCwd(cwdRaw)
+ assert.NilError(t, err, "GetCwd")
+ want := map[string][]string{
+ "nodejs-npm": {"**/node_modules/**"},
+ "nodejs-berry": {"**/node_modules", "**/.git", "**/.yarn"},
+ "nodejs-yarn": {"apps/*/node_modules/**", "packages/*/node_modules/**"},
+ "nodejs-pnpm": {"**/node_modules/**", "**/bower_components/**", "packages/skip"},
+ "nodejs-pnpm6": {"**/node_modules/**", "**/bower_components/**", "packages/skip"},
+ }
+
+ tests := make([]test, len(packageManagers))
+ for i, packageManager := range packageManagers {
+ tests[i] = test{
+ name: packageManager.Name,
+ pm: packageManager,
+ rootPath: cwd.UntypedJoin("fixtures"),
+ want: want[packageManager.Name],
+ wantErr: false,
+ }
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotWorkspaceIgnores, err := tt.pm.GetWorkspaceIgnores(tt.rootPath)
+
+ gotToSlash := make([]string, len(gotWorkspaceIgnores))
+ for index, ignore := range gotWorkspaceIgnores {
+ gotToSlash[index] = filepath.ToSlash(ignore)
+ }
+
+ if (err != nil) != tt.wantErr {
+ t.Errorf("GetWorkspaceIgnores() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if !reflect.DeepEqual(gotToSlash, tt.want) {
+ t.Errorf("GetWorkspaceIgnores() = %v, want %v", gotToSlash, tt.want)
+ }
+ })
+ }
+}
+
+func Test_CanPrune(t *testing.T) {
+ type test struct {
+ name string
+ pm PackageManager
+ rootPath turbopath.AbsoluteSystemPath
+ want bool
+ wantErr bool
+ }
+
+ type want struct {
+ want bool
+ wantErr bool
+ }
+
+ cwdRaw, err := os.Getwd()
+ assert.NilError(t, err, "os.Getwd")
+ cwd, err := fs.GetCwd(cwdRaw)
+ assert.NilError(t, err, "GetCwd")
+ wants := map[string]want{
+ "nodejs-npm": {true, false},
+ "nodejs-berry": {false, true},
+ "nodejs-yarn": {true, false},
+ "nodejs-pnpm": {true, false},
+ "nodejs-pnpm6": {true, false},
+ }
+
+ tests := make([]test, len(packageManagers))
+ for i, packageManager := range packageManagers {
+ tests[i] = test{
+ name: packageManager.Name,
+ pm: packageManager,
+ rootPath: cwd.UntypedJoin("../../../examples/with-yarn"),
+ want: wants[packageManager.Name].want,
+ wantErr: wants[packageManager.Name].wantErr,
+ }
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ canPrune, err := tt.pm.CanPrune(tt.rootPath)
+
+ if (err != nil) != tt.wantErr {
+ t.Errorf("CanPrune() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if canPrune != tt.want {
+ t.Errorf("CanPrune() = %v, want %v", canPrune, tt.want)
+ }
+ })
+ }
+}
diff --git a/cli/internal/packagemanager/pnpm.go b/cli/internal/packagemanager/pnpm.go
new file mode 100644
index 0000000..e65a4dc
--- /dev/null
+++ b/cli/internal/packagemanager/pnpm.go
@@ -0,0 +1,168 @@
+package packagemanager
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/Masterminds/semver"
+ "github.com/vercel/turbo/cli/internal/fs"
+ "github.com/vercel/turbo/cli/internal/lockfile"
+ "github.com/vercel/turbo/cli/internal/turbopath"
+ "github.com/vercel/turbo/cli/internal/yaml"
+)
+
+// PnpmWorkspaces is a representation of workspace package globs found
+// in pnpm-workspace.yaml
+type PnpmWorkspaces struct {
+ Packages []string `yaml:"packages,omitempty"`
+}
+
+func readPnpmWorkspacePackages(workspaceFile turbopath.AbsoluteSystemPath) ([]string, error) {
+ bytes, err := workspaceFile.ReadFile()
+ if err != nil {
+ return nil, fmt.Errorf("%v: %w", workspaceFile, err)
+ }
+ var pnpmWorkspaces PnpmWorkspaces
+ if err := yaml.Unmarshal(bytes, &pnpmWorkspaces); err != nil {
+ return nil, fmt.Errorf("%v: %w", workspaceFile, err)
+ }
+ return pnpmWorkspaces.Packages, nil
+}
+
+func getPnpmWorkspaceGlobs(rootpath turbopath.AbsoluteSystemPath) ([]string, error) {
+ pkgGlobs, err := readPnpmWorkspacePackages(rootpath.UntypedJoin("pnpm-workspace.yaml"))
+ if err != nil {
+ return nil, err
+ }
+
+ if len(pkgGlobs) == 0 {
+ return nil, fmt.Errorf("pnpm-workspace.yaml: no packages found. Turborepo requires pnpm workspaces and thus packages to be defined in the root pnpm-workspace.yaml")
+ }
+
+ filteredPkgGlobs := []string{}
+ for _, pkgGlob := range pkgGlobs {
+ if !strings.HasPrefix(pkgGlob, "!") {
+ filteredPkgGlobs = append(filteredPkgGlobs, pkgGlob)
+ }
+ }
+ return filteredPkgGlobs, nil
+}
+
+func getPnpmWorkspaceIgnores(pm PackageManager, rootpath turbopath.AbsoluteSystemPath) ([]string, error) {
+ // Matches upstream values:
+ // function: https://github.com/pnpm/pnpm/blob/d99daa902442e0c8ab945143ebaf5cdc691a91eb/packages/find-packages/src/index.ts#L27
+ // key code: https://github.com/pnpm/pnpm/blob/d99daa902442e0c8ab945143ebaf5cdc691a91eb/packages/find-packages/src/index.ts#L30
+ // call site: https://github.com/pnpm/pnpm/blob/d99daa902442e0c8ab945143ebaf5cdc691a91eb/packages/find-workspace-packages/src/index.ts#L32-L39
+ ignores := []string{
+ "**/node_modules/**",
+ "**/bower_components/**",
+ }
+ pkgGlobs, err := readPnpmWorkspacePackages(rootpath.UntypedJoin("pnpm-workspace.yaml"))
+ if err != nil {
+ return nil, err
+ }
+ for _, pkgGlob := range pkgGlobs {
+ if strings.HasPrefix(pkgGlob, "!") {
+ ignores = append(ignores, pkgGlob[1:])
+ }
+ }
+ return ignores, nil
+}
+
+var nodejsPnpm = PackageManager{
+ Name: "nodejs-pnpm",
+ Slug: "pnpm",
+ Command: "pnpm",
+ Specfile: "package.json",
+ Lockfile: "pnpm-lock.yaml",
+ PackageDir: "node_modules",
+ // pnpm v7+ changed their handling of '--'. We no longer need to pass it to pass args to
+ // the script being run, and in fact doing so will cause the '--' to be passed through verbatim,
+ // potentially breaking scripts that aren't expecting it.
+ // We are allowed to use nil here because ArgSeparator already has a type, so it's a typed nil,
+ // This could just as easily be []string{}, but the style guide says to prefer
+ // nil for empty slices.
+ ArgSeparator: nil,
+ WorkspaceConfigurationPath: "pnpm-workspace.yaml",
+
+ getWorkspaceGlobs: getPnpmWorkspaceGlobs,
+
+ getWorkspaceIgnores: getPnpmWorkspaceIgnores,
+
+ Matches: func(manager string, version string) (bool, error) {
+ if manager != "pnpm" {
+ return false, nil
+ }
+
+ v, err := semver.NewVersion(version)
+ if err != nil {
+ return false, fmt.Errorf("could not parse pnpm version: %w", err)
+ }
+ c, err := semver.NewConstraint(">=7.0.0")
+ if err != nil {
+ return false, fmt.Errorf("could not create constraint: %w", err)
+ }
+
+ return c.Check(v), nil
+ },
+
+ detect: func(projectDirectory turbopath.AbsoluteSystemPath, packageManager *PackageManager) (bool, error) {
+ specfileExists := projectDirectory.UntypedJoin(packageManager.Specfile).FileExists()
+ lockfileExists := projectDirectory.UntypedJoin(packageManager.Lockfile).FileExists()
+
+ return (specfileExists && lockfileExists), nil
+ },
+
+ canPrune: func(cwd turbopath.AbsoluteSystemPath) (bool, error) {
+ return true, nil
+ },
+
+ UnmarshalLockfile: func(_rootPackageJSON *fs.PackageJSON, contents []byte) (lockfile.Lockfile, error) {
+ return lockfile.DecodePnpmLockfile(contents)
+ },
+
+ prunePatches: func(pkgJSON *fs.PackageJSON, patches []turbopath.AnchoredUnixPath) error {
+ return pnpmPrunePatches(pkgJSON, patches)
+ },
+}
+
+func pnpmPrunePatches(pkgJSON *fs.PackageJSON, patches []turbopath.AnchoredUnixPath) error {
+ pkgJSON.Mu.Lock()
+ defer pkgJSON.Mu.Unlock()
+
+ keysToDelete := []string{}
+ pnpmConfig, ok := pkgJSON.RawJSON["pnpm"].(map[string]interface{})
+ if !ok {
+ return fmt.Errorf("Invalid structure for pnpm field in package.json")
+ }
+ patchedDependencies, ok := pnpmConfig["patchedDependencies"].(map[string]interface{})
+ if !ok {
+ return fmt.Errorf("Invalid structure for patchedDependencies field in package.json")
+ }
+
+ for dependency, untypedPatch := range patchedDependencies {
+ patch, ok := untypedPatch.(string)
+ if !ok {
+ return fmt.Errorf("Expected only strings in patchedDependencies. Got %v", untypedPatch)
+ }
+
+ inPatches := false
+
+ for _, wantedPatch := range patches {
+ if wantedPatch.ToString() == patch {
+ inPatches = true
+ break
+ }
+ }
+
+ if !inPatches {
+ keysToDelete = append(keysToDelete, dependency)
+ }
+ }
+
+ for _, key := range keysToDelete {
+ delete(patchedDependencies, key)
+ }
+
+ return nil
+}
diff --git a/cli/internal/packagemanager/pnpm6.go b/cli/internal/packagemanager/pnpm6.go
new file mode 100644
index 0000000..6039966
--- /dev/null
+++ b/cli/internal/packagemanager/pnpm6.go
@@ -0,0 +1,63 @@
+package packagemanager
+
+import (
+ "fmt"
+
+ "github.com/Masterminds/semver"
+ "github.com/vercel/turbo/cli/internal/fs"
+ "github.com/vercel/turbo/cli/internal/lockfile"
+ "github.com/vercel/turbo/cli/internal/turbopath"
+)
+
+// Pnpm6Workspaces is a representation of workspace package globs found
+// in pnpm-workspace.yaml
+type Pnpm6Workspaces struct {
+ Packages []string `yaml:"packages,omitempty"`
+}
+
+var nodejsPnpm6 = PackageManager{
+ Name: "nodejs-pnpm6",
+ Slug: "pnpm",
+ Command: "pnpm",
+ Specfile: "package.json",
+ Lockfile: "pnpm-lock.yaml",
+ PackageDir: "node_modules",
+ ArgSeparator: []string{"--"},
+ WorkspaceConfigurationPath: "pnpm-workspace.yaml",
+
+ getWorkspaceGlobs: getPnpmWorkspaceGlobs,
+
+ getWorkspaceIgnores: getPnpmWorkspaceIgnores,
+
+ Matches: func(manager string, version string) (bool, error) {
+ if manager != "pnpm" {
+ return false, nil
+ }
+
+ v, err := semver.NewVersion(version)
+ if err != nil {
+ return false, fmt.Errorf("could not parse pnpm version: %w", err)
+ }
+ c, err := semver.NewConstraint("<7.0.0")
+ if err != nil {
+ return false, fmt.Errorf("could not create constraint: %w", err)
+ }
+
+ return c.Check(v), nil
+ },
+
+ detect: func(projectDirectory turbopath.AbsoluteSystemPath, packageManager *PackageManager) (bool, error) {
+ specfileExists := projectDirectory.UntypedJoin(packageManager.Specfile).FileExists()
+ lockfileExists := projectDirectory.UntypedJoin(packageManager.Lockfile).FileExists()
+
+ return (specfileExists && lockfileExists), nil
+ },
+
+ canPrune: func(cwd turbopath.AbsoluteSystemPath) (bool, error) {
+ return true, nil
+ },
+
+ UnmarshalLockfile: func(_rootPackageJSON *fs.PackageJSON, contents []byte) (lockfile.Lockfile, error) {
+ return lockfile.DecodePnpmLockfile(contents)
+ },
+}
diff --git a/cli/internal/packagemanager/pnpm_test.go b/cli/internal/packagemanager/pnpm_test.go
new file mode 100644
index 0000000..c05bc43
--- /dev/null
+++ b/cli/internal/packagemanager/pnpm_test.go
@@ -0,0 +1,57 @@
+package packagemanager
+
+import (
+ "os"
+ "testing"
+
+ "github.com/vercel/turbo/cli/internal/fs"
+ "github.com/vercel/turbo/cli/internal/turbopath"
+ "gotest.tools/v3/assert"
+)
+
+func pnpmPatchesSection(t *testing.T, pkgJSON *fs.PackageJSON) map[string]interface{} {
+ t.Helper()
+ pnpmSection, ok := pkgJSON.RawJSON["pnpm"].(map[string]interface{})
+ assert.Assert(t, ok)
+ patchesSection, ok := pnpmSection["patchedDependencies"].(map[string]interface{})
+ assert.Assert(t, ok)
+ return patchesSection
+}
+
+func getPnpmPackageJSON(t *testing.T) *fs.PackageJSON {
+ t.Helper()
+ rawCwd, err := os.Getwd()
+ assert.NilError(t, err)
+ cwd, err := fs.CheckedToAbsoluteSystemPath(rawCwd)
+ assert.NilError(t, err)
+ pkgJSONPath := cwd.Join("fixtures", "pnpm-patches.json")
+ pkgJSON, err := fs.ReadPackageJSON(pkgJSONPath)
+ assert.NilError(t, err)
+ return pkgJSON
+}
+
+func Test_PnpmPrunePatches_KeepsNecessary(t *testing.T) {
+ pkgJSON := getPnpmPackageJSON(t)
+ initialPatches := pnpmPatchesSection(t, pkgJSON)
+
+ assert.DeepEqual(t, initialPatches, map[string]interface{}{"is-odd@3.0.1": "patches/is-odd@3.0.1.patch"})
+
+ err := pnpmPrunePatches(pkgJSON, []turbopath.AnchoredUnixPath{turbopath.AnchoredUnixPath("patches/is-odd@3.0.1.patch")})
+ assert.NilError(t, err)
+
+ newPatches := pnpmPatchesSection(t, pkgJSON)
+ assert.DeepEqual(t, newPatches, map[string]interface{}{"is-odd@3.0.1": "patches/is-odd@3.0.1.patch"})
+}
+
+func Test_PnpmPrunePatches_RemovesExtra(t *testing.T) {
+ pkgJSON := getPnpmPackageJSON(t)
+ initialPatches := pnpmPatchesSection(t, pkgJSON)
+
+ assert.DeepEqual(t, initialPatches, map[string]interface{}{"is-odd@3.0.1": "patches/is-odd@3.0.1.patch"})
+
+ err := pnpmPrunePatches(pkgJSON, nil)
+ assert.NilError(t, err)
+
+ newPatches := pnpmPatchesSection(t, pkgJSON)
+ assert.DeepEqual(t, newPatches, map[string]interface{}{})
+}
diff --git a/cli/internal/packagemanager/yarn.go b/cli/internal/packagemanager/yarn.go
new file mode 100644
index 0000000..8779c5f
--- /dev/null
+++ b/cli/internal/packagemanager/yarn.go
@@ -0,0 +1,116 @@
+package packagemanager
+
+import (
+ "errors"
+ "fmt"
+ "os/exec"
+ "path/filepath"
+ "strings"
+
+ "github.com/Masterminds/semver"
+ "github.com/vercel/turbo/cli/internal/fs"
+ "github.com/vercel/turbo/cli/internal/lockfile"
+ "github.com/vercel/turbo/cli/internal/turbopath"
+)
+
+// NoWorkspacesFoundError is a custom error used so that upstream implementations can switch on it
+type NoWorkspacesFoundError struct{}
+
+func (e *NoWorkspacesFoundError) Error() string {
+ return "package.json: no workspaces found. Turborepo requires Yarn workspaces to be defined in the root package.json"
+}
+
+var nodejsYarn = PackageManager{
+ Name: "nodejs-yarn",
+ Slug: "yarn",
+ Command: "yarn",
+ Specfile: "package.json",
+ Lockfile: "yarn.lock",
+ PackageDir: "node_modules",
+ ArgSeparator: []string{"--"},
+
+ getWorkspaceGlobs: func(rootpath turbopath.AbsoluteSystemPath) ([]string, error) {
+ pkg, err := fs.ReadPackageJSON(rootpath.UntypedJoin("package.json"))
+ if err != nil {
+ return nil, fmt.Errorf("package.json: %w", err)
+ }
+ if len(pkg.Workspaces) == 0 {
+ return nil, &NoWorkspacesFoundError{}
+ }
+ return pkg.Workspaces, nil
+ },
+
+ getWorkspaceIgnores: func(pm PackageManager, rootpath turbopath.AbsoluteSystemPath) ([]string, error) {
+ // function: https://github.com/yarnpkg/yarn/blob/3119382885ea373d3c13d6a846de743eca8c914b/src/config.js#L799
+
+ // Yarn is unique in ignore patterns handling.
+ // The only time it does globbing is for package.json or yarn.json and it scopes the search to each workspace.
+ // For example: `apps/*/node_modules/**/+(package.json|yarn.json)`
+ // The `extglob` `+(package.json|yarn.json)` (from micromatch) after node_modules/** is redundant.
+
+ globs, err := pm.getWorkspaceGlobs(rootpath)
+ if err != nil {
+ // In case of a non-monorepo, the workspaces field is empty and only node_modules in the root should be ignored
+ var e *NoWorkspacesFoundError
+ if errors.As(err, &e) {
+ return []string{"node_modules/**"}, nil
+ }
+
+ return nil, err
+ }
+
+ ignores := make([]string, len(globs))
+
+ for i, glob := range globs {
+ ignores[i] = filepath.Join(glob, "/node_modules/**")
+ }
+
+ return ignores, nil
+ },
+
+ canPrune: func(cwd turbopath.AbsoluteSystemPath) (bool, error) {
+ return true, nil
+ },
+
+ // Versions older than 2.0 are yarn, after that they become berry
+ Matches: func(manager string, version string) (bool, error) {
+ if manager != "yarn" {
+ return false, nil
+ }
+
+ v, err := semver.NewVersion(version)
+ if err != nil {
+ return false, fmt.Errorf("could not parse yarn version: %w", err)
+ }
+ c, err := semver.NewConstraint("<2.0.0-0")
+ if err != nil {
+ return false, fmt.Errorf("could not create constraint: %w", err)
+ }
+
+ return c.Check(v), nil
+ },
+
+ // Detect for yarn needs to identify which version of yarn is running on the system.
+ detect: func(projectDirectory turbopath.AbsoluteSystemPath, packageManager *PackageManager) (bool, error) {
+ specfileExists := projectDirectory.UntypedJoin(packageManager.Specfile).FileExists()
+ lockfileExists := projectDirectory.UntypedJoin(packageManager.Lockfile).FileExists()
+
+ // Short-circuit, definitely not Yarn.
+ if !specfileExists || !lockfileExists {
+ return false, nil
+ }
+
+ cmd := exec.Command("yarn", "--version")
+ cmd.Dir = projectDirectory.ToString()
+ out, err := cmd.Output()
+ if err != nil {
+ return false, fmt.Errorf("could not detect yarn version: %w", err)
+ }
+
+ return packageManager.Matches(packageManager.Slug, strings.TrimSpace(string(out)))
+ },
+
+ UnmarshalLockfile: func(_rootPackageJSON *fs.PackageJSON, contents []byte) (lockfile.Lockfile, error) {
+ return lockfile.DecodeYarnLockfile(contents)
+ },
+}