Skip to content

Commit 48eedad

Browse files
liangmiQwQfengmk2
andauthored
fix(upgrade): run pinned pnpm with managed Node (#1900)
Close #1876 The dependency install spawned by `vp upgrade` currently re-enters `vp install`, which can select a session, project, or system-first Node.js runtime. An incompatible runtime can make pnpm skip optional native binaries and leave the upgraded CLI broken. This PR removes that recursive path. The setup flow resolves and downloads the latest managed Node.js LTS runtime, downloads the pinned pnpm version, and runs `node pnpm.cjs install` directly. This bypasses `VP_NODE_VERSION`, `.session-node-version`, project runtime configuration, and `vp env off` while keeping the install script and upgrade flow on the same managed bootstrap path. 🤖 Generated with Codex --------- Co-authored-by: MK (fengmk2) <fengmk2@gmail.com>
1 parent cadf5f1 commit 48eedad

3 files changed

Lines changed: 90 additions & 19 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/vite_setup/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ thiserror = { workspace = true }
1919
tokio = { workspace = true, features = ["full"] }
2020
tracing = { workspace = true }
2121
vite_install = { workspace = true }
22+
vite_js_runtime = { workspace = true }
2223
vite_path = { workspace = true }
2324
vite_str = { workspace = true }
2425

crates/vite_setup/src/install.rs

Lines changed: 88 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
//! and version cleanup.
55
66
use std::{
7+
env,
78
io::{Cursor, IsTerminal, Read as _, Write as _},
89
path::Path,
910
process::{self, Output},
@@ -12,6 +13,8 @@ use std::{
1213

1314
use flate2::read::GzDecoder;
1415
use tar::Archive;
16+
use vite_install::{PackageManagerType, download_package_manager};
17+
use vite_js_runtime::{JsRuntimeType, NodeProvider, download_runtime};
1518
use vite_path::{AbsolutePath, AbsolutePathBuf};
1619

1720
use crate::error::Error;
@@ -91,7 +94,7 @@ pub async fn extract_platform_package(
9194

9295
/// The pnpm version pinned in the wrapper package.json for global installs.
9396
/// This ensures consistent install behavior regardless of the user's global pnpm version.
94-
const PINNED_PNPM_VERSION: &str = "pnpm@10.33.0";
97+
const PINNED_PNPM_VERSION: &str = "10.33.0";
9598

9699
/// Generate a wrapper `package.json` that declares `vite-plus` as a dependency.
97100
///
@@ -106,7 +109,7 @@ pub async fn generate_wrapper_package_json(
106109
"name": "vp-global",
107110
"version": version,
108111
"private": true,
109-
"packageManager": PINNED_PNPM_VERSION,
112+
"packageManager": format!("pnpm@{PINNED_PNPM_VERSION}"),
110113
"dependencies": {
111114
"vite-plus": version
112115
}
@@ -238,38 +241,50 @@ pub async fn write_upgrade_log(
238241
}
239242
}
240243

241-
/// Install production dependencies using the new version's binary.
244+
/// Install production dependencies with managed Node.js LTS and pinned pnpm.
242245
///
243-
/// Spawns: `{version_dir}/bin/vp install [--registry <url>]` with `CI=true`.
246+
/// Spawns: `node <managed-pnpm>/bin/pnpm.cjs install [--registry <url>]` with `CI=true`.
244247
/// On failure, writes stdout+stderr to `{version_dir}/upgrade.log` for debugging.
245248
pub async fn install_production_deps(
246249
version_dir: &AbsolutePath,
247250
registry: Option<&str>,
248251
silent: bool,
249252
new_version: &str,
250253
) -> Result<(), Error> {
251-
let vp_binary = version_dir.join("bin").join(crate::VP_BINARY_NAME);
252-
253-
if !tokio::fs::try_exists(&vp_binary).await.unwrap_or(false) {
254-
return Err(Error::Setup(
255-
format!("New binary not found at {}", vp_binary.as_path().display()).into(),
256-
));
257-
}
258-
259-
tracing::debug!("Running vp install in {}", version_dir.as_path().display());
254+
tracing::debug!("Running pnpm install in {}", version_dir.as_path().display());
260255

261256
// Do not pass `--silent` to the inner install: pnpm suppresses the
262257
// release-age error body in silent mode, which would leave upgrade.log
263258
// empty and make the release-age gate impossible to detect. This outer
264259
// process captures the output and only surfaces it through the log.
265260
let mut args = vec!["install"];
266261
if let Some(registry_url) = registry {
267-
args.push("--");
268262
args.push("--registry");
269263
args.push(registry_url);
270264
}
271265

272-
let output = run_vp_install(version_dir, &vp_binary, &args).await?;
266+
let node_version = NodeProvider::new().resolve_latest_version().await.map_err(|error| {
267+
Error::Setup(format!("Failed to resolve the latest Node.js LTS version: {error}").into())
268+
})?;
269+
let node_runtime =
270+
download_runtime(JsRuntimeType::Node, &node_version).await.map_err(|error| {
271+
Error::Setup(format!("Failed to install Node.js {node_version}: {error}").into())
272+
})?;
273+
let (pnpm_dir, _, _) =
274+
download_package_manager(PackageManagerType::Pnpm, PINNED_PNPM_VERSION, None)
275+
.await
276+
.map_err(|error| {
277+
Error::Setup(
278+
format!("Failed to install pnpm {PINNED_PNPM_VERSION}: {error}").into(),
279+
)
280+
})?;
281+
let pnpm_entry = pnpm_dir.join("bin").join("pnpm.cjs");
282+
if !tokio::fs::try_exists(&pnpm_entry).await.unwrap_or(false) {
283+
return Err(Error::Setup(
284+
format!("pnpm entry not found at {}", pnpm_entry.as_path().display()).into(),
285+
));
286+
}
287+
let output = run_pnpm_install(version_dir, &node_runtime, &pnpm_entry, &args).await?;
273288

274289
if !output.status.success() {
275290
let log_path = write_upgrade_log(version_dir, &output.stdout, &output.stderr).await;
@@ -301,7 +316,7 @@ pub async fn install_production_deps(
301316
// Only create the local override after explicit consent. This preserves
302317
// minimumReleaseAge protection for the default and non-interactive paths.
303318
write_release_age_overrides(version_dir).await?;
304-
let retry_output = run_vp_install(version_dir, &vp_binary, &args).await?;
319+
let retry_output = run_pnpm_install(version_dir, &node_runtime, &pnpm_entry, &args).await?;
305320
if !retry_output.status.success() {
306321
let retry_log_path =
307322
write_upgrade_log(version_dir, &retry_output.stdout, &retry_output.stderr).await;
@@ -319,15 +334,28 @@ pub async fn install_production_deps(
319334
Ok(())
320335
}
321336

322-
async fn run_vp_install(
337+
async fn run_pnpm_install(
323338
version_dir: &AbsolutePath,
324-
vp_binary: &AbsolutePath,
339+
node_runtime: &vite_js_runtime::JsRuntime,
340+
pnpm_entry: &AbsolutePath,
325341
args: &[&str],
326342
) -> Result<Output, Error> {
327-
let output = tokio::process::Command::new(vp_binary.as_path())
343+
let node_bin = node_runtime.get_bin_prefix();
344+
let pnpm_bin = pnpm_entry.parent().ok_or_else(|| {
345+
Error::Setup(format!("pnpm entry has no parent: {}", pnpm_entry.as_path().display()).into())
346+
})?;
347+
let current_path = env::var_os("PATH").unwrap_or_default();
348+
let mut path_entries = vec![node_bin.as_path().to_path_buf(), pnpm_bin.as_path().to_path_buf()];
349+
path_entries.extend(env::split_paths(&current_path));
350+
let path = env::join_paths(path_entries)
351+
.map_err(|error| Error::Setup(format!("Failed to build PATH for pnpm: {error}").into()))?;
352+
353+
let output = tokio::process::Command::new(node_runtime.get_binary_path().as_path())
354+
.arg(pnpm_entry.as_path())
328355
.args(args)
329356
.current_dir(version_dir)
330357
.env("CI", "true")
358+
.env("PATH", path)
331359
.output()
332360
.await?;
333361

@@ -823,6 +851,47 @@ mod tests {
823851
);
824852
}
825853

854+
#[cfg(unix)]
855+
#[tokio::test]
856+
async fn run_pnpm_install_uses_managed_node_directly() {
857+
use std::os::unix::fs::PermissionsExt;
858+
859+
let temp = tempfile::tempdir().unwrap();
860+
let version_dir = AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap();
861+
let node_bin = version_dir.join("node").join("bin");
862+
let pnpm_bin = version_dir.join("pnpm").join("bin");
863+
tokio::fs::create_dir_all(&node_bin).await.unwrap();
864+
tokio::fs::create_dir_all(&pnpm_bin).await.unwrap();
865+
866+
let node_binary = node_bin.join("node");
867+
tokio::fs::write(
868+
&node_binary,
869+
"#!/bin/sh\nprintf '%s\\n' \"$@\" > invocation.txt\nprintf '%s' \"$PATH\" > path.txt\n",
870+
)
871+
.await
872+
.unwrap();
873+
tokio::fs::set_permissions(&node_binary, std::fs::Permissions::from_mode(0o755))
874+
.await
875+
.unwrap();
876+
let pnpm_entry = pnpm_bin.join("pnpm.cjs");
877+
tokio::fs::write(&pnpm_entry, "").await.unwrap();
878+
let node_runtime =
879+
vite_js_runtime::JsRuntime::from_system(JsRuntimeType::Node, node_binary);
880+
881+
let output =
882+
run_pnpm_install(&version_dir, &node_runtime, &pnpm_entry, &["install"]).await.unwrap();
883+
assert!(output.status.success());
884+
885+
let invocation =
886+
tokio::fs::read_to_string(version_dir.join("invocation.txt")).await.unwrap();
887+
assert_eq!(invocation, format!("{}\ninstall\n", pnpm_entry.as_path().display()));
888+
889+
let path = tokio::fs::read_to_string(version_dir.join("path.txt")).await.unwrap();
890+
let path_entries = env::split_paths(&path).collect::<Vec<_>>();
891+
assert_eq!(path_entries[0], node_bin.as_path());
892+
assert_eq!(path_entries[1], pnpm_bin.as_path());
893+
}
894+
826895
#[test]
827896
fn test_is_release_age_error_detects_pnpm_no_mature_code() {
828897
assert!(is_release_age_error(

0 commit comments

Comments
 (0)