@@ -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 {
@@ -1414,6 +1492,9 @@ func (p *Package) buildYarn(buildctx *buildContext, wd, result string) (bld *pac
14141492 }
14151493
14161494 pkgYarnLock := "pkg-yarn.lock"
1495+ // Collect yarn dependencies to patch link: dependencies in package.json
1496+ // Maps npm package name -> built tarball path
1497+ yarnDepsForLinkPatching := make (map [string ]string )
14171498 for _ , deppkg := range p .GetTransitiveDependencies () {
14181499 if deppkg .Ephemeral {
14191500 continue
@@ -1456,6 +1537,18 @@ func (p *Package) buildYarn(buildctx *buildContext, wd, result string) (bld *pac
14561537 untarCmd ,
14571538 }... )
14581539 }
1540+
1541+ // For any yarn package dependency, extract npm package names for link: patching
1542+ if deppkg .Type == YarnPackage {
1543+ npmNames , err := extractNpmPackageNames (builtpkg )
1544+ if err != nil {
1545+ log .WithField ("package" , deppkg .FullName ()).WithError (err ).Debug ("cannot extract npm package names from yarn dependency" )
1546+ } else {
1547+ for npmName := range npmNames {
1548+ yarnDepsForLinkPatching [npmName ] = builtpkg
1549+ }
1550+ }
1551+ }
14591552 }
14601553
14611554 pkgJSONFilename := filepath .Join (wd , "package.json" )
@@ -1469,6 +1562,135 @@ func (p *Package) buildYarn(buildctx *buildContext, wd, result string) (bld *pac
14691562 return nil , xerrors .Errorf ("cannot patch package.json of yarn package: %w" , err )
14701563 }
14711564 var modifiedPackageJSON bool
1565+
1566+ // Patch link: dependencies to point to built yarn packages
1567+ // This is necessary because link: paths are relative to the original source location,
1568+ // but yarn install runs in an isolated build directory where those paths don't exist.
1569+ // For YarnApp packages, we extract node_modules/<pkg>/ to _link_deps/<pkg>/
1570+ // For YarnLibrary packages, we use the tarball directly (yarn pack format)
1571+ // We also need to patch yarn.lock to match the new package.json references.
1572+ type linkPatch struct {
1573+ npmName string
1574+ oldRef string // e.g., "link:../shared"
1575+ newRef string // e.g., "file:./_link_deps/gitpod-shared"
1576+ builtPath string
1577+ isYarnPack bool
1578+ extractCmd string // command to extract YarnApp package (empty for YarnLibrary)
1579+ }
1580+ var linkPatches []linkPatch
1581+
1582+ if len (yarnDepsForLinkPatching ) > 0 {
1583+ for _ , depField := range []string {"dependencies" , "devDependencies" } {
1584+ deps , ok := packageJSON [depField ].(map [string ]interface {})
1585+ if ! ok {
1586+ continue
1587+ }
1588+ for npmName , builtPath := range yarnDepsForLinkPatching {
1589+ if depValue , exists := deps [npmName ]; exists {
1590+ if depStr , ok := depValue .(string ); ok && strings .HasPrefix (depStr , "link:" ) {
1591+ // Check if this is a YarnLibrary (yarn pack) or YarnApp (node_modules) tarball
1592+ isYarnPack := false
1593+ if f , err := os .Open (builtPath ); err == nil {
1594+ if gzr , err := gzip .NewReader (f ); err == nil {
1595+ tr := tar .NewReader (gzr )
1596+ for {
1597+ header , err := tr .Next ()
1598+ if err != nil {
1599+ break
1600+ }
1601+ if header .Name == "package/package.json" {
1602+ isYarnPack = true
1603+ break
1604+ }
1605+ }
1606+ gzr .Close ()
1607+ }
1608+ f .Close ()
1609+ }
1610+
1611+ var newRef string
1612+ var extractCmd string
1613+ // Extract dependency to _link_deps/<pkg>/ directory
1614+ // We need to strip the tarball's internal directory structure:
1615+ // - YarnLibrary tarballs (from yarn pack) have: package/<files>
1616+ // - YarnApp tarballs have: ./node_modules/<pkg-name>/<files>
1617+ linkDepDir := filepath .Join ("_link_deps" , npmName )
1618+ if isYarnPack {
1619+ // YarnLibrary: extract package/* to _link_deps/<pkg>/
1620+ // --strip-components=1 removes "package/" prefix
1621+ extractCmd = fmt .Sprintf ("mkdir -p %s && tar -xzf %s -C %s --strip-components=1 package/" , linkDepDir , builtPath , linkDepDir )
1622+ } else {
1623+ // YarnApp: extract ./node_modules/<pkg>/* to _link_deps/<pkg>/
1624+ // --strip-components=3 removes "./node_modules/<pkg>/" prefix
1625+ extractCmd = fmt .Sprintf ("mkdir -p %s && tar -xzf %s -C %s --strip-components=3 ./node_modules/%s/" , linkDepDir , builtPath , linkDepDir , npmName )
1626+ }
1627+ newRef = fmt .Sprintf ("file:./%s" , linkDepDir )
1628+
1629+ linkPatches = append (linkPatches , linkPatch {
1630+ npmName : npmName ,
1631+ oldRef : depStr ,
1632+ newRef : newRef ,
1633+ builtPath : builtPath ,
1634+ isYarnPack : isYarnPack ,
1635+ extractCmd : extractCmd ,
1636+ })
1637+
1638+ deps [npmName ] = newRef
1639+ modifiedPackageJSON = true
1640+ log .WithField ("package" , p .FullName ()).WithField ("dependency" , npmName ).WithField ("isYarnPack" , isYarnPack ).Debug ("patched link: dependency in package.json" )
1641+ }
1642+ }
1643+ }
1644+ }
1645+ }
1646+
1647+ // Add extraction commands for YarnApp link dependencies
1648+ for _ , patch := range linkPatches {
1649+ if patch .extractCmd != "" {
1650+ commands [PackageBuildPhasePrep ] = append (commands [PackageBuildPhasePrep ], []string {"sh" , "-c" , patch .extractCmd })
1651+ }
1652+ }
1653+
1654+ // Patch yarn.lock to replace link: references with file: references
1655+ // This is necessary because --frozen-lockfile requires package.json and yarn.lock to match
1656+ if len (linkPatches ) > 0 {
1657+ yarnLockPath := filepath .Join (wd , "yarn.lock" )
1658+ yarnLockContent , err := os .ReadFile (yarnLockPath )
1659+ if err == nil {
1660+ yarnLockStr := string (yarnLockContent )
1661+ modified := false
1662+ for _ , patch := range linkPatches {
1663+ // yarn.lock format: "package-name@link:../path":
1664+ // Note: yarn.lock may normalize paths differently than package.json
1665+ // e.g., package.json has "link:./../shared" but yarn.lock has "link:../shared"
1666+ oldPattern := fmt .Sprintf (`"%s@%s"` , patch .npmName , patch .oldRef )
1667+ newPattern := fmt .Sprintf (`"%s@%s"` , patch .npmName , patch .newRef )
1668+
1669+ // Try exact match first
1670+ if strings .Contains (yarnLockStr , oldPattern ) {
1671+ yarnLockStr = strings .ReplaceAll (yarnLockStr , oldPattern , newPattern )
1672+ modified = true
1673+ log .WithField ("package" , p .FullName ()).WithField ("dependency" , patch .npmName ).Debug ("patched link: dependency in yarn.lock" )
1674+ } else if strings .HasPrefix (patch .oldRef , "link:" ) {
1675+ // Try normalized path: remove leading "./" from the path
1676+ // e.g., "link:./../shared" -> "link:../shared"
1677+ normalizedOldRef := strings .Replace (patch .oldRef , "link:./" , "link:" , 1 )
1678+ normalizedOldPattern := fmt .Sprintf (`"%s@%s"` , patch .npmName , normalizedOldRef )
1679+ if strings .Contains (yarnLockStr , normalizedOldPattern ) {
1680+ yarnLockStr = strings .ReplaceAll (yarnLockStr , normalizedOldPattern , newPattern )
1681+ modified = true
1682+ log .WithField ("package" , p .FullName ()).WithField ("dependency" , patch .npmName ).Debug ("patched link: dependency in yarn.lock" )
1683+ }
1684+ }
1685+ }
1686+ if modified {
1687+ if err := os .WriteFile (yarnLockPath , []byte (yarnLockStr ), 0644 ); err != nil {
1688+ return nil , xerrors .Errorf ("cannot write patched yarn.lock: %w" , err )
1689+ }
1690+ }
1691+ }
1692+ }
1693+
14721694 if cfg .Packaging == YarnLibrary {
14731695 // 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
14741696 // package we're building. To this end, we modify the package.json of the source package.
0 commit comments