Skip to content

Commit feefa31

Browse files
kazuponfengmk2
andauthored
fix(upgrade): bypass package manager release age gates during vp upgrade (#1272)
## Summary resolves #1260 `vp upgrade` fails when a package manager's global config enforces a minimum release age (e.g. pnpm's `minimumReleaseAge`), because newly published vite-plus versions are blocked during dependency installation. ## Changes - Write local config overrides to the version directory before `vp install`, bypassing release age gates for all package managers: - `.npmrc` — pnpm (`minimum-release-age=0`) and npm (`min-release-age=0`) - `.yarnrc.yml` — yarn (`npmMinimalAgeGate: "0m"`) - `bunfig.toml` — bun (`minimumReleaseAge = 0`) - Write `upgrade.log` on install failure with stdout+stderr for debugging --------- Co-authored-by: MK (fengmk2) <fengmk2@gmail.com>
1 parent 1fe4cff commit feefa31

File tree

4 files changed

+136
-15
lines changed

4 files changed

+136
-15
lines changed

crates/vite_global_cli/src/commands/upgrade/install.rs

Lines changed: 119 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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`.
9499
pub 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.
114155
pub 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
}

crates/vite_global_cli/src/commands/upgrade/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,11 @@ async fn install_platform_and_main(
165165
// Generate wrapper package.json that declares vite-plus as a dependency
166166
install::generate_wrapper_package_json(version_dir, new_version).await?;
167167

168+
// Isolate from user's global package manager config that may block
169+
// installing recently-published packages (e.g. pnpm's minimumReleaseAge,
170+
// yarn's npmMinimalAgeGate, bun's minimumReleaseAge)
171+
install::write_release_age_overrides(version_dir).await?;
172+
168173
// Install production dependencies (npm installs vite-plus + all transitive deps)
169174
install::install_production_deps(version_dir, registry).await?;
170175

packages/cli/install.ps1

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -351,21 +351,22 @@ function Main {
351351
}
352352

353353
# Generate wrapper package.json that declares vite-plus as a dependency.
354-
# npm will install vite-plus and all transitive deps via `vp install`.
354+
# pnpm will install vite-plus and all transitive deps via `vp install`.
355+
# The packageManager field pins pnpm to a known-good version.
355356
$wrapperJson = @{
356357
name = "vp-global"
357358
version = $ViteVersion
358359
private = $true
360+
packageManager = "pnpm@10.33.0"
359361
dependencies = @{
360362
"vite-plus" = $ViteVersion
361363
}
362364
} | ConvertTo-Json -Depth 10
363365
Set-Content -Path (Join-Path $VersionDir "package.json") -Value $wrapperJson
364366

365-
# Isolate from user's global package manager config that may block
366-
# installing recently-published packages (e.g. pnpm's minimumReleaseAge,
367-
# npm's min-release-age) by creating a local .npmrc in the version directory.
368-
Set-Content -Path (Join-Path $VersionDir ".npmrc") -Value "minimum-release-age=0`nmin-release-age=0"
367+
# Isolate from pnpm's global config that may block installing
368+
# recently-published packages (e.g. minimumReleaseAge).
369+
Set-Content -Path (Join-Path $VersionDir ".npmrc") -Value "minimum-release-age=0"
369370

370371
# Install production dependencies (skip if VP_SKIP_DEPS_INSTALL is set,
371372
# e.g. during local dev where install-global-cli.ts handles deps separately)

packages/cli/install.sh

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -625,24 +625,25 @@ main() {
625625
fi
626626

627627
# Generate wrapper package.json that declares vite-plus as a dependency.
628-
# npm will install vite-plus and all transitive deps via `vp install`.
628+
# pnpm will install vite-plus and all transitive deps via `vp install`.
629+
# The packageManager field pins pnpm to a known-good version, ensuring
630+
# consistent behavior regardless of the user's global pnpm version.
629631
cat > "$VERSION_DIR/package.json" <<WRAPPER_EOF
630632
{
631633
"name": "vp-global",
632634
"version": "$VP_VERSION",
633635
"private": true,
636+
"packageManager": "pnpm@10.33.0",
634637
"dependencies": {
635638
"vite-plus": "$VP_VERSION"
636639
}
637640
}
638641
WRAPPER_EOF
639642

640-
# Isolate from user's global package manager config that may block
641-
# installing recently-published packages (e.g. pnpm's minimumReleaseAge,
642-
# npm's min-release-age) by creating a local .npmrc in the version directory.
643+
# Isolate from pnpm's global config that may block installing
644+
# recently-published packages (e.g. minimumReleaseAge).
643645
cat > "$VERSION_DIR/.npmrc" <<NPMRC_EOF
644646
minimum-release-age=0
645-
min-release-age=0
646647
NPMRC_EOF
647648

648649
# Install production dependencies (skip if VP_SKIP_DEPS_INSTALL is set,

0 commit comments

Comments
 (0)