mirror of
https://github.com/hanzoai/pack.git
synced 2026-09-30 23:43:13 +00:00
feat: support package.json engines for package manager and node versions (#251)
* refactor(node): check engines after file-based detection for package manager Engines are now considered only if file-based detection keeps default npm, so lockfiles/configs take precedence over engines as a hint. * feat: extract yarn version from package.json > engines > yarn * test: add bun version to bun test assertion * test: update snapshots * test: add engines version extraction test * test: assert against two distinct versions we couldn't do this before the recent testing harness improvement * test: snapshot update * fix: properly handle corepack version definitions * docs: update docs on node provider detection * docs: add docs command to claude
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
Take a look at changes on this branch (compared to upstream/main) and update any relevant documentation in @docs/.
|
||||
|
||||
Only update markdown documentation, do not make any code changes.
|
||||
@@ -1,11 +1,19 @@
|
||||
{
|
||||
"caches": {
|
||||
"node-modules": {
|
||||
"directory": "/app/node_modules/.cache",
|
||||
"apt": {
|
||||
"directory": "/var/cache/apt",
|
||||
"type": "locked"
|
||||
},
|
||||
"apt-lists": {
|
||||
"directory": "/var/lib/apt/lists",
|
||||
"type": "locked"
|
||||
},
|
||||
"bun-install": {
|
||||
"directory": "/root/.bun/install/cache",
|
||||
"type": "shared"
|
||||
},
|
||||
"npm-install": {
|
||||
"directory": "/root/.npm",
|
||||
"node-modules": {
|
||||
"directory": "/app/node_modules/.cache",
|
||||
"type": "shared"
|
||||
}
|
||||
},
|
||||
@@ -42,7 +50,7 @@
|
||||
"step": "build"
|
||||
}
|
||||
],
|
||||
"startCommand": "npm run start",
|
||||
"startCommand": "bun run start",
|
||||
"variables": {
|
||||
"CI": "true",
|
||||
"NODE_ENV": "production",
|
||||
@@ -52,6 +60,24 @@
|
||||
}
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"caches": [
|
||||
"apt",
|
||||
"apt-lists"
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"cmd": "sh -c 'apt-get update \u0026\u0026 apt-get install -y nodejs'",
|
||||
"customName": "install apt packages: nodejs"
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"image": "ghcr.io/railwayapp/railpack-builder:latest"
|
||||
}
|
||||
],
|
||||
"name": "packages:apt:build"
|
||||
},
|
||||
{
|
||||
"assets": {
|
||||
"mise.toml": "[mise.toml]"
|
||||
@@ -67,12 +93,12 @@
|
||||
},
|
||||
{
|
||||
"cmd": "sh -c 'mise trust -a \u0026\u0026 mise install'",
|
||||
"customName": "install mise packages: bun, node"
|
||||
"customName": "install mise packages: bun"
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"image": "ghcr.io/railwayapp/railpack-builder:latest"
|
||||
"step": "packages:apt:build"
|
||||
}
|
||||
],
|
||||
"name": "packages:mise",
|
||||
@@ -87,7 +113,7 @@
|
||||
},
|
||||
{
|
||||
"caches": [
|
||||
"npm-install"
|
||||
"bun-install"
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
@@ -101,7 +127,7 @@
|
||||
"src": "package.json"
|
||||
},
|
||||
{
|
||||
"cmd": "npm install"
|
||||
"cmd": "bun install --frozen-lockfile"
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
{
|
||||
"caches": {
|
||||
"node-modules": {
|
||||
"directory": "/app/node_modules/.cache",
|
||||
"type": "shared"
|
||||
},
|
||||
"pnpm-install": {
|
||||
"directory": "/root/.local/share/pnpm/store/v3",
|
||||
"type": "shared"
|
||||
}
|
||||
},
|
||||
"deploy": {
|
||||
"base": {
|
||||
"image": "ghcr.io/railwayapp/railpack-runtime:latest"
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"include": [
|
||||
"/mise/shims",
|
||||
"/mise/installs",
|
||||
"/usr/local/bin/mise",
|
||||
"/etc/mise/config.toml",
|
||||
"/root/.local/state/mise"
|
||||
],
|
||||
"step": "packages:mise"
|
||||
},
|
||||
{
|
||||
"include": [
|
||||
"/app/node_modules"
|
||||
],
|
||||
"step": "build"
|
||||
},
|
||||
{
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
".yarn"
|
||||
],
|
||||
"include": [
|
||||
"/root/.cache",
|
||||
"."
|
||||
],
|
||||
"step": "build"
|
||||
}
|
||||
],
|
||||
"startCommand": "pnpm run start",
|
||||
"variables": {
|
||||
"CI": "true",
|
||||
"NODE_ENV": "production",
|
||||
"NPM_CONFIG_FUND": "false",
|
||||
"NPM_CONFIG_PRODUCTION": "false",
|
||||
"NPM_CONFIG_UPDATE_NOTIFIER": "false"
|
||||
}
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"assets": {
|
||||
"mise.toml": "[mise.toml]"
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"path": "/mise/shims"
|
||||
},
|
||||
{
|
||||
"customName": "create mise config",
|
||||
"name": "mise.toml",
|
||||
"path": "/etc/mise/config.toml"
|
||||
},
|
||||
{
|
||||
"cmd": "sh -c 'mise trust -a \u0026\u0026 mise install'",
|
||||
"customName": "install mise packages: node, pnpm"
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"image": "ghcr.io/railwayapp/railpack-builder:latest"
|
||||
}
|
||||
],
|
||||
"name": "packages:mise",
|
||||
"variables": {
|
||||
"MISE_CACHE_DIR": "/mise/cache",
|
||||
"MISE_CONFIG_DIR": "/mise",
|
||||
"MISE_DATA_DIR": "/mise",
|
||||
"MISE_INSTALLS_DIR": "/mise/installs",
|
||||
"MISE_NODE_VERIFY": "false",
|
||||
"MISE_SHIMS_DIR": "/mise/shims"
|
||||
}
|
||||
},
|
||||
{
|
||||
"caches": [
|
||||
"pnpm-install"
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"path": "/app/node_modules/.bin"
|
||||
},
|
||||
{
|
||||
"cmd": "mkdir -p /app/node_modules/.cache"
|
||||
},
|
||||
{
|
||||
"dest": "package.json",
|
||||
"src": "package.json"
|
||||
},
|
||||
{
|
||||
"cmd": "pnpm install"
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"step": "packages:mise"
|
||||
}
|
||||
],
|
||||
"name": "install",
|
||||
"variables": {
|
||||
"CI": "true",
|
||||
"NODE_ENV": "production",
|
||||
"NPM_CONFIG_FUND": "false",
|
||||
"NPM_CONFIG_PRODUCTION": "false",
|
||||
"NPM_CONFIG_UPDATE_NOTIFIER": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"caches": [
|
||||
"node-modules"
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"step": "install"
|
||||
},
|
||||
{
|
||||
"include": [
|
||||
"."
|
||||
],
|
||||
"local": true
|
||||
}
|
||||
],
|
||||
"name": "build",
|
||||
"secrets": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -260,6 +260,8 @@ func (p *NodeProvider) InstallNodeDeps(ctx *generate.GenerateContext, install *g
|
||||
|
||||
install.AddCommands([]plan.Command{
|
||||
plan.NewCopyCommand("package.json"),
|
||||
// corepack will detect the package manager version from package.json, safe to assume the user is properly
|
||||
// specifying the version they want there, no need to check other version specifications.
|
||||
plan.NewExecShellCommand("npm i -g corepack@latest && corepack enable && corepack prepare --activate"),
|
||||
})
|
||||
}
|
||||
@@ -313,6 +315,11 @@ func (p *NodeProvider) InstallMisePackages(ctx *generate.GenerateContext, miseSt
|
||||
miseStep.Version(bun, string(bunVersionFile), ".bun-version")
|
||||
}
|
||||
|
||||
if bunVersionFile, err := ctx.App.ReadFile(".bun-version"); err == nil {
|
||||
miseStep.Version(bun, string(bunVersionFile), ".bun-version")
|
||||
}
|
||||
|
||||
// TODO why don't we install this via mise?
|
||||
// If we don't need node in the final image, we still want to include it for the install steps
|
||||
// since many packages need node-gyp to install native modules
|
||||
// in this case, we don't need a specific version, so we'll just pull from apt
|
||||
@@ -352,6 +359,7 @@ func (p *NodeProvider) hasDependency(dependency string) bool {
|
||||
return p.packageJson.hasDependency(dependency)
|
||||
}
|
||||
|
||||
// if packageManager config exists in package.json, then assume corepack
|
||||
func (p *NodeProvider) usesCorepack() bool {
|
||||
return p.packageJson != nil && p.packageJson.PackageManager != nil && p.packageManager != PackageManagerBun
|
||||
}
|
||||
@@ -361,8 +369,6 @@ func (p *NodeProvider) usesPuppeteer() bool {
|
||||
}
|
||||
|
||||
func (p *NodeProvider) getPackageManager(app *app.App) PackageManager {
|
||||
packageManager := PackageManagerNpm
|
||||
|
||||
// Check packageManager field first
|
||||
if packageJson, err := p.GetPackageJson(app); err == nil && packageJson.PackageManager != nil {
|
||||
pmName, pmVersion := packageJson.GetPackageManagerInfo()
|
||||
@@ -382,16 +388,34 @@ func (p *NodeProvider) getPackageManager(app *app.App) PackageManager {
|
||||
|
||||
// Fall back to file-based detection
|
||||
if app.HasMatch("pnpm-lock.yaml") {
|
||||
packageManager = PackageManagerPnpm
|
||||
return PackageManagerPnpm
|
||||
} else if app.HasMatch("bun.lockb") || app.HasMatch("bun.lock") {
|
||||
packageManager = PackageManagerBun
|
||||
return PackageManagerBun
|
||||
} else if app.HasMatch(".yarnrc.yml") || app.HasMatch(".yarnrc.yaml") {
|
||||
packageManager = PackageManagerYarnBerry
|
||||
return PackageManagerYarnBerry
|
||||
} else if app.HasMatch("yarn.lock") {
|
||||
packageManager = PackageManagerYarn1
|
||||
return PackageManagerYarn1
|
||||
}
|
||||
|
||||
return packageManager
|
||||
// Finally, consider engines as a last-resort
|
||||
if packageJson, err := p.GetPackageJson(app); err == nil && packageJson.Engines != nil {
|
||||
if engine := strings.TrimSpace(packageJson.Engines["pnpm"]); engine != "" {
|
||||
return PackageManagerPnpm
|
||||
}
|
||||
if engine := strings.TrimSpace(packageJson.Engines["bun"]); engine != "" {
|
||||
return PackageManagerBun
|
||||
}
|
||||
if engine := strings.TrimSpace(packageJson.Engines["yarn"]); engine != "" {
|
||||
// Decide yarn major: 1 -> yarn1, otherwise default to berry
|
||||
major := strings.Split(engine, ".")[0]
|
||||
if major == "1" {
|
||||
return PackageManagerYarn1
|
||||
}
|
||||
return PackageManagerYarnBerry
|
||||
}
|
||||
}
|
||||
|
||||
return PackageManagerNpm
|
||||
}
|
||||
|
||||
func (p *NodeProvider) GetPackageJson(app *app.App) (*PackageJson, error) {
|
||||
|
||||
@@ -72,6 +72,9 @@ func (p *PackageJson) hasLocalDependency() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// parse a packageManager string in the format "name@version" or "name@version+extra".
|
||||
// Returns the package manager name and version as separate strings.
|
||||
// returns empty strings for both name and version if it can't be parsed
|
||||
func (p *PackageJson) GetPackageManagerInfo() (string, string) {
|
||||
if p == nil || p.PackageManager == nil {
|
||||
return "", ""
|
||||
|
||||
@@ -266,6 +266,11 @@ func (p PackageManager) GetPackageManagerPackages(ctx *generate.GenerateContext,
|
||||
if p == PackageManagerPnpm {
|
||||
pnpm := packages.Default("pnpm", DEFAULT_PNPM_VERSION)
|
||||
|
||||
// Prefer explicit version from package.json engines over defaults/lockfile
|
||||
if packageJson != nil && packageJson.Engines != nil && packageJson.Engines["pnpm"] != "" {
|
||||
packages.Version(pnpm, packageJson.Engines["pnpm"], "package.json > engines > pnpm")
|
||||
}
|
||||
|
||||
lockfile, err := ctx.App.ReadFile("pnpm-lock.yaml")
|
||||
if err == nil {
|
||||
if strings.HasPrefix(lockfile, "lockfileVersion: 5.3") {
|
||||
@@ -280,25 +285,37 @@ func (p PackageManager) GetPackageManagerPackages(ctx *generate.GenerateContext,
|
||||
if pmName == "pnpm" && pmVersion != "" {
|
||||
packages.Version(pnpm, pmVersion, "package.json > packageManager")
|
||||
|
||||
// We want to skip installing with Mise and just install with corepack instead
|
||||
// skip installing via Mise and install with corepack instead
|
||||
// https://github.com/railwayapp/railpack/issues/201
|
||||
packages.SkipMiseInstall(pnpm)
|
||||
}
|
||||
}
|
||||
|
||||
// Yarn
|
||||
if p == PackageManagerYarn1 || p == PackageManagerYarnBerry {
|
||||
var defaultMajor string
|
||||
if p == PackageManagerYarn1 {
|
||||
packages.Default("yarn", "1")
|
||||
defaultMajor = "1"
|
||||
packages.AddSupportingAptPackage("tar")
|
||||
packages.AddSupportingAptPackage("gpg")
|
||||
} else {
|
||||
packages.Default("yarn", "2")
|
||||
defaultMajor = "2"
|
||||
}
|
||||
yarn := packages.Default("yarn", defaultMajor)
|
||||
|
||||
// Prefer explicit version from package.json engines over defaults
|
||||
if packageJson != nil && packageJson.Engines != nil && packageJson.Engines["yarn"] != "" {
|
||||
packages.Version(yarn, packageJson.Engines["yarn"], "package.json > engines > yarn")
|
||||
}
|
||||
|
||||
// TODO we should use SemVer at this point
|
||||
if pmName == "yarn" && pmVersion != "" {
|
||||
majorVersion := strings.Split(pmVersion, ".")[0]
|
||||
yarn := packages.Default("yarn", majorVersion)
|
||||
packages.Version(yarn, pmVersion, "package.json > packageManager")
|
||||
|
||||
// skip installing via Mise and install with corepack instead
|
||||
// https://github.com/railwayapp/railpack/issues/201
|
||||
packages.SkipMiseInstall(yarn)
|
||||
}
|
||||
}
|
||||
@@ -307,6 +324,11 @@ func (p PackageManager) GetPackageManagerPackages(ctx *generate.GenerateContext,
|
||||
if p == PackageManagerBun {
|
||||
bun := packages.Default("bun", "latest")
|
||||
|
||||
// Prefer explicit version from package.json engines over defaults
|
||||
if packageJson != nil && packageJson.Engines != nil && packageJson.Engines["bun"] != "" {
|
||||
packages.Version(bun, packageJson.Engines["bun"], "package.json > engines > bun")
|
||||
}
|
||||
|
||||
if pmName == "bun" && pmVersion != "" {
|
||||
packages.Version(bun, pmVersion, "package.json > packageManager")
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ The Node.js version is determined in the following order:
|
||||
The Bun version is determined in the following order:
|
||||
|
||||
- Set via the `RAILPACK_BUN_VERSION` environment variable
|
||||
- Read from the `.bun-version` file
|
||||
- Read from the `engines.bun` field in `package.json`
|
||||
- Defaults to `latest`
|
||||
|
||||
If Bun is used as the package manager, Node.js will still be installed in the
|
||||
@@ -86,16 +88,23 @@ Railpack determines the start command in the following order:
|
||||
Railpack detects your package manager in the following order:
|
||||
|
||||
1. **packageManager field**: Reads the `packageManager` field from
|
||||
`package.json`
|
||||
`package.json` (uses Corepack to install the specified version)
|
||||
2. **Lock files**: Falls back to detecting based on lock files:
|
||||
- `pnpm-lock.yaml` for pnpm
|
||||
- `bun.lockb` or `bun.lock` for Bun
|
||||
- `.yarnrc.yml` or `.yarnrc.yaml` for Yarn Berry (2+)
|
||||
- `yarn.lock` for Yarn 1
|
||||
- Defaults to npm if no lock file is found
|
||||
3. **engines field**: As a fallback, checks the `engines` field in
|
||||
`package.json` for package manager versions:
|
||||
- `engines.pnpm` for pnpm version
|
||||
- `engines.bun` for Bun version
|
||||
- `engines.yarn` for Yarn version
|
||||
- Defaults to npm if no package manager is detected
|
||||
|
||||
When the `packageManager` field is present, Railpack will use Corepack to
|
||||
install the specified package manager version.
|
||||
install the specified package manager version. When a package manager is
|
||||
detected via the `engines` field, the specified version constraint will be
|
||||
used.
|
||||
|
||||
### Install
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
console.log("hi from bun without a lock")
|
||||
const bunVersion = Bun.version.split('.')[0];
|
||||
console.log(`hi from bun without a lock - using Bun major version ${bunVersion}`)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[
|
||||
{
|
||||
"expectedOutput": "hi from bun without a lock"
|
||||
"expectedOutput": "hi from bun without a lock - using Bun major version 1"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
function getPnpmVersion() {
|
||||
const ua = process.env.npm_config_user_agent || "";
|
||||
return /\bpnpm\/([^\s]+)/.exec(ua)?.[1] ?? null; // e.g. "8.15.6"
|
||||
}
|
||||
|
||||
// Get Node.js version
|
||||
const nodeVersion = process.version;
|
||||
console.log(`Node.js version: ${nodeVersion}`);
|
||||
|
||||
// Get PNPM version from user agent
|
||||
const pnpmVersionFromUA = getPnpmVersion();
|
||||
if (pnpmVersionFromUA) {
|
||||
console.log(`PNPM version (from user agent): v${pnpmVersionFromUA}`);
|
||||
} else {
|
||||
console.log('PNPM version (from user agent): not detected');
|
||||
}
|
||||
|
||||
// Get PNPM version from CLI (fallback)
|
||||
try {
|
||||
const pnpmVersion = execSync('pnpm --version', { encoding: 'utf8' }).trim();
|
||||
console.log(`PNPM version (from CLI): v${pnpmVersion}`);
|
||||
} catch (error) {
|
||||
console.log('PNPM version (from CLI): not available');
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "node-pnpm-engines",
|
||||
"version": "1.0.0",
|
||||
"description": "Example testing engines specification for Node.js and PNPM",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "node main.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "22.19.0",
|
||||
"pnpm": "8.15.9"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"expectedOutput": [
|
||||
"Node.js version: v22.19.0",
|
||||
"PNPM version (from user agent): v8.15.9",
|
||||
"PNPM version (from CLI): v8.15.9"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -115,7 +115,12 @@ func ExtractSemverVersion(version string) string {
|
||||
|
||||
// ParseSemver parses a semantic version string and returns a Semver struct.
|
||||
func ParseSemver(version string) (*Semver, error) {
|
||||
// Remove potential prefixes like "v" or "ruby-"
|
||||
// Handle corepack version format like "[email protected]"
|
||||
if idx := strings.Index(version, "@"); idx != -1 {
|
||||
version = version[idx+1:]
|
||||
}
|
||||
|
||||
// Remove potential prefixes like "v"
|
||||
version = strings.TrimPrefix(version, "v")
|
||||
|
||||
// Split by dots
|
||||
|
||||
@@ -78,6 +78,16 @@ func TestParseSemver(t *testing.T) {
|
||||
wantErr: true,
|
||||
errorPrefix: "invalid major version",
|
||||
},
|
||||
{
|
||||
name: "corepack version",
|
||||
version: "[email protected]",
|
||||
want: &Semver{
|
||||
Major: 8,
|
||||
Minor: 15,
|
||||
Patch: 4,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
Reference in New Issue
Block a user