aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/cli/internal/lockfile/pnpm_lockfile.go
diff options
context:
space:
mode:
Diffstat (limited to 'cli/internal/lockfile/pnpm_lockfile.go')
-rw-r--r--cli/internal/lockfile/pnpm_lockfile.go579
1 files changed, 579 insertions, 0 deletions
diff --git a/cli/internal/lockfile/pnpm_lockfile.go b/cli/internal/lockfile/pnpm_lockfile.go
new file mode 100644
index 0000000..a51b36e
--- /dev/null
+++ b/cli/internal/lockfile/pnpm_lockfile.go
@@ -0,0 +1,579 @@
+package lockfile
+
+import (
+ "fmt"
+ "io"
+ "reflect"
+ "sort"
+ "strings"
+
+ "github.com/pkg/errors"
+ "github.com/vercel/turbo/cli/internal/turbopath"
+ "github.com/vercel/turbo/cli/internal/yaml"
+)
+
+// PnpmLockfile Go representation of the contents of 'pnpm-lock.yaml'
+// Reference https://github.com/pnpm/pnpm/blob/main/packages/lockfile-types/src/index.ts
+type PnpmLockfile struct {
+ isV6 bool
+ // Before 6.0 version was stored as a float, but as of 6.0+ it's a string
+ Version interface{} `yaml:"lockfileVersion"`
+ NeverBuiltDependencies []string `yaml:"neverBuiltDependencies,omitempty"`
+ OnlyBuiltDependencies []string `yaml:"onlyBuiltDependencies,omitempty"`
+ Overrides map[string]string `yaml:"overrides,omitempty"`
+ PackageExtensionsChecksum string `yaml:"packageExtensionsChecksum,omitempty"`
+ PatchedDependencies map[string]PatchFile `yaml:"patchedDependencies,omitempty"`
+ Importers map[string]ProjectSnapshot `yaml:"importers"`
+ Packages map[string]PackageSnapshot `yaml:"packages,omitempty"`
+ Time map[string]string `yaml:"time,omitempty"`
+}
+
+var _ Lockfile = (*PnpmLockfile)(nil)
+
+// ProjectSnapshot Snapshot used to represent projects in the importers section
+type ProjectSnapshot struct {
+ // for v6 we omitempty
+ // for pre v6 we *need* to omit the empty map
+ Specifiers SpecifierMap `yaml:"specifiers,omitempty"`
+
+ // The values of these maps will be string if lockfileVersion <6 or DependencyV6 if 6+
+ Dependencies map[string]yaml.Node `yaml:"dependencies,omitempty"`
+ OptionalDependencies map[string]yaml.Node `yaml:"optionalDependencies,omitempty"`
+ DevDependencies map[string]yaml.Node `yaml:"devDependencies,omitempty"`
+
+ DependenciesMeta map[string]DependenciesMeta `yaml:"dependenciesMeta,omitempty"`
+ PublishDirectory string `yaml:"publishDirectory,omitempty"`
+}
+
+// SpecifierMap is a type wrapper that overrides IsZero for Golang's map
+// to match the behavior that pnpm expects
+type SpecifierMap map[string]string
+
+// IsZero is used to check whether an object is zero to
+// determine whether it should be omitted when marshaling
+// with the omitempty flag.
+func (m SpecifierMap) IsZero() bool {
+ return m == nil
+}
+
+var _ (yaml.IsZeroer) = (*SpecifierMap)(nil)
+
+// DependencyV6 are dependency entries for lockfileVersion 6.0+
+type DependencyV6 struct {
+ Specifier string `yaml:"specifier"`
+ Version string `yaml:"version"`
+}
+
+// Will try to find a resolution in any of the dependency fields
+func (p *ProjectSnapshot) findResolution(dependency string) (DependencyV6, bool, error) {
+ var getResolution func(yaml.Node) (DependencyV6, bool, error)
+ if len(p.Specifiers) > 0 {
+ getResolution = func(node yaml.Node) (DependencyV6, bool, error) {
+ specifier, ok := p.Specifiers[dependency]
+ if !ok {
+ return DependencyV6{}, false, nil
+ }
+ var version string
+ if err := node.Decode(&version); err != nil {
+ return DependencyV6{}, false, err
+ }
+ return DependencyV6{Version: version, Specifier: specifier}, true, nil
+ }
+ } else {
+ getResolution = func(node yaml.Node) (DependencyV6, bool, error) {
+ var resolution DependencyV6
+ if err := node.Decode(&resolution); err != nil {
+ return DependencyV6{}, false, err
+ }
+ return resolution, true, nil
+ }
+ }
+ if resolution, ok := p.Dependencies[dependency]; ok {
+ return getResolution(resolution)
+ }
+ if resolution, ok := p.DevDependencies[dependency]; ok {
+ return getResolution(resolution)
+ }
+ if resolution, ok := p.OptionalDependencies[dependency]; ok {
+ return getResolution(resolution)
+ }
+ return DependencyV6{}, false, nil
+}
+
+// PackageSnapshot Snapshot used to represent a package in the packages setion
+type PackageSnapshot struct {
+ Resolution PackageResolution `yaml:"resolution,flow"`
+ ID string `yaml:"id,omitempty"`
+
+ // only needed for packages that aren't in npm
+ Name string `yaml:"name,omitempty"`
+ Version string `yaml:"version,omitempty"`
+
+ Engines struct {
+ Node string `yaml:"node"`
+ NPM string `yaml:"npm,omitempty"`
+ } `yaml:"engines,omitempty,flow"`
+ CPU []string `yaml:"cpu,omitempty,flow"`
+ Os []string `yaml:"os,omitempty,flow"`
+ LibC []string `yaml:"libc,omitempty"`
+
+ Deprecated string `yaml:"deprecated,omitempty"`
+ HasBin bool `yaml:"hasBin,omitempty"`
+ Prepare bool `yaml:"prepare,omitempty"`
+ RequiresBuild bool `yaml:"requiresBuild,omitempty"`
+
+ BundledDependencies []string `yaml:"bundledDependencies,omitempty"`
+ PeerDependencies map[string]string `yaml:"peerDependencies,omitempty"`
+ PeerDependenciesMeta map[string]struct {
+ Optional bool `yaml:"optional"`
+ } `yaml:"peerDependenciesMeta,omitempty"`
+
+ Dependencies map[string]string `yaml:"dependencies,omitempty"`
+ OptionalDependencies map[string]string `yaml:"optionalDependencies,omitempty"`
+
+ TransitivePeerDependencies []string `yaml:"transitivePeerDependencies,omitempty"`
+ Dev bool `yaml:"dev"`
+ Optional bool `yaml:"optional,omitempty"`
+ Patched bool `yaml:"patched,omitempty"`
+}
+
+// PackageResolution Various resolution strategies for packages
+type PackageResolution struct {
+ Type string `yaml:"type,omitempty"`
+ // For npm or tarball
+ Integrity string `yaml:"integrity,omitempty"`
+
+ // For tarball
+ Tarball string `yaml:"tarball,omitempty"`
+
+ // For local directory
+ Dir string `yaml:"directory,omitempty"`
+
+ // For git repo
+ Repo string `yaml:"repo,omitempty"`
+ Commit string `yaml:"commit,omitempty"`
+}
+
+// PatchFile represent a patch applied to a package
+type PatchFile struct {
+ Path string `yaml:"path"`
+ Hash string `yaml:"hash"`
+}
+
+func isSupportedVersion(version interface{}) error {
+ switch version.(type) {
+ case string:
+ if version == "6.0" {
+ return nil
+ }
+ case float64:
+ if version == 5.3 || version == 5.4 {
+ return nil
+ }
+ default:
+ return fmt.Errorf("lockfileVersion of type %T is invalid", version)
+ }
+ supportedVersions := []string{"5.3", "5.4", "6.0"}
+ return errors.Errorf("Unable to generate pnpm-lock.yaml with lockfileVersion: %s. Supported lockfile versions are %v", version, supportedVersions)
+}
+
+// DependenciesMeta metadata for dependencies
+type DependenciesMeta struct {
+ Injected bool `yaml:"injected,omitempty"`
+ Node string `yaml:"node,omitempty"`
+ Patch string `yaml:"patch,omitempty"`
+}
+
+// DecodePnpmLockfile parse a pnpm lockfile
+func DecodePnpmLockfile(contents []byte) (*PnpmLockfile, error) {
+ var lockfile PnpmLockfile
+ err := yaml.Unmarshal(contents, &lockfile)
+ if err != nil {
+ return nil, errors.Wrap(err, "could not unmarshal lockfile: ")
+ }
+
+ switch lockfile.Version.(type) {
+ case float64:
+ lockfile.isV6 = false
+ case string:
+ lockfile.isV6 = true
+ default:
+ return nil, fmt.Errorf("Unexpected type of lockfileVersion: '%T', expected float64 or string", lockfile.Version)
+ }
+ return &lockfile, nil
+}
+
+// ResolvePackage Given a package and version returns the key, resolved version, and if it was found
+func (p *PnpmLockfile) ResolvePackage(workspacePath turbopath.AnchoredUnixPath, name string, version string) (Package, error) {
+ // Check if version is a key
+ if _, ok := p.Packages[version]; ok {
+ return Package{Key: version, Version: p.extractVersion(version), Found: true}, nil
+ }
+
+ resolvedVersion, ok, err := p.resolveSpecifier(workspacePath, name, version)
+ if !ok || err != nil {
+ return Package{}, err
+ }
+ key := p.formatKey(name, resolvedVersion)
+ if entry, ok := (p.Packages)[key]; ok {
+ var version string
+ if entry.Version != "" {
+ version = entry.Version
+ } else {
+ version = resolvedVersion
+ }
+ return Package{Key: key, Version: version, Found: true}, nil
+ }
+
+ if entry, ok := p.Packages[resolvedVersion]; ok {
+ var version string
+ if entry.Version != "" {
+ version = entry.Version
+ } else {
+ // If there isn't a version field in the entry then the version is
+ // encoded in the key and we can omit the name from the version.
+ version = p.extractVersion(resolvedVersion)
+ }
+ return Package{Key: resolvedVersion, Version: version, Found: true}, nil
+ }
+
+ return Package{}, nil
+}
+
+// AllDependencies Given a lockfile key return all (dev/optional/peer) dependencies of that package
+func (p *PnpmLockfile) AllDependencies(key string) (map[string]string, bool) {
+ deps := map[string]string{}
+ entry, ok := p.Packages[key]
+ if !ok {
+ return deps, false
+ }
+
+ for name, version := range entry.Dependencies {
+ deps[name] = version
+ }
+
+ for name, version := range entry.OptionalDependencies {
+ deps[name] = version
+ }
+
+ // Peer dependencies appear in the Dependencies map resolved
+
+ return deps, true
+}
+
+// Subgraph Given a list of lockfile keys returns a Lockfile based off the original one that only contains the packages given
+func (p *PnpmLockfile) Subgraph(workspacePackages []turbopath.AnchoredSystemPath, packages []string) (Lockfile, error) {
+ lockfilePackages := make(map[string]PackageSnapshot, len(packages))
+ for _, key := range packages {
+ entry, ok := p.Packages[key]
+ if ok {
+ lockfilePackages[key] = entry
+ } else {
+ return nil, fmt.Errorf("Unable to find lockfile entry for %s", key)
+ }
+ }
+
+ importers, err := pruneImporters(p.Importers, workspacePackages)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, importer := range importers {
+ for dependency, meta := range importer.DependenciesMeta {
+ if meta.Injected {
+ resolution, ok, err := importer.findResolution(dependency)
+ if err != nil {
+ return nil, errors.Wrapf(err, "Unable to decode reference to %s", dependency)
+ }
+ if !ok {
+ return nil, fmt.Errorf("Unable to find %s other than reference in dependenciesMeta", dependency)
+ }
+ entry, ok := p.Packages[resolution.Version]
+ if !ok {
+ return nil, fmt.Errorf("Unable to find package entry for %s", resolution)
+ }
+
+ lockfilePackages[resolution.Version] = entry
+ }
+ }
+ }
+
+ lockfile := PnpmLockfile{
+ Version: p.Version,
+ Packages: lockfilePackages,
+ NeverBuiltDependencies: p.NeverBuiltDependencies,
+ OnlyBuiltDependencies: p.OnlyBuiltDependencies,
+ Overrides: p.Overrides,
+ PackageExtensionsChecksum: p.PackageExtensionsChecksum,
+ PatchedDependencies: p.prunePatches(p.PatchedDependencies, lockfilePackages),
+ Importers: importers,
+ }
+
+ return &lockfile, nil
+}
+
+// Prune imports to only those have all of their dependencies in the packages list
+func pruneImporters(importers map[string]ProjectSnapshot, workspacePackages []turbopath.AnchoredSystemPath) (map[string]ProjectSnapshot, error) {
+ prunedImporters := map[string]ProjectSnapshot{}
+
+ // Copy over root level importer
+ if root, ok := importers["."]; ok {
+ prunedImporters["."] = root
+ }
+
+ for _, workspacePath := range workspacePackages {
+ workspace := workspacePath.ToUnixPath().ToString()
+ importer, ok := importers[workspace]
+
+ // If a workspace has no dependencies *and* it is only depended on by the
+ // workspace root it will not show up as an importer.
+ if ok {
+ prunedImporters[workspace] = importer
+ }
+
+ }
+
+ return prunedImporters, nil
+}
+
+func (p *PnpmLockfile) prunePatches(patches map[string]PatchFile, packages map[string]PackageSnapshot) map[string]PatchFile {
+ if len(patches) == 0 {
+ return nil
+ }
+
+ patchPackages := make(map[string]PatchFile, len(patches))
+ for dependency := range packages {
+ if p.isV6 {
+ // Internally pnpm partially converts the new path format to the old
+ // format in order for existing parsing logic to work.
+ dependency = convertNewToOldDepPath(dependency)
+ }
+ dp := parseDepPath(dependency)
+ patchKey := fmt.Sprintf("%s@%s", dp.name, dp.version)
+
+ if patch, ok := patches[patchKey]; ok && patch.Hash == dp.patchHash() {
+ patchPackages[patchKey] = patch
+ }
+ }
+
+ return patchPackages
+}
+
+// Encode encode the lockfile representation and write it to the given writer
+func (p *PnpmLockfile) Encode(w io.Writer) error {
+ if err := isSupportedVersion(p.Version); err != nil {
+ return err
+ }
+
+ encoder := yaml.NewEncoder(w)
+ encoder.SetIndent(2)
+
+ if err := encoder.Encode(p); err != nil {
+ return errors.Wrap(err, "unable to encode pnpm lockfile")
+ }
+ return nil
+}
+
+// Patches return a list of patches used in the lockfile
+func (p *PnpmLockfile) Patches() []turbopath.AnchoredUnixPath {
+ if len(p.PatchedDependencies) == 0 {
+ return nil
+ }
+ patches := make([]string, len(p.PatchedDependencies))
+ i := 0
+ for _, patch := range p.PatchedDependencies {
+ patches[i] = patch.Path
+ i++
+ }
+ sort.Strings(patches)
+
+ patchPaths := make([]turbopath.AnchoredUnixPath, len(p.PatchedDependencies))
+ for i, patch := range patches {
+ patchPaths[i] = turbopath.AnchoredUnixPath(patch)
+ }
+ return patchPaths
+}
+
+// GlobalChange checks if there are any differences between lockfiles that would completely invalidate
+// the cache.
+func (p *PnpmLockfile) GlobalChange(other Lockfile) bool {
+ o, ok := other.(*PnpmLockfile)
+ return !ok ||
+ p.Version != o.Version ||
+ p.PackageExtensionsChecksum != o.PackageExtensionsChecksum ||
+ !reflect.DeepEqual(p.Overrides, o.Overrides) ||
+ !reflect.DeepEqual(p.PatchedDependencies, o.PatchedDependencies)
+}
+
+func (p *PnpmLockfile) resolveSpecifier(workspacePath turbopath.AnchoredUnixPath, name string, specifier string) (string, bool, error) {
+ pnpmWorkspacePath := workspacePath.ToString()
+ if pnpmWorkspacePath == "" {
+ // For pnpm, the root is named "."
+ pnpmWorkspacePath = "."
+ }
+ importer, ok := p.Importers[pnpmWorkspacePath]
+ if !ok {
+ return "", false, fmt.Errorf("no workspace '%v' found in lockfile", workspacePath)
+ }
+ resolution, ok, err := importer.findResolution(name)
+ if err != nil {
+ return "", false, err
+ }
+ // Verify that the specifier in the importer matches the one given
+ if !ok {
+ // Check if the specifier is already a resolved version
+ if _, ok := p.Packages[p.formatKey(name, specifier)]; ok {
+ return specifier, true, nil
+ }
+ return "", false, fmt.Errorf("Unable to find resolved version for %s@%s in %s", name, specifier, workspacePath)
+ }
+ overrideSpecifier := p.applyOverrides(name, specifier)
+ if resolution.Specifier != overrideSpecifier {
+ if _, ok := p.Packages[p.formatKey(name, overrideSpecifier)]; ok {
+ return overrideSpecifier, true, nil
+ }
+ return "", false, nil
+ }
+ return resolution.Version, true, nil
+}
+
+// Apply pnpm overrides to specifier, see https://pnpm.io/package_json#pnpmoverrides
+// Note this is barebones support and will only supports global overrides
+// future work will support semver ranges and selector filtering.
+func (p *PnpmLockfile) applyOverrides(name string, specifier string) string {
+ if len(p.Overrides) > 0 {
+ if new, ok := p.Overrides[name]; ok {
+ return new
+ }
+ }
+ return specifier
+}
+
+// Formatter of the lockfile key given a package name and version
+func (p *PnpmLockfile) formatKey(name string, version string) string {
+ if p.isV6 {
+ return fmt.Sprintf("/%s@%s", name, version)
+ }
+ return fmt.Sprintf("/%s/%s", name, version)
+}
+
+// Extracts version from lockfile key
+func (p *PnpmLockfile) extractVersion(key string) string {
+ if p.isV6 {
+ key = convertNewToOldDepPath(key)
+ }
+ dp := parseDepPath(key)
+ if dp.peerSuffix != "" {
+ sep := ""
+ if !p.isV6 {
+ sep = "_"
+ }
+ return fmt.Sprintf("%s%s%s", dp.version, sep, dp.peerSuffix)
+ }
+ return dp.version
+}
+
+// Parsed representation of a pnpm lockfile key
+type depPath struct {
+ host string
+ name string
+ version string
+ peerSuffix string
+}
+
+func parseDepPath(dependency string) depPath {
+ // See https://github.com/pnpm/pnpm/blob/185ab01adfc927ea23d2db08a14723bf51d0025f/packages/dependency-path/src/index.ts#L96
+ var dp depPath
+ parts := strings.Split(dependency, "/")
+ shift := func() string {
+ if len(parts) == 0 {
+ return ""
+ }
+ val := parts[0]
+ parts = parts[1:]
+ return val
+ }
+
+ isAbsolute := dependency[0] != '/'
+ // Skip leading '/'
+ if !isAbsolute {
+ shift()
+ }
+
+ if isAbsolute {
+ dp.host = shift()
+ }
+
+ if len(parts) == 0 {
+ return dp
+ }
+
+ if strings.HasPrefix(parts[0], "@") {
+ dp.name = fmt.Sprintf("%s/%s", shift(), shift())
+ } else {
+ dp.name = shift()
+ }
+
+ version := strings.Join(parts, "/")
+ if len(version) > 0 {
+ var peerSuffixIndex int
+ if strings.Contains(version, "(") && strings.HasSuffix(version, ")") {
+ // v6 encodes peers deps using (peer=version)
+ // also used to encode patches using (path_hash=hash)
+ peerSuffixIndex = strings.Index(version, "(")
+ dp.peerSuffix = version[peerSuffixIndex:]
+ dp.version = version[0:peerSuffixIndex]
+ } else {
+ // pre v6 uses _ to separate version from peer dependencies
+ // if a dependency is patched and has peer dependencies its version will
+ // be encoded as version_patchHash_peerDepsHash
+ peerSuffixIndex = strings.Index(version, "_")
+ if peerSuffixIndex != -1 {
+ dp.peerSuffix = version[peerSuffixIndex+1:]
+ dp.version = version[0:peerSuffixIndex]
+ }
+ }
+ if peerSuffixIndex == -1 {
+ dp.version = version
+ }
+ }
+
+ return dp
+}
+
+var _patchHashKey = "patch_hash="
+
+func (d depPath) patchHash() string {
+ if strings.HasPrefix(d.peerSuffix, "(") && strings.HasSuffix(d.peerSuffix, ")") {
+ for _, part := range strings.Split(d.peerSuffix, "(") {
+ if strings.HasPrefix(part, _patchHashKey) {
+ // drop the enclosing ')'
+ return part[len(_patchHashKey) : len(part)-1]
+ }
+ }
+ // no patch entry found
+ return ""
+ }
+
+ sepIndex := strings.Index(d.peerSuffix, "_")
+ if sepIndex != -1 {
+ return d.peerSuffix[:sepIndex]
+ }
+ // if a dependency just has a single suffix we can't tell if it's a patch or peer hash
+ // return it in case it's a patch hash
+ return d.peerSuffix
+}
+
+// Used to convert v6's dep path of /name@version to v5's /name/version
+// See https://github.com/pnpm/pnpm/blob/185ab01adfc927ea23d2db08a14723bf51d0025f/lockfile/lockfile-file/src/experiments/inlineSpecifiersLockfileConverters.ts#L162
+func convertNewToOldDepPath(newPath string) string {
+ if len(newPath) > 2 && !strings.Contains(newPath[2:], "@") {
+ return newPath
+ }
+ searchStartIndex := strings.Index(newPath, "/@") + 2
+ index := strings.Index(newPath[searchStartIndex:], "@") + searchStartIndex
+ if strings.Contains(newPath, "(") && index > strings.Index(newPath, "(") {
+ return newPath
+ }
+ return fmt.Sprintf("%s/%s", newPath[0:index], newPath[index+1:])
+}