44//! and version cleanup.
55
66use 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
1314use flate2:: read:: GzDecoder ;
1415use tar:: Archive ;
16+ use vite_install:: { PackageManagerType , download_package_manager} ;
17+ use vite_js_runtime:: { JsRuntimeType , NodeProvider , download_runtime} ;
1518use vite_path:: { AbsolutePath , AbsolutePathBuf } ;
1619
1720use 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.
245248pub 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\n printf '%s\\ n' \" $@\" > invocation.txt\n printf '%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!( "{}\n install\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