package node import ( "fmt" "strings" a "github.com/railwayapp/railpack/core/app" "github.com/railwayapp/railpack/core/generate" "github.com/railwayapp/railpack/core/plan" ) const ( PackageManagerNpm PackageManager = "npm" PackageManagerPnpm PackageManager = "pnpm" PackageManagerBun PackageManager = "bun" PackageManagerYarn1 PackageManager = "yarn1" PackageManagerYarn2 PackageManager = "yarn2" ) func (p PackageManager) Name() string { switch p { case PackageManagerNpm: return "npm" case PackageManagerPnpm: return "pnpm" case PackageManagerBun: return "bun" case PackageManagerYarn1, PackageManagerYarn2: return "yarn" default: return "" } } func (p PackageManager) RunCmd(cmd string) string { return fmt.Sprintf("%s run %s", p.Name(), cmd) } func (p PackageManager) RunScriptCommand(cmd string) string { if p == PackageManagerBun { return "bun " + cmd } return "node " + cmd } func (p PackageManager) installDependencies(ctx *generate.GenerateContext, packageJson *PackageJson, install *generate.CommandStepBuilder) { hasPostInstall := packageJson.Scripts != nil && packageJson.Scripts["postinstall"] != "" hasPrepare := packageJson.Scripts != nil && packageJson.Scripts["prepare"] != "" // If there is a postinstall script, we need the entire app to be copied // This is to handle things like patch-package if hasPostInstall || hasPrepare { install.AddCommands([]plan.Command{ plan.NewCopyCommand(".", "."), }) } else { for _, file := range p.SupportingInstallFiles(ctx.App) { install.AddCommands([]plan.Command{ plan.NewCopyCommand(file, file), }) } } p.InstallDeps(ctx, install) } // GetCache returns the cache for the package manager func (p PackageManager) GetInstallCache(ctx *generate.GenerateContext) string { switch p { case PackageManagerNpm: return ctx.Caches.AddCache("npm-install", "/root/.npm") case PackageManagerPnpm: return ctx.Caches.AddCache("pnpm-install", "/root/.local/share/pnpm/store/v3") case PackageManagerBun: return ctx.Caches.AddCache("bun-install", "/root/.bun/install/cache") case PackageManagerYarn1: return ctx.Caches.AddCacheWithType("yarn-install", "/usr/local/share/.cache/yarn", plan.CacheTypeLocked) case PackageManagerYarn2: return ctx.Caches.AddCache("yarn-install", "/app/.yarn/cache") default: return "" } } func (p PackageManager) InstallDeps(ctx *generate.GenerateContext, install *generate.CommandStepBuilder) { install.AddCache(p.GetInstallCache(ctx)) switch p { case PackageManagerNpm: hasLockfile := ctx.App.HasMatch("package-lock.json") if hasLockfile { install.AddCommand(plan.NewExecCommand("npm ci")) } else { install.AddCommand(plan.NewExecCommand("npm install")) } case PackageManagerPnpm: install.AddCommand(plan.NewExecCommand("pnpm install --frozen-lockfile --prefer-offline")) case PackageManagerBun: install.AddCommand(plan.NewExecCommand("bun install --frozen-lockfile")) case PackageManagerYarn1: install.AddCommand(plan.NewExecCommand("yarn install --frozen-lockfile")) case PackageManagerYarn2: install.AddCommand(plan.NewExecCommand("yarn install --check-cache")) } } func (p PackageManager) PruneDeps(ctx *generate.GenerateContext, prune *generate.CommandStepBuilder) { prune.AddCache(p.GetInstallCache(ctx)) switch p { case PackageManagerNpm: prune.AddCommand(plan.NewExecCommand("npm prune --omit=dev")) case PackageManagerPnpm: prune.AddCommand(plan.NewExecCommand("pnpm prune --prod")) case PackageManagerBun: // Prune is not supported in Bun. https://github.com/oven-sh/bun/issues/3605 prune.AddCommand(plan.NewExecShellCommand("rm -rf node_modules && bun install --production")) case PackageManagerYarn1: prune.AddCommand(plan.NewExecCommand("yarn install --production=true")) case PackageManagerYarn2: prune.AddCommand(plan.NewExecCommand("yarn workspaces focus --production --all")) } } func (p PackageManager) GetInstallFolder(ctx *generate.GenerateContext) []string { switch p { case PackageManagerYarn2: return []string{"/app/.yarn", p.getYarn2GlobalFolder(ctx)} default: return []string{"/app/node_modules"} } } // SupportingInstallFiles returns a list of files that are needed to install dependencies func (p PackageManager) SupportingInstallFiles(app *a.App) []string { patterns := []string{ "**/package.json", "**/package-lock.json", "**/pnpm-workspace.yaml", "**/yarn.lock", "**/pnpm-lock.yaml", "**/bun.lockb", "**/bun.lock", "**/.yarn", "**/.pnp.*", // Yarn Plug'n'Play files "**/.yarnrc.yml", // Yarn 2+ config "**/.npmrc", // NPM config "**/.node-version", // Node version file "**/.nvmrc", // NVM config "patches", // PNPM patches ".pnpm-patches", } var allFiles []string for _, pattern := range patterns { files, err := app.FindFiles(pattern) if err != nil { continue } for _, file := range files { if !strings.HasPrefix(file, "node_modules/") { allFiles = append(allFiles, file) } } dirs, err := app.FindDirectories(pattern) if err != nil { continue } allFiles = append(allFiles, dirs...) } return allFiles } // GetPackageManagerPackages installs specific versions of package managers by analyzing the users code func (p PackageManager) GetPackageManagerPackages(ctx *generate.GenerateContext, packageJson *PackageJson, packages *generate.MiseStepBuilder) { // Pnpm if p == PackageManagerPnpm { pnpm := packages.Default("pnpm", "latest") lockfile, err := ctx.App.ReadFile("pnpm-lock.yaml") if err == nil { if strings.HasPrefix(lockfile, "lockfileVersion: 5.3") { packages.Version(pnpm, "6", "pnpm-lock.yaml") } else if strings.HasPrefix(lockfile, "lockfileVersion: 5.4") { packages.Version(pnpm, "7", "pnpm-lock.yaml") } else if strings.HasPrefix(lockfile, "lockfileVersion: '6.0'") { packages.Version(pnpm, "8", "pnpm-lock.yaml") } } name, version := p.parsePackageManagerField(packageJson) if name == "pnpm" && version != "" { packages.Version(pnpm, version, "package.json > packageManager") } } // Yarn if p == PackageManagerYarn1 || p == PackageManagerYarn2 { if p == PackageManagerYarn1 { packages.Default("yarn", "1") packages.AddSupportingAptPackage("tar") packages.AddSupportingAptPackage("gpg") } else { packages.Default("yarn", "2") } name, version := p.parsePackageManagerField(packageJson) if name == "yarn" && version != "" { majorVersion := strings.Split(version, ".")[0] // Only apply version if it matches the expected yarn version if (majorVersion == "1" && p == PackageManagerYarn1) || (majorVersion != "1" && p == PackageManagerYarn2) { packages.Version(packages.Default("yarn", majorVersion), version, "package.json > packageManager") } } } // Bun if p == PackageManagerBun { bun := packages.Default("bun", "latest") name, version := p.parsePackageManagerField(packageJson) if name == "bun" && version != "" { packages.Version(bun, version, "package.json > packageManager") } } } // parsePackageManagerField parses the packageManager field from package.json // and returns the name and version as a tuple func (p PackageManager) parsePackageManagerField(packageJson *PackageJson) (string, string) { if packageJson.PackageManager != nil { pmString := *packageJson.PackageManager // Parse packageManager field which is in format "name@version" or "name@version+sha224.hash" parts := strings.Split(pmString, "@") if len(parts) == 2 { // Split version on '+' to remove SHA hash if present versionParts := strings.Split(parts[1], "+") return parts[0], versionParts[0] } } return "", "" } type YarnRc struct { GlobalFolder string `json:"globalFolder"` } func (p PackageManager) getYarn2GlobalFolder(ctx *generate.GenerateContext) string { var yarnRc YarnRc if err := ctx.App.ReadYAML(".yarnrc.yml", &yarnRc); err == nil && yarnRc.GlobalFolder != "" { return yarnRc.GlobalFolder } return "/root/.yarn" }