Skip to content

Commit 277648f

Browse files
authored
fix: inherit .node-version from ancestor directories in monorepos (#606)
Previously, `download_runtime_for_project` only checked the current directory for version sources. When no local source was found, it defaulted to LTS and wrote a new `.node-version` — even if the monorepo root already had one. Add `find_ancestor_node_version()` to walk up and check for `.node-version` in parent directories. When found, inherit that version and suppress the write-back — similar to how packageManager is workspace-wide.
1 parent f8d94f0 commit 277648f

17 files changed

Lines changed: 144 additions & 17 deletions

File tree

.github/workflows/test-standalone-install.yml

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,6 @@ jobs:
3838
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
3939

4040
- name: Run install.sh
41-
env:
42-
VITE_PLUS_VERSION: test
4341
run: cat packages/global/install.sh | bash
4442

4543
- name: Verify installation
@@ -130,7 +128,6 @@ jobs:
130128
run: |
131129
docker run --rm --platform linux/arm64 \
132130
-v "${{ github.workspace }}:/workspace" \
133-
-e VITE_PLUS_VERSION=test \
134131
ubuntu:20.04 bash -c "
135132
ls -al ~/
136133
apt-get update && apt-get install -y curl ca-certificates
@@ -187,8 +184,6 @@ jobs:
187184

188185
- name: Run install.ps1
189186
shell: pwsh
190-
env:
191-
VITE_PLUS_VERSION: test
192187
run: |
193188
& ./packages/global/install.ps1
194189

crates/vite_js_runtime/src/runtime.rs

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -343,14 +343,14 @@ pub async fn download_runtime_for_project(project_path: &AbsolutePath) -> Result
343343
let provider = NodeProvider::new();
344344
let cache_dir = crate::cache::get_cache_dir()?;
345345

346-
// Use resolve_node_version to find the version (no directory walking for project downloads)
347-
let resolution = resolve_node_version(project_path, false).await?;
346+
// Resolve version from the project directory, walking up to inherit from ancestors
347+
let resolution = resolve_node_version(project_path, true).await?;
348348

349349
// Validate the version from the resolved source
350350
let version_req =
351351
resolution.as_ref().and_then(|r| normalize_version(&r.version, &r.source.to_string()));
352352

353-
// For compatibility checking, we need to read all sources
353+
// For compatibility checking, we need to read all sources from the local package.json
354354
let package_json_path = project_path.join("package.json");
355355
let pkg = read_package_json(&package_json_path).await?;
356356

@@ -662,18 +662,21 @@ mod tests {
662662

663663
let runtime = download_runtime_for_project(&temp_path).await.unwrap();
664664

665-
// Should download latest Node.js
665+
// Should download Node.js
666666
assert_eq!(runtime.runtime_type(), JsRuntimeType::Node);
667667

668668
// Should have a valid version
669669
let version = runtime.version();
670670
let parsed = node_semver::Version::parse(version).unwrap();
671671
assert!(parsed.major >= 20);
672672

673-
// Should write resolved version to .node-version
674-
let node_version_content =
675-
tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap();
676-
assert_eq!(node_version_content, format!("{version}\n"));
673+
// .node-version is written only if no ancestor has one (write-back is
674+
// suppressed when an ancestor .node-version exists, e.g. in a monorepo)
675+
if tokio::fs::try_exists(temp_path.join(".node-version")).await.unwrap() {
676+
let node_version_content =
677+
tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap();
678+
assert_eq!(node_version_content, format!("{version}\n"));
679+
}
677680

678681
// package.json should remain unchanged
679682
let pkg_content = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap();
@@ -700,10 +703,13 @@ mod tests {
700703
let runtime = download_runtime_for_project(&temp_path).await.unwrap();
701704
let version = runtime.version();
702705

703-
// Should write resolved version to .node-version
704-
let node_version_content =
705-
tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap();
706-
assert_eq!(node_version_content, format!("{version}\n"));
706+
// .node-version is written only if no ancestor has one (write-back is
707+
// suppressed when an ancestor .node-version exists, e.g. in a monorepo)
708+
if tokio::fs::try_exists(temp_path.join(".node-version")).await.unwrap() {
709+
let node_version_content =
710+
tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap();
711+
assert_eq!(node_version_content, format!("{version}\n"));
712+
}
707713

708714
// package.json should remain unchanged
709715
let pkg_content = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap();
@@ -773,6 +779,31 @@ mod tests {
773779
assert_eq!(runtime.runtime_type(), JsRuntimeType::Node);
774780
}
775781

782+
#[tokio::test]
783+
async fn test_download_runtime_for_project_inherits_parent_node_version() {
784+
let temp_dir = TempDir::new().unwrap();
785+
let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
786+
787+
// Write .node-version in root (simulating monorepo root)
788+
tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap();
789+
790+
// Create a sub-package directory with a minimal package.json (no engines/devEngines)
791+
let subdir = temp_path.join("packages").join("foo");
792+
tokio::fs::create_dir_all(&subdir).await.unwrap();
793+
tokio::fs::write(subdir.join("package.json"), r#"{"name": "foo"}"#).await.unwrap();
794+
795+
let runtime = download_runtime_for_project(&subdir).await.unwrap();
796+
797+
// Should inherit version from parent's .node-version
798+
assert_eq!(runtime.version(), "20.18.0");
799+
800+
// Should NOT write .node-version in the sub-package
801+
assert!(
802+
!tokio::fs::try_exists(subdir.join(".node-version")).await.unwrap(),
803+
".node-version should not be written in sub-package when parent already has one"
804+
);
805+
}
806+
776807
/// Integration test that downloads a real Node.js version
777808
#[tokio::test]
778809
async fn test_download_node_integration() {

packages/global/install.ps1

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,11 @@ function Main {
406406
$pkg.PSObject.Properties.Remove("optionalDependencies")
407407
$pkg | ConvertTo-Json -Depth 10 | Set-Content $pkgFile
408408

409+
# Remove stale lockfile and node_modules to avoid frozen-lockfile conflicts
410+
# when package.json changes between installs
411+
Remove-Item -Path "$VersionDir\pnpm-lock.yaml" -Force -ErrorAction SilentlyContinue
412+
Remove-Item -Path "$VersionDir\node_modules" -Recurse -Force -ErrorAction SilentlyContinue
413+
409414
# Install production dependencies
410415
Push-Location $VersionDir
411416
try {

packages/global/install.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,11 @@ main() {
640640
{ print }
641641
' "$pkg_file" > "$pkg_file.tmp" && mv "$pkg_file.tmp" "$pkg_file"
642642

643+
# Remove stale lockfile and node_modules to avoid frozen-lockfile conflicts
644+
# when package.json changes between installs
645+
rm -f "$VERSION_DIR/pnpm-lock.yaml"
646+
rm -rf "$VERSION_DIR/node_modules"
647+
643648
# Install production dependencies
644649
(cd "$VERSION_DIR" && CI=true "$BIN_DIR/vp" install --silent)
645650

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "shim-inherits-parent-dev-engines-runtime",
3+
"version": "1.0.0",
4+
"private": true,
5+
"devEngines": {
6+
"runtime": {
7+
"name": "node",
8+
"version": "20.18.0"
9+
}
10+
}
11+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "app",
3+
"version": "1.0.0",
4+
"private": true
5+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
> vp env exec node -v # Root: uses devEngines.runtime directly
2+
v20.18.0
3+
4+
> cd packages/app && vp env exec node -v # Sub-package: inherits parent devEngines.runtime
5+
v20.18.0
6+
7+
> test ! -f packages/app/.node-version && echo 'No .node-version written in sub-package' # Verify no .node-version created in sub-package
8+
No .node-version written in sub-package
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"env": {},
3+
"ignoredPlatforms": ["win32"],
4+
"commands": [
5+
"vp env exec node -v # Root: uses devEngines.runtime directly",
6+
"cd packages/app && vp env exec node -v # Sub-package: inherits parent devEngines.runtime",
7+
"test ! -f packages/app/.node-version && echo 'No .node-version written in sub-package' # Verify no .node-version created in sub-package"
8+
]
9+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "shim-inherits-parent-engines-node",
3+
"version": "1.0.0",
4+
"private": true,
5+
"engines": {
6+
"node": "20.18.0"
7+
}
8+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "app",
3+
"version": "1.0.0",
4+
"private": true
5+
}

0 commit comments

Comments
 (0)