@@ -87,10 +87,15 @@ pub async fn extract_platform_package(
8787 Ok ( ( ) )
8888}
8989
90+ /// The pnpm version pinned in the wrapper package.json for global installs.
91+ /// This ensures consistent install behavior regardless of the user's global pnpm version.
92+ const PINNED_PNPM_VERSION : & str = "pnpm@10.33.0" ;
93+
9094/// Generate a wrapper `package.json` that declares `vite-plus` as a dependency.
9195///
92- /// This replaces the old approach of extracting the main package tarball.
93- /// npm will install `vite-plus` and all its transitive deps via `vp install`.
96+ /// The `packageManager` field pins pnpm to a known-good version, ensuring
97+ /// consistent behavior regardless of the user's global pnpm version.
98+ /// pnpm will install `vite-plus` and all its transitive deps via `vp install`.
9499pub async fn generate_wrapper_package_json (
95100 version_dir : & AbsolutePath ,
96101 version : & str ,
@@ -99,6 +104,7 @@ pub async fn generate_wrapper_package_json(
99104 "name" : "vp-global" ,
100105 "version" : version,
101106 "private" : true ,
107+ "packageManager" : PINNED_PNPM_VERSION ,
102108 "dependencies" : {
103109 "vite-plus" : version
104110 }
@@ -108,9 +114,44 @@ pub async fn generate_wrapper_package_json(
108114 Ok ( ( ) )
109115}
110116
117+ /// Create a local `.npmrc` in the version directory to bypass pnpm's
118+ /// `minimumReleaseAge` setting that may block installing recently-published packages.
119+ pub async fn write_release_age_overrides ( version_dir : & AbsolutePath ) -> Result < ( ) , Error > {
120+ let npmrc_path = version_dir. join ( ".npmrc" ) ;
121+ tokio:: fs:: write ( & npmrc_path, "minimum-release-age=0\n " ) . await ?;
122+ Ok ( ( ) )
123+ }
124+
125+ /// Write stdout and stderr from a failed install to `upgrade.log`.
126+ ///
127+ /// The log is written to the **parent** of `version_dir` (i.e. `~/.vite-plus/upgrade.log`)
128+ /// so it survives the cleanup that removes `version_dir` on failure.
129+ ///
130+ /// Returns the log file path on success, or `None` if writing failed.
131+ pub async fn write_upgrade_log (
132+ version_dir : & AbsolutePath ,
133+ stdout : & [ u8 ] ,
134+ stderr : & [ u8 ] ,
135+ ) -> Option < AbsolutePathBuf > {
136+ // Write to parent dir so the log survives version_dir cleanup on failure
137+ let parent = version_dir. as_path ( ) . parent ( ) ?;
138+ let log_path = AbsolutePathBuf :: new ( parent. join ( "upgrade.log" ) ) ?;
139+ let stdout_str = String :: from_utf8_lossy ( stdout) ;
140+ let stderr_str = String :: from_utf8_lossy ( stderr) ;
141+ let content = format ! ( "=== stdout ===\n {stdout_str}\n === stderr ===\n {stderr_str}" ) ;
142+ match tokio:: fs:: write ( & log_path, & content) . await {
143+ Ok ( ( ) ) => Some ( log_path) ,
144+ Err ( e) => {
145+ tracing:: warn!( "Failed to write upgrade log: {}" , e) ;
146+ None
147+ }
148+ }
149+ }
150+
111151/// Install production dependencies using the new version's binary.
112152///
113153/// Spawns: `{version_dir}/bin/vp install --silent [--registry <url>]` with `CI=true`.
154+ /// On failure, writes stdout+stderr to `{version_dir}/upgrade.log` for debugging.
114155pub async fn install_production_deps (
115156 version_dir : & AbsolutePath ,
116157 registry : Option < & str > ,
@@ -140,12 +181,16 @@ pub async fn install_production_deps(
140181 . await ?;
141182
142183 if !output. status . success ( ) {
143- let stderr = String :: from_utf8_lossy ( & output. stderr ) ;
184+ let log_path = write_upgrade_log ( version_dir, & output. stdout , & output. stderr ) . await ;
185+ let log_msg = log_path. map_or_else (
186+ || String :: new ( ) ,
187+ |p| format ! ( ". See log for details: {}" , p. as_path( ) . display( ) ) ,
188+ ) ;
144189 return Err ( Error :: Upgrade (
145190 format ! (
146- "Failed to install production dependencies (exit code: {})\n {}" ,
191+ "Failed to install production dependencies (exit code: {}){}" ,
147192 output. status. code( ) . unwrap_or( -1 ) ,
148- stderr . trim ( )
193+ log_msg
149194 )
150195 . into ( ) ,
151196 ) ) ;
@@ -431,4 +476,73 @@ mod tests {
431476 let result = cleanup_old_versions ( & non_existent, 5 , & [ ] ) . await ;
432477 assert ! ( result. is_err( ) , "cleanup_old_versions should error on non-existent dir" ) ;
433478 }
479+
480+ #[ tokio:: test]
481+ async fn test_write_upgrade_log_creates_log_in_parent_dir ( ) {
482+ let temp = tempfile:: tempdir ( ) . unwrap ( ) ;
483+ // Simulate ~/.vite-plus/0.1.15/ structure
484+ let version_dir = AbsolutePathBuf :: new ( temp. path ( ) . join ( "0.1.15" ) . to_path_buf ( ) ) . unwrap ( ) ;
485+ tokio:: fs:: create_dir ( & version_dir) . await . unwrap ( ) ;
486+
487+ let stdout = b"some stdout output" ;
488+ let stderr = b"error: something went wrong" ;
489+
490+ let result = write_upgrade_log ( & version_dir, stdout, stderr) . await ;
491+ assert ! ( result. is_some( ) , "write_upgrade_log should return log path" ) ;
492+
493+ let log_path = result. unwrap ( ) ;
494+ // Log should be in parent dir, not version_dir
495+ assert_eq ! (
496+ log_path. as_path( ) . parent( ) . unwrap( ) ,
497+ temp. path( ) ,
498+ "upgrade.log should be in parent dir"
499+ ) ;
500+ assert ! ( log_path. as_path( ) . exists( ) , "upgrade.log should exist" ) ;
501+
502+ let content = tokio:: fs:: read_to_string ( & log_path) . await . unwrap ( ) ;
503+ assert ! ( content. contains( "=== stdout ===" ) , "log should have stdout section" ) ;
504+ assert ! ( content. contains( "some stdout output" ) , "log should contain stdout" ) ;
505+ assert ! ( content. contains( "=== stderr ===" ) , "log should have stderr section" ) ;
506+ assert ! ( content. contains( "error: something went wrong" ) , "log should contain stderr" ) ;
507+
508+ // Log should survive version_dir removal
509+ tokio:: fs:: remove_dir_all ( & version_dir) . await . unwrap ( ) ;
510+ assert ! ( log_path. as_path( ) . exists( ) , "upgrade.log should survive version_dir cleanup" ) ;
511+ }
512+
513+ #[ tokio:: test]
514+ async fn test_write_upgrade_log_handles_empty_output ( ) {
515+ let temp = tempfile:: tempdir ( ) . unwrap ( ) ;
516+ let version_dir = AbsolutePathBuf :: new ( temp. path ( ) . join ( "0.1.15" ) . to_path_buf ( ) ) . unwrap ( ) ;
517+ tokio:: fs:: create_dir ( & version_dir) . await . unwrap ( ) ;
518+
519+ let result = write_upgrade_log ( & version_dir, b"" , b"" ) . await ;
520+ assert ! ( result. is_some( ) ) ;
521+
522+ let content = tokio:: fs:: read_to_string ( result. unwrap ( ) ) . await . unwrap ( ) ;
523+ assert ! ( content. contains( "=== stdout ===" ) ) ;
524+ assert ! ( content. contains( "=== stderr ===" ) ) ;
525+ }
526+
527+ #[ tokio:: test]
528+ async fn test_write_release_age_overrides_creates_npmrc ( ) {
529+ let temp = tempfile:: tempdir ( ) . unwrap ( ) ;
530+ let version_dir = AbsolutePathBuf :: new ( temp. path ( ) . to_path_buf ( ) ) . unwrap ( ) ;
531+
532+ write_release_age_overrides ( & version_dir) . await . unwrap ( ) ;
533+
534+ // .npmrc (pnpm only — packageManager pins pnpm)
535+ let npmrc = tokio:: fs:: read_to_string ( version_dir. join ( ".npmrc" ) ) . await . unwrap ( ) ;
536+ assert ! ( npmrc. contains( "minimum-release-age=0" ) , ".npmrc should contain pnpm override" ) ;
537+
538+ // No .yarnrc.yml or bunfig.toml (pnpm only)
539+ assert ! (
540+ !version_dir. join( ".yarnrc.yml" ) . as_path( ) . exists( ) ,
541+ ".yarnrc.yml should not be created"
542+ ) ;
543+ assert ! (
544+ !version_dir. join( "bunfig.toml" ) . as_path( ) . exists( ) ,
545+ "bunfig.toml should not be created"
546+ ) ;
547+ }
434548}
0 commit comments