@@ -347,6 +347,84 @@ func computeSHA256(filePath string) (string, error) {
347347 return hex .EncodeToString (hash .Sum (nil )), nil
348348}
349349
350+ // extractNpmPackageNames extracts npm package names from a yarn tarball.
351+ // It handles both YarnLibrary tarballs (package/package.json) and YarnApp tarballs (node_modules/*/package.json).
352+ // Returns a map of npm package name -> true for all packages found.
353+ func extractNpmPackageNames (tarballPath string ) (map [string ]bool , error ) {
354+ file , err := os .Open (tarballPath )
355+ if err != nil {
356+ return nil , xerrors .Errorf ("cannot open tarball: %w" , err )
357+ }
358+ defer file .Close ()
359+
360+ gzr , err := gzip .NewReader (file )
361+ if err != nil {
362+ return nil , xerrors .Errorf ("cannot create gzip reader: %w" , err )
363+ }
364+ defer gzr .Close ()
365+
366+ result := make (map [string ]bool )
367+ tr := tar .NewReader (gzr )
368+ for {
369+ header , err := tr .Next ()
370+ if err == io .EOF {
371+ break
372+ }
373+ if err != nil {
374+ return nil , xerrors .Errorf ("cannot read tarball: %w" , err )
375+ }
376+
377+ // YarnLibrary: package/package.json
378+ // YarnApp: node_modules/<pkg>/package.json (but not nested node_modules)
379+ isLibraryPkgJSON := header .Name == "package/package.json"
380+ isAppPkgJSON := strings .HasPrefix (header .Name , "node_modules/" ) ||
381+ strings .HasPrefix (header .Name , "./node_modules/" )
382+
383+ if isAppPkgJSON {
384+ // Check it's a direct child of node_modules, not nested
385+ // e.g., node_modules/foo/package.json but not node_modules/foo/node_modules/bar/package.json
386+ name := strings .TrimPrefix (header .Name , "./" )
387+ parts := strings .Split (name , "/" )
388+ // Should be: node_modules, <pkg-name>, package.json (3 parts)
389+ // Or for scoped: node_modules, @scope, pkg-name, package.json (4 parts)
390+ if len (parts ) < 3 {
391+ continue
392+ }
393+ if parts [len (parts )- 1 ] != "package.json" {
394+ continue
395+ }
396+ // Check no nested node_modules
397+ nodeModulesCount := 0
398+ for _ , p := range parts {
399+ if p == "node_modules" {
400+ nodeModulesCount ++
401+ }
402+ }
403+ if nodeModulesCount != 1 {
404+ continue
405+ }
406+ isAppPkgJSON = true
407+ } else {
408+ isAppPkgJSON = false
409+ }
410+
411+ if isLibraryPkgJSON || isAppPkgJSON {
412+ var pkgJSON struct {
413+ Name string `json:"name"`
414+ }
415+ if err := json .NewDecoder (tr ).Decode (& pkgJSON ); err != nil {
416+ log .WithField ("file" , header .Name ).WithError (err ).Debug ("cannot parse package.json in tarball" )
417+ continue
418+ }
419+ if pkgJSON .Name != "" && pkgJSON .Name != "local" {
420+ result [pkgJSON .Name ] = true
421+ }
422+ }
423+ }
424+
425+ return result , nil
426+ }
427+
350428// verifyAllArtifactChecksums verifies all tracked cache artifacts before signing handoff
351429func verifyAllArtifactChecksums (buildctx * buildContext ) error {
352430 if buildctx .artifactChecksums == nil {
@@ -1491,6 +1569,9 @@ func (p *Package) buildYarn(buildctx *buildContext, wd, result string) (bld *pac
14911569 }
14921570
14931571 pkgYarnLock := "pkg-yarn.lock"
1572+ // Collect yarn dependencies to patch link: dependencies in package.json
1573+ // Maps npm package name -> built tarball path
1574+ yarnDepsForLinkPatching := make (map [string ]string )
14941575 for _ , deppkg := range p .GetTransitiveDependencies () {
14951576 if deppkg .Ephemeral {
14961577 continue
@@ -1533,6 +1614,18 @@ func (p *Package) buildYarn(buildctx *buildContext, wd, result string) (bld *pac
15331614 untarCmd ,
15341615 }... )
15351616 }
1617+
1618+ // For any yarn package dependency, extract npm package names for link: patching
1619+ if deppkg .Type == YarnPackage {
1620+ npmNames , err := extractNpmPackageNames (builtpkg )
1621+ if err != nil {
1622+ log .WithField ("package" , deppkg .FullName ()).WithError (err ).Debug ("cannot extract npm package names from yarn dependency" )
1623+ } else {
1624+ for npmName := range npmNames {
1625+ yarnDepsForLinkPatching [npmName ] = builtpkg
1626+ }
1627+ }
1628+ }
15361629 }
15371630
15381631 pkgJSONFilename := filepath .Join (wd , "package.json" )
@@ -1546,6 +1639,135 @@ func (p *Package) buildYarn(buildctx *buildContext, wd, result string) (bld *pac
15461639 return nil , xerrors .Errorf ("cannot patch package.json of yarn package: %w" , err )
15471640 }
15481641 var modifiedPackageJSON bool
1642+
1643+ // Patch link: dependencies to point to built yarn packages
1644+ // This is necessary because link: paths are relative to the original source location,
1645+ // but yarn install runs in an isolated build directory where those paths don't exist.
1646+ // For YarnApp packages, we extract node_modules/<pkg>/ to _link_deps/<pkg>/
1647+ // For YarnLibrary packages, we use the tarball directly (yarn pack format)
1648+ // We also need to patch yarn.lock to match the new package.json references.
1649+ type linkPatch struct {
1650+ npmName string
1651+ oldRef string // e.g., "link:../shared"
1652+ newRef string // e.g., "file:./_link_deps/gitpod-shared"
1653+ builtPath string
1654+ isYarnPack bool
1655+ extractCmd string // command to extract YarnApp package (empty for YarnLibrary)
1656+ }
1657+ var linkPatches []linkPatch
1658+
1659+ if len (yarnDepsForLinkPatching ) > 0 {
1660+ for _ , depField := range []string {"dependencies" , "devDependencies" } {
1661+ deps , ok := packageJSON [depField ].(map [string ]interface {})
1662+ if ! ok {
1663+ continue
1664+ }
1665+ for npmName , builtPath := range yarnDepsForLinkPatching {
1666+ if depValue , exists := deps [npmName ]; exists {
1667+ if depStr , ok := depValue .(string ); ok && strings .HasPrefix (depStr , "link:" ) {
1668+ // Check if this is a YarnLibrary (yarn pack) or YarnApp (node_modules) tarball
1669+ isYarnPack := false
1670+ if f , err := os .Open (builtPath ); err == nil {
1671+ if gzr , err := gzip .NewReader (f ); err == nil {
1672+ tr := tar .NewReader (gzr )
1673+ for {
1674+ header , err := tr .Next ()
1675+ if err != nil {
1676+ break
1677+ }
1678+ if header .Name == "package/package.json" {
1679+ isYarnPack = true
1680+ break
1681+ }
1682+ }
1683+ gzr .Close ()
1684+ }
1685+ f .Close ()
1686+ }
1687+
1688+ var newRef string
1689+ var extractCmd string
1690+ // Extract dependency to _link_deps/<pkg>/ directory
1691+ // We need to strip the tarball's internal directory structure:
1692+ // - YarnLibrary tarballs (from yarn pack) have: package/<files>
1693+ // - YarnApp tarballs have: ./node_modules/<pkg-name>/<files>
1694+ linkDepDir := filepath .Join ("_link_deps" , npmName )
1695+ if isYarnPack {
1696+ // YarnLibrary: extract package/* to _link_deps/<pkg>/
1697+ // --strip-components=1 removes "package/" prefix
1698+ extractCmd = fmt .Sprintf ("mkdir -p %s && tar -xzf %s -C %s --strip-components=1 package/" , linkDepDir , builtPath , linkDepDir )
1699+ } else {
1700+ // YarnApp: extract ./node_modules/<pkg>/* to _link_deps/<pkg>/
1701+ // --strip-components=3 removes "./node_modules/<pkg>/" prefix
1702+ extractCmd = fmt .Sprintf ("mkdir -p %s && tar -xzf %s -C %s --strip-components=3 ./node_modules/%s/" , linkDepDir , builtPath , linkDepDir , npmName )
1703+ }
1704+ newRef = fmt .Sprintf ("file:./%s" , linkDepDir )
1705+
1706+ linkPatches = append (linkPatches , linkPatch {
1707+ npmName : npmName ,
1708+ oldRef : depStr ,
1709+ newRef : newRef ,
1710+ builtPath : builtPath ,
1711+ isYarnPack : isYarnPack ,
1712+ extractCmd : extractCmd ,
1713+ })
1714+
1715+ deps [npmName ] = newRef
1716+ modifiedPackageJSON = true
1717+ log .WithField ("package" , p .FullName ()).WithField ("dependency" , npmName ).WithField ("isYarnPack" , isYarnPack ).Debug ("patched link: dependency in package.json" )
1718+ }
1719+ }
1720+ }
1721+ }
1722+ }
1723+
1724+ // Add extraction commands for YarnApp link dependencies
1725+ for _ , patch := range linkPatches {
1726+ if patch .extractCmd != "" {
1727+ commands [PackageBuildPhasePrep ] = append (commands [PackageBuildPhasePrep ], []string {"sh" , "-c" , patch .extractCmd })
1728+ }
1729+ }
1730+
1731+ // Patch yarn.lock to replace link: references with file: references
1732+ // This is necessary because --frozen-lockfile requires package.json and yarn.lock to match
1733+ if len (linkPatches ) > 0 {
1734+ yarnLockPath := filepath .Join (wd , "yarn.lock" )
1735+ yarnLockContent , err := os .ReadFile (yarnLockPath )
1736+ if err == nil {
1737+ yarnLockStr := string (yarnLockContent )
1738+ modified := false
1739+ for _ , patch := range linkPatches {
1740+ // yarn.lock format: "package-name@link:../path":
1741+ // Note: yarn.lock may normalize paths differently than package.json
1742+ // e.g., package.json has "link:./../shared" but yarn.lock has "link:../shared"
1743+ oldPattern := fmt .Sprintf (`"%s@%s"` , patch .npmName , patch .oldRef )
1744+ newPattern := fmt .Sprintf (`"%s@%s"` , patch .npmName , patch .newRef )
1745+
1746+ // Try exact match first
1747+ if strings .Contains (yarnLockStr , oldPattern ) {
1748+ yarnLockStr = strings .ReplaceAll (yarnLockStr , oldPattern , newPattern )
1749+ modified = true
1750+ log .WithField ("package" , p .FullName ()).WithField ("dependency" , patch .npmName ).Debug ("patched link: dependency in yarn.lock" )
1751+ } else if strings .HasPrefix (patch .oldRef , "link:" ) {
1752+ // Try normalized path: remove leading "./" from the path
1753+ // e.g., "link:./../shared" -> "link:../shared"
1754+ normalizedOldRef := strings .Replace (patch .oldRef , "link:./" , "link:" , 1 )
1755+ normalizedOldPattern := fmt .Sprintf (`"%s@%s"` , patch .npmName , normalizedOldRef )
1756+ if strings .Contains (yarnLockStr , normalizedOldPattern ) {
1757+ yarnLockStr = strings .ReplaceAll (yarnLockStr , normalizedOldPattern , newPattern )
1758+ modified = true
1759+ log .WithField ("package" , p .FullName ()).WithField ("dependency" , patch .npmName ).Debug ("patched link: dependency in yarn.lock" )
1760+ }
1761+ }
1762+ }
1763+ if modified {
1764+ if err := os .WriteFile (yarnLockPath , []byte (yarnLockStr ), 0644 ); err != nil {
1765+ return nil , xerrors .Errorf ("cannot write patched yarn.lock: %w" , err )
1766+ }
1767+ }
1768+ }
1769+ }
1770+
15491771 if cfg .Packaging == YarnLibrary {
15501772 // We can't modify the `yarn pack` generated tar file without runnign the risk of yarn blocking when attempting to unpack it again. Thus, we must include the pkgYarnLock in the npm
15511773 // package we're building. To this end, we modify the package.json of the source package.
0 commit comments