diff --git a/Cargo.lock b/Cargo.lock index 8880abfe9ab4a..b29a4c148bc8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3741,7 +3741,7 @@ checksum = "1c90329e44f9208b55f45711f9558cec15d7ef8295cc65ecd6d4188ae8edc58c" dependencies = [ "atty", "backtrace", - "miette-derive", + "miette-derive 4.7.1", "once_cell", "owo-colors", "supports-color", @@ -3753,6 +3753,18 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "miette" +version = "5.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92a992891d5579caa9efd8e601f82e30a1caa79a27a5db075dde30ecb9eab357" +dependencies = [ + "miette-derive 5.8.0", + "once_cell", + "thiserror", + "unicode-width", +] + [[package]] name = "miette-derive" version = "4.7.1" @@ -3764,6 +3776,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "miette-derive" +version = "5.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c65c625186a9bcce6699394bee511e1b1aec689aa7e3be1bf4e996e75834153" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.15", +] + [[package]] name = "mimalloc" version = "0.1.34" @@ -4081,6 +4104,19 @@ dependencies = [ "turbopack-core", ] +[[package]] +name = "node-semver" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84f390c1756333538f2aed01cf280a56bc683e199b9804a504df6e7320d40116" +dependencies = [ + "bytecount", + "miette 5.8.0", + "nom", + "serde", + "thiserror", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -7205,7 +7241,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3afbf2e52ddce38da1ee204252f7b9019a12176ca73aaa0fd0c36ded1ecbec7d" dependencies = [ "anyhow", - "miette", + "miette 4.7.1", "once_cell", "parking_lot", "swc_common", @@ -8954,6 +8990,7 @@ dependencies = [ "itertools", "lazy_static", "libc", + "node-semver", "notify 5.1.0", "owo-colors", "pidlock", @@ -8961,6 +8998,7 @@ dependencies = [ "pretty_assertions", "prost", "rand 0.8.5", + "regex", "reqwest", "rustc_version_runtime", "semver 1.0.17", diff --git a/cli/internal/context/context.go b/cli/internal/context/context.go index 7e556a758facc..02dac233d618e 100644 --- a/cli/internal/context/context.go +++ b/cli/internal/context/context.go @@ -142,7 +142,7 @@ func isWorkspaceReference(packageVersion string, dependencyVersion string, cwd s } // SinglePackageGraph constructs a Context instance from a single package. -func SinglePackageGraph(repoRoot turbopath.AbsoluteSystemPath, rootPackageJSON *fs.PackageJSON) (*Context, error) { +func SinglePackageGraph(rootPackageJSON *fs.PackageJSON, packageManagerName string) (*Context, error) { workspaceInfos := workspace.Catalog{ PackageJSONs: map[string]*fs.PackageJSON{util.RootPkgName: rootPackageJSON}, TurboConfigs: map[string]*fs.TurboJSON{}, @@ -152,7 +152,7 @@ func SinglePackageGraph(repoRoot turbopath.AbsoluteSystemPath, rootPackageJSON * RootNode: core.ROOT_NODE_NAME, } c.WorkspaceGraph.Connect(dag.BasicEdge(util.RootPkgName, core.ROOT_NODE_NAME)) - packageManager, err := packagemanager.GetPackageManager(repoRoot, rootPackageJSON) + packageManager, err := packagemanager.GetPackageManager(packageManagerName) if err != nil { return nil, err } @@ -161,7 +161,7 @@ func SinglePackageGraph(repoRoot turbopath.AbsoluteSystemPath, rootPackageJSON * } // BuildPackageGraph constructs a Context instance with information about the package dependency graph -func BuildPackageGraph(repoRoot turbopath.AbsoluteSystemPath, rootPackageJSON *fs.PackageJSON) (*Context, error) { +func BuildPackageGraph(repoRoot turbopath.AbsoluteSystemPath, rootPackageJSON *fs.PackageJSON, packageManagerName string) (*Context, error) { c := &Context{} rootpath := repoRoot.ToStringDuringMigration() c.WorkspaceInfos = workspace.Catalog{ @@ -172,7 +172,7 @@ func BuildPackageGraph(repoRoot turbopath.AbsoluteSystemPath, rootPackageJSON *f var warnings Warnings - packageManager, err := packagemanager.GetPackageManager(repoRoot, rootPackageJSON) + packageManager, err := packagemanager.GetPackageManager(packageManagerName) if err != nil { return nil, err } diff --git a/cli/internal/context/context_test.go b/cli/internal/context/context_test.go index 7e04590416c25..ce67f12c63c1c 100644 --- a/cli/internal/context/context_test.go +++ b/cli/internal/context/context_test.go @@ -141,7 +141,7 @@ func TestBuildPackageGraph_DuplicateNames(t *testing.T) { PackageManager: "pnpm@7.15.0", } - _, actualErr := BuildPackageGraph(path, pkgJSON) + _, actualErr := BuildPackageGraph(path, pkgJSON, "pnpm") // Not asserting the full error message, because it includes a path with slashes and backslashes // getting the regex incantation to check that is not worth it. @@ -157,7 +157,7 @@ func Test_populateExternalDeps_NoTransitiveDepsWithoutLockfile(t *testing.T) { PackageManager: "pnpm@7.15.0", } - pm, err := packagemanager.GetPackageManager(path, pkgJSON) + pm, err := packagemanager.GetPackageManager("pnpm") assert.NilError(t, err) pm.UnmarshalLockfile = func(rootPackageJSON *fs.PackageJSON, contents []byte) (lockfile.Lockfile, error) { return nil, errors.New("bad lockfile") diff --git a/cli/internal/packagemanager/berry.go b/cli/internal/packagemanager/berry.go index 080f9a328fd77..b1964468f2f04 100644 --- a/cli/internal/packagemanager/berry.go +++ b/cli/internal/packagemanager/berry.go @@ -2,10 +2,8 @@ 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" @@ -51,68 +49,6 @@ var nodejsBerry = PackageManager{ 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) { var resolutions map[string]string if untypedResolutions, ok := rootPackageJSON.RawJSON["resolutions"]; ok { diff --git a/cli/internal/packagemanager/npm.go b/cli/internal/packagemanager/npm.go index ce2eb8c8c3ca9..9c6abe0a8eb25 100644 --- a/cli/internal/packagemanager/npm.go +++ b/cli/internal/packagemanager/npm.go @@ -38,17 +38,6 @@ var nodejsNpm = PackageManager{ }, 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 }, diff --git a/cli/internal/packagemanager/packagemanager.go b/cli/internal/packagemanager/packagemanager.go index dc5b9665884dc..5e0af98a3c6bc 100644 --- a/cli/internal/packagemanager/packagemanager.go +++ b/cli/internal/packagemanager/packagemanager.go @@ -7,15 +7,12 @@ 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 @@ -54,12 +51,6 @@ type PackageManager struct { // 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) @@ -75,63 +66,22 @@ var packageManagers = []PackageManager{ 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) +// GetPackageManager reads the package manager name sent by the Rust side +func GetPackageManager(name string) (packageManager *PackageManager, err error) { + switch name { + case "yarn": + return &nodejsYarn, nil + case "berry": + return &nodejsBerry, nil + case "npm": + return &nodejsNpm, nil + case "pnpm": + return &nodejsPnpm, nil + case "pnpm6": + return &nodejsPnpm6, nil + default: + return nil, errors.New("Unknown package manager") } - - 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. diff --git a/cli/internal/packagemanager/packagemanager_test.go b/cli/internal/packagemanager/packagemanager_test.go index a5dc472273189..012094215b26f 100644 --- a/cli/internal/packagemanager/packagemanager_test.go +++ b/cli/internal/packagemanager/packagemanager_test.go @@ -12,209 +12,6 @@ import ( "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 diff --git a/cli/internal/packagemanager/pnpm.go b/cli/internal/packagemanager/pnpm.go index e65a4dca578f5..d7a40da192250 100644 --- a/cli/internal/packagemanager/pnpm.go +++ b/cli/internal/packagemanager/pnpm.go @@ -4,7 +4,6 @@ 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" @@ -89,30 +88,6 @@ var nodejsPnpm = PackageManager{ 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 }, diff --git a/cli/internal/packagemanager/pnpm6.go b/cli/internal/packagemanager/pnpm6.go index 603996642be24..e9d64078b499d 100644 --- a/cli/internal/packagemanager/pnpm6.go +++ b/cli/internal/packagemanager/pnpm6.go @@ -1,9 +1,6 @@ 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" @@ -29,30 +26,6 @@ var nodejsPnpm6 = PackageManager{ 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 }, diff --git a/cli/internal/packagemanager/yarn.go b/cli/internal/packagemanager/yarn.go index 8779c5fffa763..a304b92a5d031 100644 --- a/cli/internal/packagemanager/yarn.go +++ b/cli/internal/packagemanager/yarn.go @@ -3,11 +3,8 @@ 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" @@ -72,44 +69,6 @@ var nodejsYarn = PackageManager{ 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) }, diff --git a/cli/internal/prune/prune.go b/cli/internal/prune/prune.go index a32c59e710e12..65555673d894f 100644 --- a/cli/internal/prune/prune.go +++ b/cli/internal/prune/prune.go @@ -41,7 +41,7 @@ func ExecutePrune(helper *cmdutil.Helper, executionState *turbostate.ExecutionSt p := &prune{ base, } - if err := p.prune(executionState.CLIArgs.Command.Prune); err != nil { + if err := p.prune(executionState.CLIArgs.Command.Prune, executionState.PackageManager); err != nil { logError(p.base.Logger, p.base.UI, err) return err } @@ -59,13 +59,13 @@ type prune struct { } // Prune creates a smaller monorepo with only the required workspaces -func (p *prune) prune(opts *turbostate.PrunePayload) error { +func (p *prune) prune(opts *turbostate.PrunePayload, packageManagerName string) error { rootPackageJSONPath := p.base.RepoRoot.UntypedJoin("package.json") rootPackageJSON, err := fs.ReadPackageJSON(rootPackageJSONPath) if err != nil { return fmt.Errorf("failed to read package.json: %w", err) } - ctx, err := context.BuildPackageGraph(p.base.RepoRoot, rootPackageJSON) + ctx, err := context.BuildPackageGraph(p.base.RepoRoot, rootPackageJSON, packageManagerName) if err != nil { return errors.Wrap(err, "could not construct graph") } diff --git a/cli/internal/run/run.go b/cli/internal/run/run.go index 0f41d54f7fed5..9461590f294ce 100644 --- a/cli/internal/run/run.go +++ b/cli/internal/run/run.go @@ -40,9 +40,6 @@ func ExecuteRun(ctx gocontext.Context, helper *cmdutil.Helper, signalWatcher *si } tasks := executionState.CLIArgs.Command.Run.Tasks passThroughArgs := executionState.CLIArgs.Command.Run.PassThroughArgs - if len(tasks) == 0 { - return errors.New("at least one task must be specified") - } opts, err := optsFromArgs(&executionState.CLIArgs) if err != nil { return err @@ -50,7 +47,7 @@ func ExecuteRun(ctx gocontext.Context, helper *cmdutil.Helper, signalWatcher *si opts.runOpts.PassThroughArgs = passThroughArgs run := configureRun(base, opts, signalWatcher) - if err := run.run(ctx, tasks); err != nil { + if err := run.run(ctx, tasks, executionState); err != nil { base.LogError("run failed: %v", err) return err } @@ -148,7 +145,7 @@ type run struct { processes *process.Manager } -func (r *run) run(ctx gocontext.Context, targets []string) error { +func (r *run) run(ctx gocontext.Context, targets []string, executionState *turbostate.ExecutionState) error { startAt := time.Now() packageJSONPath := r.base.RepoRoot.UntypedJoin("package.json") rootPackageJSON, err := fs.ReadPackageJSON(packageJSONPath) @@ -160,9 +157,9 @@ func (r *run) run(ctx gocontext.Context, targets []string) error { var pkgDepGraph *context.Context if r.opts.runOpts.SinglePackage { - pkgDepGraph, err = context.SinglePackageGraph(r.base.RepoRoot, rootPackageJSON) + pkgDepGraph, err = context.SinglePackageGraph(rootPackageJSON, executionState.PackageManager) } else { - pkgDepGraph, err = context.BuildPackageGraph(r.base.RepoRoot, rootPackageJSON) + pkgDepGraph, err = context.BuildPackageGraph(r.base.RepoRoot, rootPackageJSON, executionState.PackageManager) } if err != nil { var warnings *context.Warnings diff --git a/cli/internal/turbostate/turbostate.go b/cli/internal/turbostate/turbostate.go index 7d94ba9d1eac0..4efe4058e6eb4 100644 --- a/cli/internal/turbostate/turbostate.go +++ b/cli/internal/turbostate/turbostate.go @@ -91,6 +91,7 @@ type ParsedArgsFromRust struct { // ExecutionState is the entire state of a turbo execution that is passed from the Rust shim. type ExecutionState struct { APIClientConfig APIClientConfig `json:"api_client_config"` + PackageManager string `json:"package_manager"` CLIArgs ParsedArgsFromRust `json:"cli_args"` } diff --git a/crates/turbopath/src/anchored_system_path_buf.rs b/crates/turbopath/src/anchored_system_path_buf.rs index fe8e0c06e2b07..410458ca96e0c 100644 --- a/crates/turbopath/src/anchored_system_path_buf.rs +++ b/crates/turbopath/src/anchored_system_path_buf.rs @@ -1,10 +1,10 @@ use std::path::{Path, PathBuf}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::{AbsoluteSystemPathBuf, IntoSystem, PathValidationError}; -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize)] pub struct AnchoredSystemPathBuf(PathBuf); impl TryFrom<&Path> for AnchoredSystemPathBuf { diff --git a/crates/turborepo-lib/Cargo.toml b/crates/turborepo-lib/Cargo.toml index 7cc0f5f5c1e2d..2d96a0150cb2d 100644 --- a/crates/turborepo-lib/Cargo.toml +++ b/crates/turborepo-lib/Cargo.toml @@ -44,6 +44,7 @@ hex = "0.4.3" hostname = "0.3.1" humantime = "2.1.0" indicatif = { workspace = true } +itertools = { workspace = true } lazy_static = { workspace = true } libc = "0.2.140" notify = { version = "5.1.0", default-features = false, features = [ @@ -73,7 +74,9 @@ url = "2.3.1" const_format = "0.2.30" go-parse-duration = "0.1.1" is-terminal = "0.4.7" +node-semver = "2.1.0" owo-colors.workspace = true +regex.workspace = true tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } tracing.workspace = true turbo-updater = { workspace = true } diff --git a/crates/turborepo-lib/src/cli.rs b/crates/turborepo-lib/src/cli.rs index a0f80ecf6580f..23784482252c1 100644 --- a/crates/turborepo-lib/src/cli.rs +++ b/crates/turborepo-lib/src/cli.rs @@ -566,8 +566,14 @@ pub async fn run(repo_state: Option) -> Result { daemon::main(&command, &base).await?; Ok(Payload::Rust(Ok(0))) }, + Command::Run(args) => { + if args.tasks.is_empty() { + return Err(anyhow!("at least one task must be specified")); + } + let base = CommandBase::new(cli_args, repo_root, version)?; + Ok(Payload::Go(Box::new(base))) + } Command::Prune { .. } - | Command::Run(_) // the daemon itself still delegates to Go | Command::Daemon { .. } => { let base = CommandBase::new(cli_args, repo_root, version)?; diff --git a/crates/turborepo-lib/src/execution_state.rs b/crates/turborepo-lib/src/execution_state.rs index 68ebc84f25118..50e9ef70e28f9 100644 --- a/crates/turborepo-lib/src/execution_state.rs +++ b/crates/turborepo-lib/src/execution_state.rs @@ -1,10 +1,15 @@ use serde::Serialize; +use tracing::trace; +use turbopath::{AbsoluteSystemPathBuf, RelativeSystemPathBuf}; -use crate::{cli::Args, commands::CommandBase}; +use crate::{ + cli::Args, commands::CommandBase, package_json::PackageJson, package_manager::PackageManager, +}; #[derive(Debug, Serialize)] pub struct ExecutionState<'a> { pub api_client_config: APIClientConfig<'a>, + package_manager: PackageManager, pub cli_args: &'a Args, } @@ -24,6 +29,16 @@ impl<'a> TryFrom<&'a CommandBase> for ExecutionState<'a> { type Error = anyhow::Error; fn try_from(base: &'a CommandBase) -> Result { + let root_package_json = PackageJson::load(&AbsoluteSystemPathBuf::new( + base.repo_root + .join_relative(RelativeSystemPathBuf::new("package.json")?), + )?) + .ok(); + + let package_manager = + PackageManager::get_package_manager(base, root_package_json.as_ref())?; + trace!("Found {} as package manager", package_manager); + let repo_config = base.repo_config()?; let user_config = base.user_config()?; let client_config = base.client_config()?; @@ -40,6 +55,7 @@ impl<'a> TryFrom<&'a CommandBase> for ExecutionState<'a> { Ok(ExecutionState { api_client_config, + package_manager, cli_args: base.args(), }) } diff --git a/crates/turborepo-lib/src/lib.rs b/crates/turborepo-lib/src/lib.rs index 41b3477a31de8..2612b39ca3a3c 100644 --- a/crates/turborepo-lib/src/lib.rs +++ b/crates/turborepo-lib/src/lib.rs @@ -7,6 +7,7 @@ mod config; mod daemon; mod execution_state; mod formatter; +mod package_json; mod package_manager; mod shim; mod ui; diff --git a/crates/turborepo-lib/src/package_json.rs b/crates/turborepo-lib/src/package_json.rs new file mode 100644 index 0000000000000..0bd9368ac3ee4 --- /dev/null +++ b/crates/turborepo-lib/src/package_json.rs @@ -0,0 +1,17 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use turbopath::AbsoluteSystemPathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct PackageJson { + pub package_manager: Option, +} + +impl PackageJson { + pub fn load(path: &AbsoluteSystemPathBuf) -> Result { + let contents = std::fs::read_to_string(path)?; + let package_json: PackageJson = serde_json::from_str(&contents)?; + Ok(package_json) + } +} diff --git a/crates/turborepo-lib/src/package_manager.rs b/crates/turborepo-lib/src/package_manager.rs deleted file mode 100644 index e1f594d89aed0..0000000000000 --- a/crates/turborepo-lib/src/package_manager.rs +++ /dev/null @@ -1,193 +0,0 @@ -use std::{ - fs, - path::{Path, PathBuf}, -}; - -use anyhow::{anyhow, Result}; -use serde::Deserialize; - -#[derive(Debug, Deserialize)] -struct PnpmWorkspace { - pub packages: Vec, -} - -#[derive(Debug, Deserialize)] -struct PackageJsonWorkspaces { - workspaces: Workspaces, -} - -#[derive(Debug, Deserialize, PartialEq, Eq, Clone)] -#[serde(untagged)] -enum Workspaces { - TopLevel(Vec), - Nested { packages: Vec }, -} - -impl AsRef<[String]> for Workspaces { - fn as_ref(&self) -> &[String] { - match self { - Workspaces::TopLevel(packages) => packages.as_slice(), - Workspaces::Nested { packages } => packages.as_slice(), - } - } -} - -impl From for Vec { - fn from(value: Workspaces) -> Self { - match value { - Workspaces::TopLevel(packages) => packages, - Workspaces::Nested { packages } => packages, - } - } -} - -pub enum PackageManager { - #[allow(dead_code)] - Berry, - Npm, - Pnpm, - #[allow(dead_code)] - Pnpm6, - #[allow(dead_code)] - Yarn, -} - -#[derive(Debug)] -pub struct Globs { - pub inclusions: Vec, - pub exclusions: Vec, -} - -impl Globs { - pub fn test(&self, root: PathBuf, target: PathBuf) -> Result { - let search_value = target - .strip_prefix(root)? - .to_str() - .ok_or_else(|| anyhow!("The relative path is not UTF8."))?; - - let includes = &self - .inclusions - .iter() - .any(|inclusion| glob_match::glob_match(inclusion, search_value)); - - let excludes = &self - .exclusions - .iter() - .any(|exclusion| glob_match::glob_match(exclusion, search_value)); - - Ok(*includes && !excludes) - } -} - -impl PackageManager { - /// Returns a list of globs for the package workspace. - /// NOTE: We return a `Vec` instead of a `GlobSet` because we - /// may need to iterate through these globs and a `GlobSet` doesn't allow - /// that. - /// - /// # Arguments - /// - /// * `root_path`: - /// - /// returns: Result, Error> - /// - /// # Examples - /// - /// ``` - /// ``` - pub fn get_workspace_globs(&self, root_path: &Path) -> Result> { - let globs = match self { - PackageManager::Pnpm | PackageManager::Pnpm6 => { - let workspace_yaml = fs::read_to_string(root_path.join("pnpm-workspace.yaml"))?; - let pnpm_workspace: PnpmWorkspace = serde_yaml::from_str(&workspace_yaml)?; - if pnpm_workspace.packages.is_empty() { - return Ok(None); - } else { - pnpm_workspace.packages - } - } - PackageManager::Berry | PackageManager::Npm | PackageManager::Yarn => { - let package_json_text = fs::read_to_string(root_path.join("package.json"))?; - let package_json: PackageJsonWorkspaces = serde_json::from_str(&package_json_text)?; - - if package_json.workspaces.as_ref().is_empty() { - return Ok(None); - } else { - package_json.workspaces.into() - } - } - }; - - let mut inclusions = Vec::new(); - let mut exclusions = Vec::new(); - - for glob in globs { - if let Some(exclusion) = glob.strip_prefix('!') { - exclusions.push(exclusion.to_string()); - } else { - inclusions.push(glob); - } - } - - Ok(Some(Globs { - inclusions, - exclusions, - })) - } -} - -#[cfg(test)] -mod tests { - use std::path::Path; - - use super::*; - - #[test] - fn test_get_workspace_globs() { - let package_manager = PackageManager::Npm; - let globs = package_manager - .get_workspace_globs(Path::new("../../examples/with-yarn")) - .unwrap() - .unwrap(); - - assert_eq!(globs.inclusions, vec!["apps/*", "packages/*"]); - } - - #[test] - fn test_globs_test() { - struct TestCase { - globs: Globs, - root: PathBuf, - target: PathBuf, - output: Result, - } - - let tests = [TestCase { - globs: Globs { - inclusions: vec!["d/**".to_string()], - exclusions: vec![], - }, - root: PathBuf::from("/a/b/c"), - target: PathBuf::from("/a/b/c/d/e/f"), - output: Ok(true), - }]; - - for test in tests { - match test.globs.test(test.root, test.target) { - Ok(value) => assert_eq!(value, test.output.unwrap()), - Err(value) => assert_eq!(value.to_string(), test.output.unwrap_err().to_string()), - }; - } - } - - #[test] - fn test_nested_workspace_globs() -> Result<()> { - let top_level: PackageJsonWorkspaces = - serde_json::from_str("{ \"workspaces\": [\"packages/**\"]}")?; - assert_eq!(top_level.workspaces.as_ref(), vec!["packages/**"]); - let nested: PackageJsonWorkspaces = - serde_json::from_str("{ \"workspaces\": {\"packages\": [\"packages/**\"]}}")?; - assert_eq!(nested.workspaces.as_ref(), vec!["packages/**"]); - Ok(()) - } -} diff --git a/crates/turborepo-lib/src/package_manager/mod.rs b/crates/turborepo-lib/src/package_manager/mod.rs new file mode 100644 index 0000000000000..dead874804093 --- /dev/null +++ b/crates/turborepo-lib/src/package_manager/mod.rs @@ -0,0 +1,448 @@ +mod npm; +mod pnpm; +mod yarn; + +use std::{ + fmt, fs, + path::{Path, PathBuf}, +}; + +use anyhow::{anyhow, Result}; +use itertools::Itertools; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use turbopath::AbsoluteSystemPathBuf; + +use crate::{ + commands::CommandBase, + package_json::PackageJson, + package_manager::{npm::NpmDetector, pnpm::PnpmDetector, yarn::YarnDetector}, + ui::UNDERLINE, +}; + +#[derive(Debug, Deserialize)] +struct PnpmWorkspace { + pub packages: Vec, +} + +#[derive(Debug, Deserialize)] +struct PackageJsonWorkspaces { + workspaces: Workspaces, +} + +#[derive(Debug, Deserialize, PartialEq, Eq, Clone)] +#[serde(untagged)] +enum Workspaces { + TopLevel(Vec), + Nested { packages: Vec }, +} + +impl AsRef<[String]> for Workspaces { + fn as_ref(&self) -> &[String] { + match self { + Workspaces::TopLevel(packages) => packages.as_slice(), + Workspaces::Nested { packages } => packages.as_slice(), + } + } +} + +impl From for Vec { + fn from(value: Workspaces) -> Self { + match value { + Workspaces::TopLevel(packages) => packages, + Workspaces::Nested { packages } => packages, + } + } +} + +#[derive(Debug, Serialize, PartialEq, Eq, Clone)] +#[serde(rename_all = "lowercase")] +pub enum PackageManager { + Berry, + Npm, + Pnpm, + Pnpm6, + Yarn, +} + +impl fmt::Display for PackageManager { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Do not change these without also changing `GetPackageManager` in + // packagemanager.go + match self { + PackageManager::Berry => write!(f, "berry"), + PackageManager::Npm => write!(f, "npm"), + PackageManager::Pnpm => write!(f, "pnpm"), + PackageManager::Pnpm6 => write!(f, "pnpm6"), + PackageManager::Yarn => write!(f, "yarn"), + } + } +} + +#[derive(Debug)] +pub struct Globs { + pub inclusions: Vec, + pub exclusions: Vec, +} + +impl Globs { + pub fn test(&self, root: PathBuf, target: PathBuf) -> Result { + let search_value = target + .strip_prefix(root)? + .to_str() + .ok_or_else(|| anyhow!("The relative path is not UTF8."))?; + + let includes = &self + .inclusions + .iter() + .any(|inclusion| glob_match::glob_match(inclusion, search_value)); + + let excludes = &self + .exclusions + .iter() + .any(|exclusion| glob_match::glob_match(exclusion, search_value)); + + Ok(*includes && !*excludes) + } +} + +impl PackageManager { + /// Returns a list of globs for the package workspace. + /// NOTE: We return a `Vec` instead of a `GlobSet` because we + /// may need to iterate through these globs and a `GlobSet` doesn't allow + /// that. + /// + /// # Arguments + /// + /// * `root_path`: + /// + /// returns: Result, Error> + /// + /// # Examples + /// + /// ``` + /// ``` + pub fn get_workspace_globs(&self, root_path: &Path) -> Result> { + let globs = match self { + PackageManager::Pnpm | PackageManager::Pnpm6 => { + let workspace_yaml = fs::read_to_string(root_path.join("pnpm-workspace.yaml"))?; + let pnpm_workspace: PnpmWorkspace = serde_yaml::from_str(&workspace_yaml)?; + if pnpm_workspace.packages.is_empty() { + return Ok(None); + } else { + pnpm_workspace.packages + } + } + PackageManager::Berry | PackageManager::Npm | PackageManager::Yarn => { + let package_json_text = fs::read_to_string(root_path.join("package.json"))?; + let package_json: PackageJsonWorkspaces = serde_json::from_str(&package_json_text)?; + + if package_json.workspaces.as_ref().is_empty() { + return Ok(None); + } else { + package_json.workspaces.into() + } + } + }; + + let mut inclusions = Vec::new(); + let mut exclusions = Vec::new(); + + for glob in globs { + if let Some(exclusion) = glob.strip_prefix('!') { + exclusions.push(exclusion.to_string()); + } else { + inclusions.push(glob); + } + } + + Ok(Some(Globs { + inclusions, + exclusions, + })) + } + + pub fn get_package_manager(base: &CommandBase, pkg: Option<&PackageJson>) -> Result { + // We don't surface errors for `read_package_manager` as we can fall back to + // `detect_package_manager` + if let Some(package_json) = pkg { + if let Ok(Some(package_manager)) = + Self::read_package_manager(&base.repo_root, package_json) + { + return Ok(package_manager); + } + } + + Self::detect_package_manager(base) + } + + // Attempts to read the package manager from the package.json + fn read_package_manager( + repo_root: &AbsoluteSystemPathBuf, + pkg: &PackageJson, + ) -> Result> { + let Some(package_manager) = &pkg.package_manager else { + return Ok(None) + }; + + let (manager, version) = Self::parse_package_manager_string(package_manager)?; + let version = version.parse()?; + let manager = match manager { + "npm" => Some(PackageManager::Npm), + "yarn" => Some(YarnDetector::detect_berry_or_yarn(repo_root, &version)?), + "pnpm" => Some(PnpmDetector::detect_pnpm6_or_pnpm(&version)?), + _ => None, + }; + + Ok(manager) + } + + fn detect_package_manager(base: &CommandBase) -> Result { + let mut detected_package_managers = PnpmDetector::new(&base.repo_root) + .chain(NpmDetector::new(&base.repo_root)) + .chain(YarnDetector::new(&base.repo_root)) + .collect::>>()?; + + match detected_package_managers.len() { + 0 => { + let url = base.ui.apply( + UNDERLINE.apply_to("https://nodejs.org/api/packages.html#packagemanager"), + ); + Err(anyhow!( + "We did not find a package manager specified in your root package.json. \ + Please set the \"packageManager\" property in your root package.json ({url}) \ + or run `npx @turbo/codemod add-package-manager` in the root of your monorepo." + )) + } + 1 => Ok(detected_package_managers.pop().unwrap()), + _ => Err(anyhow!( + "We detected multiple package managers in your repository: {}. Please remove one \ + of them.", + detected_package_managers.into_iter().join(", ") + )), + } + } + + pub(crate) fn parse_package_manager_string(manager: &str) -> Result<(&str, &str)> { + let package_manager_pattern = + Regex::new(r"(?Pnpm|pnpm|yarn)@(?P\d+\.\d+\.\d+(-.+)?)")?; + if let Some(captures) = package_manager_pattern.captures(manager) { + let manager = captures.name("manager").unwrap().as_str(); + let version = captures.name("version").unwrap().as_str(); + Ok((manager, version)) + } else { + Err(anyhow!( + "We could not parse packageManager field in package.json, expected: {}, received: \ + {}", + package_manager_pattern, + manager + )) + } + } +} + +#[cfg(test)] +mod tests { + use std::{fs::File, path::Path}; + + use tempfile::tempdir; + + use super::*; + use crate::{get_version, package_manager::yarn::YARN_RC, Args}; + + struct TestCase { + name: String, + package_manager: String, + expected_manager: String, + expected_version: String, + expected_error: bool, + } + + #[test] + fn test_parse_package_manager_string() { + let tests = vec![ + TestCase { + name: "errors with a tag version".to_owned(), + package_manager: "npm@latest".to_owned(), + expected_manager: "".to_owned(), + expected_version: "".to_owned(), + expected_error: true, + }, + TestCase { + name: "errors with no version".to_owned(), + package_manager: "npm".to_owned(), + expected_manager: "".to_owned(), + expected_version: "".to_owned(), + expected_error: true, + }, + TestCase { + name: "requires fully-qualified semver versions (one digit)".to_owned(), + package_manager: "npm@1".to_owned(), + expected_manager: "".to_owned(), + expected_version: "".to_owned(), + expected_error: true, + }, + TestCase { + name: "requires fully-qualified semver versions (two digits)".to_owned(), + package_manager: "npm@1.2".to_owned(), + expected_manager: "".to_owned(), + expected_version: "".to_owned(), + expected_error: true, + }, + TestCase { + name: "supports custom labels".to_owned(), + package_manager: "npm@1.2.3-alpha.1".to_owned(), + expected_manager: "npm".to_owned(), + expected_version: "1.2.3-alpha.1".to_owned(), + expected_error: false, + }, + TestCase { + name: "only supports specified package managers".to_owned(), + package_manager: "pip@1.2.3".to_owned(), + expected_manager: "".to_owned(), + expected_version: "".to_owned(), + expected_error: true, + }, + TestCase { + name: "supports npm".to_owned(), + package_manager: "npm@0.0.1".to_owned(), + expected_manager: "npm".to_owned(), + expected_version: "0.0.1".to_owned(), + expected_error: false, + }, + TestCase { + name: "supports pnpm".to_owned(), + package_manager: "pnpm@0.0.1".to_owned(), + expected_manager: "pnpm".to_owned(), + expected_version: "0.0.1".to_owned(), + expected_error: false, + }, + TestCase { + name: "supports yarn".to_owned(), + package_manager: "yarn@111.0.1".to_owned(), + expected_manager: "yarn".to_owned(), + expected_version: "111.0.1".to_owned(), + expected_error: false, + }, + ]; + + for case in tests { + let result = PackageManager::parse_package_manager_string(&case.package_manager); + let Ok((received_manager, received_version)) = result else { + assert!(case.expected_error, "{}: received error", case.name); + continue + }; + + assert_eq!(received_manager, case.expected_manager); + assert_eq!(received_version, case.expected_version); + } + } + + #[test] + fn test_read_package_manager() -> Result<()> { + let repo_root = tempdir()?; + let mut package_json = PackageJson::default(); + let repo_root_path = AbsoluteSystemPathBuf::new(repo_root.path())?; + + // Set up .yarnrc.yml file + let yarn_rc_path = repo_root.path().join(YARN_RC); + fs::write(&yarn_rc_path, "nodeLinker: node-modules")?; + + package_json.package_manager = Some("npm@8.19.4".to_string()); + let package_manager = PackageManager::read_package_manager(&repo_root_path, &package_json)?; + assert_eq!(package_manager, Some(PackageManager::Npm)); + + package_json.package_manager = Some("yarn@2.0.0".to_string()); + let package_manager = PackageManager::read_package_manager(&repo_root_path, &package_json)?; + assert_eq!(package_manager, Some(PackageManager::Berry)); + + package_json.package_manager = Some("yarn@1.9.0".to_string()); + let package_manager = PackageManager::read_package_manager(&repo_root_path, &package_json)?; + assert_eq!(package_manager, Some(PackageManager::Yarn)); + + package_json.package_manager = Some("pnpm@6.0.0".to_string()); + let package_manager = PackageManager::read_package_manager(&repo_root_path, &package_json)?; + assert_eq!(package_manager, Some(PackageManager::Pnpm6)); + + package_json.package_manager = Some("pnpm@7.2.0".to_string()); + let package_manager = PackageManager::read_package_manager(&repo_root_path, &package_json)?; + assert_eq!(package_manager, Some(PackageManager::Pnpm)); + + Ok(()) + } + + #[test] + fn test_detect_multiple_package_managers() -> Result<()> { + let repo_root = tempdir()?; + let repo_root_path = AbsoluteSystemPathBuf::new(repo_root.path())?; + let base = CommandBase::new(Args::default(), repo_root_path, get_version())?; + + let package_lock_json_path = repo_root.path().join(npm::LOCKFILE); + File::create(&package_lock_json_path)?; + let pnpm_lock_path = repo_root.path().join(pnpm::LOCKFILE); + File::create(&pnpm_lock_path)?; + + let error = PackageManager::detect_package_manager(&base).unwrap_err(); + assert_eq!( + error.to_string(), + "We detected multiple package managers in your repository: pnpm, npm. Please remove \ + one of them." + ); + + fs::remove_file(&package_lock_json_path)?; + + let package_manager = PackageManager::detect_package_manager(&base)?; + assert_eq!(package_manager, PackageManager::Pnpm); + + Ok(()) + } + + #[test] + fn test_get_workspace_globs() { + let package_manager = PackageManager::Npm; + let globs = package_manager + .get_workspace_globs(Path::new("../../examples/with-yarn")) + .unwrap() + .unwrap(); + + assert_eq!(globs.inclusions, vec!["apps/*", "packages/*"]); + } + + #[test] + fn test_globs_test() { + struct TestCase { + globs: Globs, + root: PathBuf, + target: PathBuf, + output: Result, + } + + let tests = [TestCase { + globs: Globs { + inclusions: vec!["d/**".to_string()], + exclusions: vec![], + }, + root: PathBuf::from("/a/b/c"), + target: PathBuf::from("/a/b/c/d/e/f"), + output: Ok(true), + }]; + + for test in tests { + match test.globs.test(test.root, test.target) { + Ok(value) => assert_eq!(value, test.output.unwrap()), + Err(value) => assert_eq!(value.to_string(), test.output.unwrap_err().to_string()), + }; + } + } + + #[test] + fn test_nested_workspace_globs() -> Result<()> { + let top_level: PackageJsonWorkspaces = + serde_json::from_str("{ \"workspaces\": [\"packages/**\"]}")?; + assert_eq!(top_level.workspaces.as_ref(), vec!["packages/**"]); + let nested: PackageJsonWorkspaces = + serde_json::from_str("{ \"workspaces\": {\"packages\": [\"packages/**\"]}}")?; + assert_eq!(nested.workspaces.as_ref(), vec!["packages/**"]); + Ok(()) + } +} diff --git a/crates/turborepo-lib/src/package_manager/npm.rs b/crates/turborepo-lib/src/package_manager/npm.rs new file mode 100644 index 0000000000000..679b01f81fd18 --- /dev/null +++ b/crates/turborepo-lib/src/package_manager/npm.rs @@ -0,0 +1,67 @@ +use anyhow::Result; +use turbopath::{AbsoluteSystemPathBuf, RelativeSystemPathBuf}; + +use crate::package_manager::PackageManager; + +pub const LOCKFILE: &str = "package-lock.json"; + +pub struct NpmDetector<'a> { + repo_root: &'a AbsoluteSystemPathBuf, + found: bool, +} + +impl<'a> NpmDetector<'a> { + pub fn new(repo_root: &'a AbsoluteSystemPathBuf) -> Self { + Self { + repo_root, + found: false, + } + } +} + +impl<'a> Iterator for NpmDetector<'a> { + type Item = Result; + + fn next(&mut self) -> Option { + if self.found { + return None; + } + + self.found = true; + let package_json = self + .repo_root + .join_relative(RelativeSystemPathBuf::new(LOCKFILE).unwrap()); + + if package_json.exists() { + Some(Ok(PackageManager::Npm)) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use std::fs::File; + + use anyhow::Result; + use tempfile::tempdir; + use turbopath::AbsoluteSystemPathBuf; + + use super::LOCKFILE; + use crate::{commands::CommandBase, get_version, package_manager::PackageManager, Args}; + + #[test] + fn test_detect_npm() -> Result<()> { + let repo_root = tempdir()?; + let repo_root_path = AbsoluteSystemPathBuf::new(repo_root.path())?; + let mut base = CommandBase::new(Args::default(), repo_root_path, get_version())?; + + let lockfile_path = repo_root.path().join(LOCKFILE); + File::create(&lockfile_path)?; + let package_manager = PackageManager::detect_package_manager(&mut base)?; + assert_eq!(package_manager, PackageManager::Npm); + + Ok(()) + } +} diff --git a/crates/turborepo-lib/src/package_manager/pnpm.rs b/crates/turborepo-lib/src/package_manager/pnpm.rs new file mode 100644 index 0000000000000..3d3f8f3000f48 --- /dev/null +++ b/crates/turborepo-lib/src/package_manager/pnpm.rs @@ -0,0 +1,73 @@ +use anyhow::Result; +use node_semver::{Range, Version}; +use turbopath::{AbsoluteSystemPathBuf, RelativeSystemPathBuf}; + +use crate::package_manager::PackageManager; + +pub const LOCKFILE: &str = "pnpm-lock.yaml"; + +pub struct PnpmDetector<'a> { + found: bool, + repo_root: &'a AbsoluteSystemPathBuf, +} + +impl<'a> PnpmDetector<'a> { + pub fn new(repo_root: &'a AbsoluteSystemPathBuf) -> Self { + Self { + repo_root, + found: false, + } + } + + pub fn detect_pnpm6_or_pnpm(version: &Version) -> Result { + let pnpm6_constraint: Range = "<7.0.0".parse()?; + if pnpm6_constraint.satisfies(version) { + Ok(PackageManager::Pnpm6) + } else { + Ok(PackageManager::Pnpm) + } + } +} + +impl<'a> Iterator for PnpmDetector<'a> { + type Item = Result; + + fn next(&mut self) -> Option { + if self.found { + return None; + } + self.found = true; + + let pnpm_lockfile = self + .repo_root + .join_relative(RelativeSystemPathBuf::new(LOCKFILE).unwrap()); + + pnpm_lockfile.exists().then(|| Ok(PackageManager::Pnpm)) + } +} + +#[cfg(test)] +mod tests { + use std::fs::File; + + use anyhow::Result; + use tempfile::tempdir; + use turbopath::AbsoluteSystemPathBuf; + + use super::LOCKFILE; + use crate::{commands::CommandBase, get_version, package_manager::PackageManager, Args}; + + #[test] + fn test_detect_pnpm() -> Result<()> { + let repo_root = tempdir()?; + let repo_root_path = AbsoluteSystemPathBuf::new(repo_root.path())?; + let mut base = CommandBase::new(Args::default(), repo_root_path, get_version())?; + + let lockfile_path = repo_root.path().join(LOCKFILE); + File::create(&lockfile_path)?; + let package_manager = PackageManager::detect_package_manager(&mut base)?; + assert_eq!(package_manager, PackageManager::Pnpm); + + Ok(()) + } +} diff --git a/crates/turborepo-lib/src/package_manager/yarn.rs b/crates/turborepo-lib/src/package_manager/yarn.rs new file mode 100644 index 0000000000000..a66bb0114a6db --- /dev/null +++ b/crates/turborepo-lib/src/package_manager/yarn.rs @@ -0,0 +1,150 @@ +use std::{fs::File, process::Command}; + +use anyhow::{anyhow, Context, Result}; +use node_semver::{Range, Version}; +use serde::Deserialize; +use turbopath::{AbsoluteSystemPathBuf, RelativeSystemPathBuf}; + +use crate::package_manager::PackageManager; + +#[derive(Debug, Deserialize)] +struct YarnRC { + #[serde(rename = "nodeLinker")] + node_linker: Option, +} + +pub const LOCKFILE: &str = "yarn.lock"; +pub const YARN_RC: &str = ".yarnrc.yml"; + +pub struct YarnDetector<'a> { + repo_root: &'a AbsoluteSystemPathBuf, + // For testing purposes + version_override: Option, + found: bool, +} + +impl<'a> YarnDetector<'a> { + pub fn new(repo_root: &'a AbsoluteSystemPathBuf) -> Self { + Self { + repo_root, + version_override: None, + found: false, + } + } + + #[cfg(test)] + fn set_version_override(&mut self, version: Version) { + self.version_override = Some(version); + } + + fn get_yarn_version(&self) -> Result { + if let Some(version) = &self.version_override { + return Ok(version.clone()); + } + + let output = Command::new("yarn") + .arg("--version") + .current_dir(&self.repo_root) + .output()?; + let yarn_version_output = String::from_utf8(output.stdout)?; + Ok(yarn_version_output.trim().parse()?) + } + + fn is_nm_linker(repo_root: &AbsoluteSystemPathBuf) -> Result { + let yarnrc_path = repo_root.join_relative(RelativeSystemPathBuf::new(YARN_RC)?); + let yarnrc = File::open(yarnrc_path)?; + let yarnrc: YarnRC = serde_yaml::from_reader(&yarnrc)?; + Ok(yarnrc.node_linker.as_deref() == Some("node-modules")) + } + + pub fn detect_berry_or_yarn( + repo_root: &AbsoluteSystemPathBuf, + version: &Version, + ) -> Result { + let berry_constraint: Range = ">=2.0.0-0".parse()?; + if berry_constraint.satisfies(version) { + let is_nm_linker = Self::is_nm_linker(repo_root) + .context("could not determine if yarn is using `nodeLinker: node-modules`")?; + + if !is_nm_linker { + return Err(anyhow!( + "only yarn v2/v3 with `nodeLinker: node-modules` is supported at this time" + )); + } + + Ok(PackageManager::Berry) + } else { + Ok(PackageManager::Yarn) + } + } +} + +impl<'a> Iterator for YarnDetector<'a> { + type Item = Result; + + fn next(&mut self) -> Option { + if self.found { + return None; + } + self.found = true; + + let yarn_lockfile = self + .repo_root + .join_relative(RelativeSystemPathBuf::new(LOCKFILE).unwrap()); + + if yarn_lockfile.exists() { + Some( + self.get_yarn_version() + .and_then(|version| Self::detect_berry_or_yarn(self.repo_root, &version)), + ) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use std::{fs, fs::File}; + + use anyhow::Result; + use tempfile::tempdir; + use turbopath::AbsoluteSystemPathBuf; + + use super::LOCKFILE; + use crate::{ + commands::CommandBase, + get_version, + package_manager::{ + yarn::{YarnDetector, YARN_RC}, + PackageManager, + }, + Args, + }; + + #[test] + fn test_detect_yarn() -> Result<()> { + let repo_root = tempdir()?; + let repo_root_path = AbsoluteSystemPathBuf::new(repo_root.path())?; + let base = CommandBase::new(Args::default(), repo_root_path, get_version())?; + + let yarn_lock_path = repo_root.path().join(LOCKFILE); + File::create(&yarn_lock_path)?; + + let yarn_rc_path = repo_root.path().join(YARN_RC); + fs::write(&yarn_rc_path, "nodeLinker: node-modules")?; + + let absolute_repo_root = AbsoluteSystemPathBuf::new(base.repo_root)?; + let mut detector = YarnDetector::new(&absolute_repo_root); + detector.set_version_override("1.22.10".parse()?); + let package_manager = detector.next().unwrap()?; + assert_eq!(package_manager, PackageManager::Yarn); + + let mut detector = YarnDetector::new(&absolute_repo_root); + detector.set_version_override("2.22.10".parse()?); + let package_manager = detector.next().unwrap()?; + assert_eq!(package_manager, PackageManager::Berry); + + Ok(()) + } +} diff --git a/turborepo-tests/integration/tests/no_args.t b/turborepo-tests/integration/tests/no_args.t index 7f0a79ec95482..daf8eb7a6df08 100644 --- a/turborepo-tests/integration/tests/no_args.t +++ b/turborepo-tests/integration/tests/no_args.t @@ -63,5 +63,5 @@ Make sure exit code is 2 when no args are passed --log-prefix Use "none" to remove prefixes from task logs. Note that tasks running in parallel interleave their logs and prefix is the only way to identify which task produced a log [possible values: none] [1] $ ${TURBO} run - Turbo error: at least one task must be specified + ERROR at least one task must be specified [1] diff --git a/turborepo-tests/integration/tests/package_manager.t b/turborepo-tests/integration/tests/package_manager.t new file mode 100644 index 0000000000000..3dc469f3fcff1 --- /dev/null +++ b/turborepo-tests/integration/tests/package_manager.t @@ -0,0 +1,38 @@ +Setup + $ . ${TESTDIR}/../../helpers/setup.sh + $ . ${TESTDIR}/_helpers/setup_monorepo.sh $(pwd) basic_monorepo "npm@8.19.4" + +Run test run + $ ${TURBO} run build --__test-run | jq .package_manager + "npm" + +Set package manager to yarn in package.json + $ jq '.packageManager = "yarn@1.22.7"' package.json > package.json.tmp && mv package.json.tmp package.json + +Run test run + $ ${TURBO} run build --__test-run | jq .package_manager + "yarn" + +Set up .yarnrc.yml + $ echo "nodeLinker: node-modules" > .yarnrc.yml + +Set package manager to berry in package.json + $ jq '.packageManager = "yarn@2.0.0"' package.json > package.json.tmp && mv package.json.tmp package.json + +Run test run + $ ${TURBO} run build --__test-run | jq .package_manager + "berry" + +Set package manager to pnpm6 in package.json + $ jq '.packageManager = "pnpm@6.0.0"' package.json > package.json.tmp && mv package.json.tmp package.json + +Run test run + $ ${TURBO} run build --__test-run | jq .package_manager + "pnpm6" + +Set package manager to pnpm in package.json + $ jq '.packageManager = "pnpm@7.0.0"' package.json > package.json.tmp && mv package.json.tmp package.json + +Run test run + $ ${TURBO} run build --__test-run | jq .package_manager + "pnpm"