Skip to content

Commit 5ab336f

Browse files
authored
fix: more safer Vite+ global install and vp upgrade (#1338)
## Summary related issues and PRs - #1260 - #1272 - #833 - #834 This updates the release-age handling for the Vite+ global install/upgrade path so we no longer silently bypass package manager protections. Users who configure pnpm `minimumReleaseAge` are explicitly trying to reduce supply-chain risk from newly published compromised packages. Instead of always writing `minimum-release-age=0`, Vite+ now first runs the wrapper install normally. If pnpm blocks the install with a release-age error, Vite+ only writes the local override and retries after an interactive, default-No confirmation. The same behavior is applied to the standalone install scripts, including `install.ps1` and `install.sh`. ## Changes - Remove unconditional `minimum-release-age=0` override from `vp upgrade` and standalone installers. - Detect pnpm release-age failures from `ERR_PNPM_NO_MATURE_MATCHING_VERSION`, `minimumReleaseAge` messages, and guarded `ERR_PNPM_NO_MATCHING_VERSION` cases. - Prompt users with a default-No warning before disabling release-age protection for this Vite+ install only. - Keep non-interactive environments blocked instead of adding a bypass flag or env var. - Preserve `install.log` / `upgrade.log` visibility for failure diagnosis. - Avoid passing `--silent` to the inner captured `vp install`, because pnpm suppresses the release-age error body in silent mode. - Add comments with pnpm source references explaining the release-age detection signals.
1 parent 2da9576 commit 5ab336f

File tree

5 files changed

+477
-47
lines changed

5 files changed

+477
-47
lines changed

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

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,86 @@ jobs:
554554
vp upgrade --rollback
555555
vp --version
556556
557+
test-install-ps1-release-age:
558+
name: Test install.ps1 (minimum-release-age)
559+
runs-on: windows-latest
560+
permissions:
561+
contents: read
562+
steps:
563+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
564+
with:
565+
persist-credentials: false
566+
567+
- name: Verify minimumReleaseAge blocks non-interactive install
568+
shell: powershell
569+
run: |
570+
$ErrorActionPreference = "Stop"
571+
$env:CI = "true"
572+
$env:VP_NODE_MANAGER = "no"
573+
$env:VP_HOME = Join-Path $env:RUNNER_TEMP "vite-plus-release-age"
574+
575+
$npmrc = Join-Path $env:USERPROFILE ".npmrc"
576+
$backup = Join-Path $env:RUNNER_TEMP ".npmrc.backup"
577+
578+
if (Test-Path $npmrc) {
579+
Move-Item -Path $npmrc -Destination $backup -Force
580+
}
581+
582+
try {
583+
Set-Content -Path $npmrc -Value "minimum-release-age=10000000"
584+
585+
$output = & powershell -NoProfile -ExecutionPolicy Bypass -File .\packages\cli\install.ps1 2>&1
586+
$exitCode = $LASTEXITCODE
587+
$text = $output -join "`n"
588+
Write-Host $text
589+
590+
if ($exitCode -eq 0) {
591+
Write-Error "Expected install.ps1 to fail when pnpm minimum-release-age blocks vite-plus"
592+
exit 1
593+
}
594+
595+
if ($text -notmatch "Install blocked by your minimumReleaseAge setting") {
596+
Write-Error "Expected release-age block message in installer output"
597+
exit 1
598+
}
599+
600+
if ($text -notmatch "ERR_PNPM_NO_MATURE_MATCHING_VERSION|minimumReleaseAge") {
601+
Write-Error "Expected pnpm release-age details in installer output"
602+
exit 1
603+
}
604+
605+
$installLog = Get-ChildItem -Path $env:VP_HOME -Recurse -Filter install.log | Select-Object -First 1
606+
if (-not $installLog) {
607+
Write-Error "Expected install.log to be written under VP_HOME"
608+
exit 1
609+
}
610+
611+
$logText = Get-Content -Path $installLog.FullName -Raw
612+
if ($logText -notmatch "ERR_PNPM_NO_MATURE_MATCHING_VERSION|minimumReleaseAge") {
613+
Write-Error "Expected pnpm release-age details in install.log"
614+
exit 1
615+
}
616+
617+
$overrideFiles = Get-ChildItem -Path $env:VP_HOME -Recurse -Force -Filter .npmrc |
618+
Where-Object { $_.FullName -notmatch "\\js_runtime\\" }
619+
if ($overrideFiles) {
620+
Write-Host "Unexpected override files:"
621+
$overrideFiles | ForEach-Object { Write-Host $_.FullName }
622+
Write-Error "Non-interactive install must not write minimum-release-age overrides"
623+
exit 1
624+
}
625+
626+
# The child install.ps1 is expected to fail in this test. Reset the
627+
# native command exit code so the GitHub Actions PowerShell wrapper
628+
# does not fail the step after our assertions pass.
629+
$global:LASTEXITCODE = 0
630+
} finally {
631+
Remove-Item -Path $npmrc -Force -ErrorAction SilentlyContinue
632+
if (Test-Path $backup) {
633+
Move-Item -Path $backup -Destination $npmrc -Force
634+
}
635+
}
636+
557637
test-install-ps1:
558638
name: Test install.ps1 (Windows x64)
559639
runs-on: windows-latest

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

Lines changed: 192 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
//! and version cleanup.
55
66
use std::{
7-
io::{Cursor, Read as _},
7+
io::{Cursor, IsTerminal, Read as _, Write as _},
88
path::Path,
9+
process::Output,
910
};
1011

1112
use flate2::read::GzDecoder;
@@ -122,6 +123,94 @@ pub async fn write_release_age_overrides(version_dir: &AbsolutePath) -> Result<(
122123
Ok(())
123124
}
124125

126+
fn is_affirmative_response(input: &str) -> bool {
127+
matches!(input.trim().to_ascii_lowercase().as_str(), "y" | "yes")
128+
}
129+
130+
fn should_prompt_release_age_override(silent: bool) -> bool {
131+
!silent && std::io::stdin().is_terminal() && std::io::stderr().is_terminal()
132+
}
133+
134+
fn prompt_release_age_override(version: &str) -> bool {
135+
eprintln!();
136+
eprintln!("warn: Your minimumReleaseAge setting prevented installing vite-plus@{version}.");
137+
eprintln!("This setting helps protect against newly published compromised packages.");
138+
eprintln!("Proceeding will disable this protection for this Vite+ install only.");
139+
eprint!("Do you want to proceed? (y/N): ");
140+
if std::io::stderr().flush().is_err() {
141+
return false;
142+
}
143+
144+
let mut input = String::new();
145+
if std::io::stdin().read_line(&mut input).is_err() {
146+
return false;
147+
}
148+
149+
is_affirmative_response(&input)
150+
}
151+
152+
fn is_release_age_error(stdout: &[u8], stderr: &[u8]) -> bool {
153+
let output =
154+
format!("{}\n{}", String::from_utf8_lossy(stdout), String::from_utf8_lossy(stderr));
155+
let lower = output.to_ascii_lowercase();
156+
157+
// This wrapper install path is pinned to pnpm via packageManager, so this
158+
// detection follows pnpm's resolver/reporter output rather than npm/yarn.
159+
//
160+
// pnpm's PnpmError prefixes internal codes with ERR_PNPM_, so
161+
// `NO_MATURE_MATCHING_VERSION` becomes `ERR_PNPM_NO_MATURE_MATCHING_VERSION`
162+
// in CLI output. We still match the unprefixed code as a fallback in case
163+
// future reporter/log output includes the raw internal code.
164+
// https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/core/error/src/index.ts#L18-L20
165+
//
166+
// npm-resolver chooses NO_MATURE_MATCHING_VERSION when
167+
// publishedBy/minimumReleaseAge rejects a matching version, and uses the
168+
// "does not meet the minimumReleaseAge constraint" message.
169+
// https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/resolving/npm-resolver/src/index.ts#L76-L84
170+
//
171+
// default-reporter handles both ERR_PNPM_NO_MATURE_MATCHING_VERSION and
172+
// ERR_PNPM_NO_MATCHING_VERSION, and may append guidance mentioning
173+
// minimumReleaseAgeExclude when the error has an immatureVersion.
174+
// https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/cli/default-reporter/src/reportError.ts#L163-L164
175+
//
176+
// pnpm itself notes that NO_MATCHING_VERSION can also happen under
177+
// minimumReleaseAge when all candidate versions are newer than the threshold.
178+
// Because it is also used for real missing versions, we only treat it as
179+
// release-age related when accompanied by the age-gate text below.
180+
// https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/deps/inspection/outdated/src/createManifestGetter.ts#L66-L76
181+
//
182+
// minimum-release-age is the pnpm .npmrc key; npm's min-release-age is
183+
// intentionally not treated as a pnpm signal here.
184+
// https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/config/reader/src/types.ts#L73-L74
185+
let has_release_age_text = output.contains("does not meet the minimumReleaseAge constraint")
186+
|| output.contains("minimumReleaseAge")
187+
|| output.contains("minimumReleaseAgeExclude")
188+
|| lower.contains("minimum release age")
189+
|| lower.contains("minimum-release-age");
190+
191+
output.contains("ERR_PNPM_NO_MATURE_MATCHING_VERSION")
192+
|| output.contains("NO_MATURE_MATCHING_VERSION")
193+
|| (output.contains("ERR_PNPM_NO_MATCHING_VERSION") && has_release_age_text)
194+
|| has_release_age_text
195+
}
196+
197+
fn format_install_failure_message(
198+
exit_code: i32,
199+
log_path: Option<&AbsolutePathBuf>,
200+
release_age_blocked: bool,
201+
) -> String {
202+
let log_msg = log_path
203+
.map_or_else(String::new, |p| format!(". See log for details: {}", p.as_path().display()));
204+
205+
if release_age_blocked {
206+
format!(
207+
"Upgrade blocked by your minimumReleaseAge setting. Wait until the package is old enough or adjust your package manager configuration explicitly{log_msg}"
208+
)
209+
} else {
210+
format!("Failed to install production dependencies (exit code: {exit_code}){log_msg}")
211+
}
212+
}
213+
125214
/// Write stdout and stderr from a failed install to `upgrade.log`.
126215
///
127216
/// The log is written to the **parent** of `version_dir` (i.e. `~/.vite-plus/upgrade.log`)
@@ -150,11 +239,13 @@ pub async fn write_upgrade_log(
150239

151240
/// Install production dependencies using the new version's binary.
152241
///
153-
/// Spawns: `{version_dir}/bin/vp install --silent [--registry <url>]` with `CI=true`.
242+
/// Spawns: `{version_dir}/bin/vp install [--registry <url>]` with `CI=true`.
154243
/// On failure, writes stdout+stderr to `{version_dir}/upgrade.log` for debugging.
155244
pub async fn install_production_deps(
156245
version_dir: &AbsolutePath,
157246
registry: Option<&str>,
247+
silent: bool,
248+
new_version: &str,
158249
) -> Result<(), Error> {
159250
let vp_binary = version_dir.join("bin").join(if cfg!(windows) { "vp.exe" } else { "vp" });
160251

@@ -166,39 +257,82 @@ pub async fn install_production_deps(
166257

167258
tracing::debug!("Running vp install in {}", version_dir.as_path().display());
168259

169-
let mut args = vec!["install", "--silent"];
260+
// Do not pass `--silent` to the inner install: pnpm suppresses the
261+
// release-age error body in silent mode, which would leave upgrade.log
262+
// empty and make the release-age gate impossible to detect. This outer
263+
// process captures the output and only surfaces it through the log.
264+
let mut args = vec!["install"];
170265
if let Some(registry_url) = registry {
171266
args.push("--");
172267
args.push("--registry");
173268
args.push(registry_url);
174269
}
175270

176-
let output = tokio::process::Command::new(vp_binary.as_path())
177-
.args(&args)
178-
.current_dir(version_dir)
179-
.env("CI", "true")
180-
.output()
181-
.await?;
271+
let output = run_vp_install(version_dir, &vp_binary, &args).await?;
182272

183273
if !output.status.success() {
184274
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-
);
189-
return Err(Error::Upgrade(
190-
format!(
191-
"Failed to install production dependencies (exit code: {}){}",
192-
output.status.code().unwrap_or(-1),
193-
log_msg
194-
)
195-
.into(),
196-
));
275+
let release_age_blocked = is_release_age_error(&output.stdout, &output.stderr);
276+
277+
if !release_age_blocked {
278+
return Err(Error::Upgrade(
279+
format_install_failure_message(
280+
output.status.code().unwrap_or(-1),
281+
log_path.as_ref(),
282+
false,
283+
)
284+
.into(),
285+
));
286+
}
287+
288+
if !should_prompt_release_age_override(silent) || !prompt_release_age_override(new_version)
289+
{
290+
return Err(Error::Upgrade(
291+
format_install_failure_message(
292+
output.status.code().unwrap_or(-1),
293+
log_path.as_ref(),
294+
true,
295+
)
296+
.into(),
297+
));
298+
}
299+
300+
// Only create the local override after explicit consent. This preserves
301+
// minimumReleaseAge protection for the default and non-interactive paths.
302+
write_release_age_overrides(version_dir).await?;
303+
let retry_output = run_vp_install(version_dir, &vp_binary, &args).await?;
304+
if !retry_output.status.success() {
305+
let retry_log_path =
306+
write_upgrade_log(version_dir, &retry_output.stdout, &retry_output.stderr).await;
307+
return Err(Error::Upgrade(
308+
format_install_failure_message(
309+
retry_output.status.code().unwrap_or(-1),
310+
retry_log_path.as_ref(),
311+
false,
312+
)
313+
.into(),
314+
));
315+
}
197316
}
198317

199318
Ok(())
200319
}
201320

321+
async fn run_vp_install(
322+
version_dir: &AbsolutePath,
323+
vp_binary: &AbsolutePath,
324+
args: &[&str],
325+
) -> Result<Output, Error> {
326+
let output = tokio::process::Command::new(vp_binary.as_path())
327+
.args(args)
328+
.current_dir(version_dir)
329+
.env("CI", "true")
330+
.output()
331+
.await?;
332+
333+
Ok(output)
334+
}
335+
202336
/// Save the current version before swapping, for rollback support.
203337
///
204338
/// Reads the `current` symlink target and writes the version to `.previous-version`.
@@ -545,4 +679,41 @@ mod tests {
545679
"bunfig.toml should not be created"
546680
);
547681
}
682+
683+
#[test]
684+
fn test_is_release_age_error_detects_pnpm_no_mature_code() {
685+
assert!(is_release_age_error(
686+
b"",
687+
b"ERR_PNPM_NO_MATURE_MATCHING_VERSION Version 0.1.16 of vite-plus does not meet the minimumReleaseAge constraint",
688+
));
689+
}
690+
691+
#[test]
692+
fn test_is_release_age_error_detects_minimum_release_age_message() {
693+
assert!(is_release_age_error(
694+
b"",
695+
b"Version 0.1.16 (released just now) of vite-plus does not meet the minimumReleaseAge constraint",
696+
));
697+
}
698+
699+
#[test]
700+
fn test_is_release_age_error_detects_no_matching_with_release_age_context() {
701+
assert!(is_release_age_error(
702+
b"",
703+
b"ERR_PNPM_NO_MATCHING_VERSION No matching version found. Add the package name to minimumReleaseAgeExclude if you want to ignore the time it was published.",
704+
));
705+
}
706+
707+
#[test]
708+
fn test_is_release_age_error_ignores_plain_no_matching_version() {
709+
assert!(!is_release_age_error(
710+
b"",
711+
b"ERR_PNPM_NO_MATCHING_VERSION No matching version found for vite-plus@999.999.999",
712+
));
713+
}
714+
715+
#[test]
716+
fn test_is_release_age_error_ignores_npm_min_release_age() {
717+
assert!(!is_release_age_error(b"", b"min-release-age prevented installing vite-plus",));
718+
}
548719
}

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

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -165,13 +165,8 @@ 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-
173-
// Install production dependencies (npm installs vite-plus + all transitive deps)
174-
install::install_production_deps(version_dir, registry).await?;
168+
// Install production dependencies (pnpm installs vite-plus + all transitive deps)
169+
install::install_production_deps(version_dir, registry, silent, new_version).await?;
175170

176171
// Save previous version for rollback
177172
let previous_version = install::save_previous_version(install_dir).await?;

0 commit comments

Comments
 (0)