diff options
Diffstat (limited to 'cli/internal/packagemanager')
| -rw-r--r-- | cli/internal/packagemanager/berry.go | 156 | ||||
| -rw-r--r-- | cli/internal/packagemanager/fixtures/package.json | 7 | ||||
| -rw-r--r-- | cli/internal/packagemanager/fixtures/pnpm-patches.json | 11 | ||||
| -rw-r--r-- | cli/internal/packagemanager/fixtures/pnpm-workspace.yaml | 3 | ||||
| -rw-r--r-- | cli/internal/packagemanager/infer_root.go | 146 | ||||
| -rw-r--r-- | cli/internal/packagemanager/infer_root_test.go | 347 | ||||
| -rw-r--r-- | cli/internal/packagemanager/npm.go | 59 | ||||
| -rw-r--r-- | cli/internal/packagemanager/packagemanager.go | 197 | ||||
| -rw-r--r-- | cli/internal/packagemanager/packagemanager_test.go | 411 | ||||
| -rw-r--r-- | cli/internal/packagemanager/pnpm.go | 168 | ||||
| -rw-r--r-- | cli/internal/packagemanager/pnpm6.go | 63 | ||||
| -rw-r--r-- | cli/internal/packagemanager/pnpm_test.go | 57 | ||||
| -rw-r--r-- | cli/internal/packagemanager/yarn.go | 116 |
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) + }, +} |
