From b5a0830a1dc2bf09d93d3c4a4a58bb6ea46c5bb0 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 17 Sep 2026 11:37:02 +0800 Subject: [PATCH 01/14] fix(cli): preserve external installs during first-run setup --- .../assert-shims.mjs | 2 +- .../snapshots.toml | 12 +- .../command_env_setup_external_vp.md | 15 +- .../snapshots.toml | 53 ++++++ .../command_self_setup_external_bare.md | 89 ++++++++++ .../command_self_setup_external_bundled.md | 92 +++++++++++ crates/vp_global_cli/src/self_setup.rs | 93 +++++++++-- .../vp_global_cli/src/self_setup/external.rs | 156 ++++++++++++++++++ 8 files changed, 483 insertions(+), 29 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_bare.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_bundled.md create mode 100644 crates/vp_global_cli/src/self_setup/external.rs diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/assert-shims.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/assert-shims.mjs index e86658fc35..798837b891 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/assert-shims.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/assert-shims.mjs @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; -const expected = path.resolve('external/vp'); +const expected = path.resolve('external/bin/vp'); const shims = [ 'vp', 'node', diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots.toml index 25a5e936d3..266b63b1ba 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots.toml @@ -2,15 +2,17 @@ name = "command_env_setup_external_vp" vp = "global" skip-platforms = ["windows"] +env = { VP_NODE_MANAGER = "yes", VP_SELF_SETUP_NO_MODIFY_PATH = "1" } steps = [ - { argv = ["vpt", "mkdir", "-p", "external", "home"], comment = "Prepare isolated external install and VP_HOME", snapshot = false }, - { argv = ["vpt", "cp", "$VP_HOME/bin/vp", "external/vp"], comment = "Simulate a Homebrew-style vp outside VP_HOME", snapshot = false }, - { argv = ["vpt", "chmod", "+x", "external/vp"], snapshot = false }, - { argv = ["vpt", "touch-file", "external/.vp-setup-complete"], comment = "The external package manager has already set up this binary", snapshot = false }, + { argv = ["vpt", "mkdir", "-p", "external/bin", "home"], comment = "Prepare isolated external install and VP_HOME", snapshot = false }, + { argv = ["vpt", "cp", "$VP_HOME/bin/vp", "external/bin/vp"], comment = "Simulate a Homebrew-style vp outside VP_HOME", snapshot = false }, + { argv = ["vpt", "chmod", "+x", "external/bin/vp"], snapshot = false }, + { argv = ["vpt", "write-file", "external/node_modules/vite-plus/package.json", "{}"], snapshot = false }, + { argv = ["vpt", "write-file", "external/node_modules/vite-plus/dist/bin.js", "// Bundled CLI"], snapshot = false }, { argv = ["vpt", "write-file", ".node-version", "22.18.0\n"], comment = "Project Node.js version", snapshot = false }, { argv = ["vpt", "write-file", "home/js_runtime/node/22.18.0/bin/node", "#!/bin/sh\necho vp-managed-node-22.18.0\n"], comment = "Preinstall managed Node runtime", snapshot = false }, { argv = ["vpt", "chmod", "+x", "home/js_runtime/node/22.18.0/bin/node"], snapshot = false }, - { argv = ["./external/vp", "env", "setup"], envs = [["VP_HOME", "${workspace}/home"]], comment = "Setup shims from external vp", snapshot = false }, + { argv = ["./external/bin/vp", "env", "setup"], envs = [["VP_HOME", "${workspace}/home"]], comment = "Setup shims from external vp without a package-manager-owned marker", snapshot = false }, # The legacy step set VP_BYPASS to reach a system node, which the hermetic # case PATH does not have; the node shim resolving the pinned 22.18.0 from # the seeded runtime serves the same purpose (any node can run the asserts). diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots/command_env_setup_external_vp.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots/command_env_setup_external_vp.md index 915816c55c..8313c2227c 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots/command_env_setup_external_vp.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots/command_env_setup_external_vp.md @@ -1,21 +1,22 @@ # command_env_setup_external_vp -## `vpt mkdir -p external home` +## `vpt mkdir -p external/bin home` Prepare isolated external install and VP_HOME -## `vpt cp $VP_HOME/bin/vp external/vp` +## `vpt cp $VP_HOME/bin/vp external/bin/vp` Simulate a Homebrew-style vp outside VP_HOME -## `vpt chmod +x external/vp` +## `vpt chmod +x external/bin/vp` -## `vpt touch-file external/.vp-setup-complete` +## `vpt write-file external/node_modules/vite-plus/package.json {}` -The external package manager has already set up this binary + +## `vpt write-file external/node_modules/vite-plus/dist/bin.js '// Bundled CLI'` ## `vpt write-file .node-version '22.18.0 @@ -34,9 +35,9 @@ Preinstall managed Node runtime ## `vpt chmod +x home/js_runtime/node/22.18.0/bin/node` -## `VP_HOME=${workspace}/home ./external/vp env setup` +## `VP_HOME=${workspace}/home ./external/bin/vp env setup` -Setup shims from external vp +Setup shims from external vp without a package-manager-owned marker ## `node assert-shims.mjs` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml new file mode 100644 index 0000000000..93add4549d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml @@ -0,0 +1,53 @@ +[[case]] +name = "command_self_setup_external_bundled" +vp = "global" +skip-platforms = ["windows"] +env = { VP_SELF_SETUP_NO_MODIFY_PATH = "1", NPM_CONFIG_REGISTRY = "http://127.0.0.1:9" } +steps = [ + { argv = ["vpt", "mkdir", "-p", "external/bin", "system/bin", "home"], snapshot = false }, + { argv = ["vpt", "cp", "$VP_HOME/bin/vp", "external/bin/vp"], snapshot = false }, + { argv = ["vpt", "cp", "$VP_HOME/js_runtime/node/22.18.0/bin/node", "system/bin/node"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "system/bin/node"], snapshot = false }, + { argv = ["vpt", "write-file", "external/node_modules/vite-plus/package.json", '{"name":"vite-plus","version":"0.3.2"}'], snapshot = false }, + { argv = ["vpt", "write-file", "external/node_modules/vite-plus/dist/bin.js", "console.log('external bundled CLI: ' + process.argv.slice(2).join(' '));"], snapshot = false }, + { argv = ["vpt", "chmod", "555", "external/bin/vp"], snapshot = false }, + { argv = ["vpt", "chmod", "555", "external/bin"], snapshot = false }, + { argv = ["vpt", "chmod", "555", "external"], snapshot = false }, + { argv = ["./external/bin/vp", "--help"], envs = [["VP_HOME", "${workspace}/home"], ["VP_NODE_MANAGER", "no"], ["VP_PM_MANAGER", "no"]], comment = "A read-only package prefix needs neither a marker nor registry access", snapshot = false }, + ["vpt", "stat-file", "external/bin/.vp-setup-complete", "--assert", "missing"], + ["vpt", "stat-file", "home/current", "--assert", "missing"], + ["vpt", "stat-file", "home/self-setup", "--assert", "dir"], + { argv = ["./external/bin/vp", "sync-versions", "--json"], envs = [["VP_HOME", "${workspace}/home"], ["PATH", "${workspace}/system/bin${PATH_SEPARATOR}${PATH}"]], comment = "A later command uses the package manager's bundled JavaScript without another setup prompt" }, + { argv = ["./external/bin/vp", "env", "off"], envs = [["VP_HOME", "${workspace}/home"]], snapshot = false }, + { argv = ["./external/bin/vp", "--help"], envs = [["VP_HOME", "${workspace}/home"], ["VP_NODE_MANAGER", "yes"]], comment = "Later invocations preserve the user's saved management choices", snapshot = false }, + ["vpt", "print-file", "home/config.json"], + { argv = ["./home/bin/vp", "--help"], envs = [["VP_HOME", "${workspace}/home"]], comment = "The user shim also recognizes the external installation", snapshot = false }, + ["vpt", "stat-file", "home/bin/vp", "--assert", "symlink"], +] +after = [ + ["vpt", "chmod", "755", "external"], + ["vpt", "chmod", "755", "external/bin"], +] + +[[case]] +name = "command_self_setup_external_bare" +vp = "global" +skip-platforms = ["windows"] +env = { VP_SKIP_DEPS_INSTALL = "1", VP_VERSION = "external-test", VP_SELF_SETUP_NO_MODIFY_PATH = "1" } +steps = [ + { argv = ["vpt", "mkdir", "-p", "external", "home/external-test/bin"], snapshot = false }, + { argv = ["vpt", "cp", "$VP_HOME/bin/vp", "external/vp"], snapshot = false }, + { argv = ["vpt", "chmod", "555", "external/vp"], snapshot = false }, + { argv = ["vpt", "cp", "external/vp", "home/external-test/bin/vp"], snapshot = false }, + { argv = ["vpt", "chmod", "555", "home/external-test/bin/vp"], comment = "An interrupted bootstrap left a read-only binary before activation", snapshot = false }, + { argv = ["./external/vp", "--help"], tty = false, envs = [["VP_HOME", "${workspace}/home"], ["VP_NODE_MANAGER", "no"]], comment = "Retry replaces the incomplete copy and finishes setup", snapshot = false }, + ["vpt", "stat-file", "home/current/bin/.vp-setup-complete", "--assert", "file"], + ["vpt", "stat-file", "external/.vp-setup-complete", "--assert", "missing"], + { argv = ["./external/vp", "env", "off"], envs = [["VP_HOME", "${workspace}/home"]], comment = "The external binary reuses its completed installation" }, + { argv = ["./external/vp", "--help"], envs = [["VP_HOME", "${workspace}/home"], ["VP_NODE_MANAGER", "yes"]], snapshot = false }, + ["vpt", "stat-file", "home/.previous-version", "--assert", "missing"], + ["vpt", "print-file", "home/config.json"], + { argv = ["./external/vp"], tty = false, envs = [["VP_HOME", "${workspace}/home"], ["VP_NODE_MANAGER", "no"], ["VP_SELF_SETUP_SHELL", "sh"]], comment = "An explicit installer handoff still reinstalls the same version", snapshot = false }, + ["vpt", "print-file", "home/.previous-version"], + ["vpt", "stat-file", "home/current/bin/.vp-setup-complete", "--assert", "file"], +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_bare.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_bare.md new file mode 100644 index 0000000000..8ac9251661 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_bare.md @@ -0,0 +1,89 @@ +# command_self_setup_external_bare + +## `vpt mkdir -p external home/external-test/bin` + + +## `vpt cp $VP_HOME/bin/vp external/vp` + + +## `vpt chmod 555 external/vp` + + +## `vpt cp external/vp home/external-test/bin/vp` + + +## `vpt chmod 555 home/external-test/bin/vp` + +An interrupted bootstrap left a read-only binary before activation + + +## `VP_HOME=${workspace}/home VP_NODE_MANAGER=no ./external/vp --help` + +Retry replaces the incomplete copy and finishes setup + + +## `vpt stat-file home/current/bin/.vp-setup-complete --assert file` + +``` +home/current/bin/.vp-setup-complete: file +``` + +## `vpt stat-file external/.vp-setup-complete --assert missing` + +``` +external/.vp-setup-complete: missing +``` + +## `VP_HOME=${workspace}/home ./external/vp env off` + +The external binary reuses its completed installation + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Node.js and package-manager management set to system-first. + +Selected commands and shims will now prefer system tools, falling back to managed tools. + +Run `vp env on` to always use Vite+ managed tools. +``` + +## `VP_HOME=${workspace}/home VP_NODE_MANAGER=yes ./external/vp --help` + + +## `vpt stat-file home/.previous-version --assert missing` + +``` +home/.previous-version: missing +``` + +## `vpt print-file home/config.json` + +``` +{ + "nodeShimMode": "system_first", + "packageManagerShimModes": { + "bun": "system_first", + "npm": "system_first", + "pnpm": "system_first", + "yarn": "system_first" + } +} +``` + +## `VP_HOME=${workspace}/home VP_NODE_MANAGER=no VP_SELF_SETUP_SHELL=sh ./external/vp` + +An explicit installer handoff still reinstalls the same version + + +## `vpt print-file home/.previous-version` + +``` +external-test +``` + +## `vpt stat-file home/current/bin/.vp-setup-complete --assert file` + +``` +home/current/bin/.vp-setup-complete: file +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_bundled.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_bundled.md new file mode 100644 index 0000000000..e6f2ea1d06 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_bundled.md @@ -0,0 +1,92 @@ +# command_self_setup_external_bundled + +## `vpt mkdir -p external/bin system/bin home` + + +## `vpt cp $VP_HOME/bin/vp external/bin/vp` + + +## `vpt cp $VP_HOME/js_runtime/node/22.18.0/bin/node system/bin/node` + + +## `vpt chmod +x system/bin/node` + + +## `vpt write-file external/node_modules/vite-plus/package.json '{"name":"vite-plus","version":"0.3.2"}'` + + +## `vpt write-file external/node_modules/vite-plus/dist/bin.js 'console.log('\''external bundled CLI: '\'' + process.argv.slice(2).join('\'' '\''));'` + + +## `vpt chmod 555 external/bin/vp` + + +## `vpt chmod 555 external/bin` + + +## `vpt chmod 555 external` + + +## `VP_HOME=${workspace}/home VP_NODE_MANAGER=no VP_PM_MANAGER=no ./external/bin/vp --help` + +A read-only package prefix needs neither a marker nor registry access + + +## `vpt stat-file external/bin/.vp-setup-complete --assert missing` + +``` +external/bin/.vp-setup-complete: missing +``` + +## `vpt stat-file home/current --assert missing` + +``` +home/current: missing +``` + +## `vpt stat-file home/self-setup --assert dir` + +``` +home/self-setup: dir +``` + +## `VP_HOME=${workspace}/home PATH=${workspace}/system/bin${PATH_SEPARATOR}${PATH} ./external/bin/vp sync-versions --json` + +A later command uses the package manager's bundled JavaScript without another setup prompt + +``` +external bundled CLI: sync-versions --json +``` + +## `VP_HOME=${workspace}/home ./external/bin/vp env off` + + +## `VP_HOME=${workspace}/home VP_NODE_MANAGER=yes ./external/bin/vp --help` + +Later invocations preserve the user's saved management choices + + +## `vpt print-file home/config.json` + +``` +{ + "nodeShimMode": "system_first", + "packageManagerShimModes": { + "bun": "system_first", + "npm": "system_first", + "pnpm": "system_first", + "yarn": "system_first" + } +} +``` + +## `VP_HOME=${workspace}/home ./home/bin/vp --help` + +The user shim also recognizes the external installation + + +## `vpt stat-file home/bin/vp --assert symlink` + +``` +home/bin/vp: symlink +``` diff --git a/crates/vp_global_cli/src/self_setup.rs b/crates/vp_global_cli/src/self_setup.rs index 4602925911..d5fbfe3b7f 100644 --- a/crates/vp_global_cli/src/self_setup.rs +++ b/crates/vp_global_cli/src/self_setup.rs @@ -1,5 +1,6 @@ //! First-start installation followed by command execution through the deployed binary. +mod external; mod shell; use std::{path::Path, process::ExitCode}; @@ -39,22 +40,55 @@ pub(crate) async fn maybe_run() -> Result, Error> { } vp_shared::validate_vp_dir_env().map_err(|error| Error::Other(error.to_string().into()))?; + let data = normalize_target(&EnvConfig::get().dirs.data)?; + let external = if dunce::simplified(&binary).starts_with(dunce::simplified(data.as_path())) { + None + } else { + Some(external::SetupState::new(&binary, local_install_version().as_deref())?) + }; + let bundled = external.is_some() && has_bundled_package(&binary); + // Installers explicitly request setup, including same-version reinstalls. + if shell.is_none() + && std::env::var_os(env_vars::VP_SELF_SETUP_REPLACE_EXISTING).is_none() + && let Some(state) = &external + && let Some(installed_binary) = state.completed_binary(bundled).await? + { + if installed_binary.as_path() == binary { + return Ok(None); + } + return execute_installed(&installed_binary, false); + } // Setup diagnostics must not pollute the original command's machine-readable stdout. output::route_user_output_to_stderr(); - let installed_binary = run(&binary).await?; + let installed_binary = run(&binary, bundled).await?; + if let Some(state) = external { + state.save(&installed_binary).await?; + } + output::success("Vite+ setup complete."); if let Some(shell) = shell.as_deref() { print_shell_result(shell); return Ok(Some(ExitCode::SUCCESS)); } + execute_installed(&installed_binary, true) +} + +fn execute_installed( + binary: &AbsolutePath, + just_installed: bool, +) -> Result, Error> { let mut args = std::env::args_os(); let argv0 = args.next(); let shim_tool = argv0.as_deref().and_then(|name| name.to_str()).and_then(crate::shim::detect_shim_tool); - if args.len() == 0 && shim_tool.is_none() && std::env::var_os("VP_COMPLETE").is_none() { + if just_installed + && args.len() == 0 + && shim_tool.is_none() + && std::env::var_os("VP_COMPLETE").is_none() + { return Ok(Some(ExitCode::SUCCESS)); } // Re-enter through the marked installation, inheriting cwd, environment and stdio. - let mut command = std::process::Command::new(installed_binary.as_path()); + let mut command = std::process::Command::new(binary.as_path()); command.args(args); #[cfg(unix)] { @@ -75,6 +109,20 @@ pub(crate) async fn maybe_run() -> Result, Error> { } } +fn has_bundled_package(binary: &Path) -> bool { + let Some(prefix) = binary.parent().and_then(Path::parent) else { return false }; + let package = prefix.join("node_modules/vite-plus"); + // Unix shims can target an external binary; Windows trampolines need the managed layout. + cfg!(unix) && package.join("package.json").is_file() && package.join("dist/bin.js").is_file() +} + +fn local_install_version() -> Option { + std::env::var_os("VP_SKIP_DEPS_INSTALL") + .is_some_and(|value| !value.is_empty()) + .then(|| std::env::var("VP_VERSION").ok()) + .flatten() +} + // Only successful setup emits executable output; logs use stderr in this mode. fn print_shell_result(shell: &str) { let dirs = &EnvConfig::get().dirs; @@ -98,11 +146,13 @@ fn print_shell_result(shell: &str) { } /// Setup Vite+ for the first run -async fn run(source: &Path) -> Result { +async fn run(source: &Path, bundled: bool) -> Result { let env = EnvConfig::get(); let dirs = &env.dirs; let active_binary = dirs.data.join("current").join("bin").join(VP_BINARY_NAME); let in_place = same_file::is_same_file(source, active_binary.as_path()).unwrap_or(false); + // External package managers own their payload. Only set up the user's config and shims. + let deploy = !in_place && !bundled; #[cfg(windows)] if !in_place && ["vp.exe", "vpx.exe", "vpr.exe"] @@ -119,7 +169,7 @@ async fn run(source: &Path) -> Result { "Installation cancelled; existing Vite+ commands were kept.".into(), )); } - let previous_install = previous_install()?; + let previous_install = if deploy { previous_install()? } else { None }; let node_override = manager_mode("VP_NODE_MANAGER"); // A supplied Node choice skips the combined prompt; upgrades preserve all saved choices. let default_mode = @@ -138,7 +188,7 @@ async fn run(source: &Path) -> Result { let registry = registry.as_deref(); // The local bootstrap provisions JS dependencies itself after this invocation. let skip_deps = std::env::var_os("VP_SKIP_DEPS_INSTALL").is_some_and(|value| !value.is_empty()); - let local_version = skip_deps.then(|| std::env::var("VP_VERSION").ok()).flatten(); + let local_version = local_install_version(); let install_version = local_version.as_deref().unwrap_or(version); if !in_place && (install_version.is_empty() @@ -153,7 +203,7 @@ async fn run(source: &Path) -> Result { // 1. Prepare the payload before activating it. Upgrade has already done this in the in-place case. let previous_version = install::read_current_version(&dirs.data).await; - let version_dir = if in_place { + let version_dir = if in_place || bundled { AbsolutePathBuf::new( source.parent().and_then(Path::parent).ok_or(Error::CliBinaryNotFound)?.to_path_buf(), ) @@ -163,12 +213,20 @@ async fn run(source: &Path) -> Result { install::target_install_dir_name(install_version, previous_version.as_deref(), true); dirs.data.join(name) }; - let binary = version_dir.join("bin").join(VP_BINARY_NAME); - if !in_place { + let binary = if bundled { + AbsolutePathBuf::new(source.to_path_buf()).ok_or(Error::CliBinaryNotFound)? + } else { + version_dir.join("bin").join(VP_BINARY_NAME) + }; + if deploy { tokio::fs::create_dir_all(version_dir.join("bin")).await?; install::clear_self_setup_marker(&version_dir).await?; if !same_file::is_same_file(source, binary.as_path()).unwrap_or(false) { - tokio::fs::copy(source, &binary).await?; + // A failed install can leave a read-only copy from a package manager. + // Replace it atomically instead of opening it for writing on retry. + let temporary = tempfile::NamedTempFile::new_in(version_dir.join("bin"))?; + tokio::fs::copy(source, temporary.path()).await?; + temporary.persist(binary.as_path()).map_err(|error| error.error)?; } } if !version_dir.join("node_modules/vite-plus/package.json").as_path().is_file() { @@ -179,7 +237,7 @@ async fn run(source: &Path) -> Result { } } #[cfg(windows)] - if !version_dir.join("bin/vp-shim.exe").as_path().is_file() { + if !bundled && !version_dir.join("bin/vp-shim.exe").as_path().is_file() { let sibling = source.with_file_name("vp-shim.exe"); if sibling.is_file() { tokio::fs::copy(sibling, version_dir.join("bin/vp-shim.exe")).await?; @@ -205,7 +263,9 @@ async fn run(source: &Path) -> Result { if !in_place { // Prepare the payload first, then let the old uninstaller clean its shell entries before writing ours. - remove_previous_install(previous_install.as_deref()).await?; + if deploy { + remove_previous_install(previous_install.as_deref()).await?; + } if std::env::var(env_vars::VP_SELF_SETUP_NO_MODIFY_PATH).as_deref() != Ok("1") { if let Err(error) = shell::configure().await { output::warn(&format!( @@ -235,7 +295,7 @@ async fn run(source: &Path) -> Result { } // 2. Activate a standalone download; an upgrade hook must not overwrite rollback history. - if !in_place { + if deploy { install::save_previous_version(&dirs.data).await?; let name = version_dir .as_path() @@ -251,7 +311,7 @@ async fn run(source: &Path) -> Result { // VpDirs::bin is private by default, so replacing its shims leaves system-first tools elsewhere on PATH intact. // Users explicitly pointing VpDirs::bin at a shared directory accept replacement of conflicting entries there. setup::execute_for_binary(binary.as_path(), true, true, false).await?; - if !in_place { + if deploy { let name = version_dir .as_path() .file_name() @@ -269,8 +329,9 @@ async fn run(source: &Path) -> Result { } // A failure above leaves the marker absent so a later launch can retry. - tokio::fs::write(version_dir.join("bin").join(SELF_SETUP_MARKER), b"").await?; - output::success("Vite+ setup complete."); + if !bundled { + tokio::fs::write(version_dir.join("bin").join(SELF_SETUP_MARKER), b"").await?; + } Ok(binary) } diff --git a/crates/vp_global_cli/src/self_setup/external.rs b/crates/vp_global_cli/src/self_setup/external.rs new file mode 100644 index 0000000000..b76fc62efe --- /dev/null +++ b/crates/vp_global_cli/src/self_setup/external.rs @@ -0,0 +1,156 @@ +//! Per-user setup receipts for binaries owned by an external installer. + +use std::{ + hash::{Hash, Hasher}, + path::{Path, PathBuf}, + time::SystemTime, +}; + +use serde::{Deserialize, Serialize}; +use vp_setup::SELF_SETUP_MARKER; +use vp_shared::EnvConfig; +use vt_path::{AbsolutePath, AbsolutePathBuf}; + +use crate::error::Error; + +#[derive(Deserialize, Serialize, PartialEq, Eq)] +struct Source { + path: PathBuf, + version: String, + modified: SystemTime, + len: u64, +} + +#[derive(Deserialize, Serialize)] +struct Receipt { + source: Source, + binary: PathBuf, +} + +pub(super) struct SetupState { + source: Source, + path: AbsolutePathBuf, +} + +impl SetupState { + pub(super) fn new(binary: &Path, local_version: Option<&str>) -> Result { + let metadata = std::fs::metadata(binary)?; + let mut hash = rustc_hash::FxHasher::default(); + binary.hash(&mut hash); + Ok(Self { + source: Source { + path: binary.to_path_buf(), + version: local_version.unwrap_or(env!("CARGO_PKG_VERSION")).to_string(), + modified: metadata.modified()?, + len: metadata.len(), + }, + path: EnvConfig::get() + .dirs + .state + .join("self-setup") + .join(format!("{:016x}.json", hash.finish())), + }) + } + + pub(super) async fn completed_binary( + &self, + bundled: bool, + ) -> Result, Error> { + let data = match tokio::fs::read(&self.path).await { + Ok(data) => data, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + // An interrupted receipt write must allow setup to retry. + let Ok(receipt) = serde_json::from_slice::(&data) else { return Ok(None) }; + if receipt.source != self.source || !receipt.binary.is_file() { + return Ok(None); + } + let complete = if bundled { + receipt.binary == self.source.path + } else { + receipt.binary != self.source.path + && receipt.binary.parent().is_some_and(|bin| bin.join(SELF_SETUP_MARKER).is_file()) + }; + Ok(complete.then(|| AbsolutePathBuf::new(receipt.binary)).flatten()) + } + + pub(super) async fn save(self, binary: &AbsolutePath) -> Result<(), Error> { + tokio::fs::create_dir_all(self.path.parent().ok_or(Error::CliBinaryNotFound)?).await?; + let receipt = Receipt { source: self.source, binary: binary.as_path().to_path_buf() }; + tokio::fs::write(&self.path, serde_json::to_vec(&receipt)?).await?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn bundled_receipt_is_per_user_and_invalidated_when_source_changes() { + EnvConfig::scoped_async(|env| async move { + let binary = env.dirs.data.join("external/bin/vp"); + tokio::fs::create_dir_all(binary.parent().unwrap()).await.unwrap(); + tokio::fs::write(&binary, b"vp").await.unwrap(); + let state = SetupState::new(binary.as_path(), None).unwrap(); + assert!(state.path.as_path().starts_with(env.dirs.state.as_path())); + assert!(state.completed_binary(true).await.unwrap().is_none()); + state.save(&binary).await.unwrap(); + + let state = SetupState::new(binary.as_path(), None).unwrap(); + assert_eq!(state.completed_binary(true).await.unwrap(), Some(binary.clone())); + assert!(state.completed_binary(false).await.unwrap().is_none()); + assert!(!binary.parent().unwrap().join(SELF_SETUP_MARKER).as_path().exists()); + + tokio::fs::write(&binary, b"new vp build").await.unwrap(); + let state = SetupState::new(binary.as_path(), None).unwrap(); + assert!(state.completed_binary(true).await.unwrap().is_none()); + }) + .await; + } + + #[tokio::test] + async fn standalone_receipt_requires_a_marked_deployed_binary() { + EnvConfig::scoped_async(|env| async move { + let source = env.dirs.data.join("vp"); + let binary = env.dirs.data.join("version/bin/vp"); + tokio::fs::write(&source, b"vp").await.unwrap(); + tokio::fs::create_dir_all(binary.parent().unwrap()).await.unwrap(); + tokio::fs::write(&binary, b"vp").await.unwrap(); + SetupState::new(source.as_path(), None).unwrap().save(&binary).await.unwrap(); + let state = SetupState::new(source.as_path(), None).unwrap(); + assert!(state.completed_binary(false).await.unwrap().is_none()); + + let marker = binary.parent().unwrap().join(SELF_SETUP_MARKER); + tokio::fs::write(&marker, b"").await.unwrap(); + assert_eq!(state.completed_binary(false).await.unwrap(), Some(binary.clone())); + // A package manager can add a bundled CLI after a bare-binary install. + assert!(state.completed_binary(true).await.unwrap().is_none()); + + // Upgrade clears the marker; cleanup or implode can remove the target entirely. + tokio::fs::remove_file(&marker).await.unwrap(); + assert!(state.completed_binary(false).await.unwrap().is_none()); + tokio::fs::write(&marker, b"").await.unwrap(); + tokio::fs::remove_file(&binary).await.unwrap(); + assert!(state.completed_binary(false).await.unwrap().is_none()); + }) + .await; + } + + #[tokio::test] + async fn changed_version_or_incomplete_receipt_retries_setup() { + EnvConfig::scoped_async(|env| async move { + let binary = env.dirs.data.join("vp"); + tokio::fs::write(&binary, b"vp").await.unwrap(); + SetupState::new(binary.as_path(), Some("first")).unwrap().save(&binary).await.unwrap(); + let state = SetupState::new(binary.as_path(), Some("second")).unwrap(); + assert!(state.completed_binary(true).await.unwrap().is_none()); + + tokio::fs::write(&state.path, b"{\"source\":").await.unwrap(); + let state = SetupState::new(binary.as_path(), Some("first")).unwrap(); + assert!(state.completed_binary(true).await.unwrap().is_none()); + }) + .await; + } +} From 2d7c721d92b71637538aeb9bb4f3ea325ba05b84 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 17 Sep 2026 12:34:29 +0800 Subject: [PATCH 02/14] docs: explain setup for external Vite+ installations --- docs/guide/global-cli.md | 14 ++++++++++++ docs/guide/implode.md | 13 ++++++++++- docs/guide/upgrade.md | 10 ++++++++- rfcs/directory-layout.md | 48 ++++++++++++++++++++++++++++++++++------ 4 files changed, 76 insertions(+), 9 deletions(-) diff --git a/docs/guide/global-cli.md b/docs/guide/global-cli.md index e7d6a36c5b..84283ac015 100644 --- a/docs/guide/global-cli.md +++ b/docs/guide/global-cli.md @@ -350,6 +350,20 @@ For example, `VP_VERSION=1.0.0 vp-setup.exe --version 2.0.0` installs version 2. ::: +### Homebrew + +For a Homebrew installation, the first `vp` command sets up your shell, shims, and environment-management preferences. It reuses Homebrew's binary and bundled JavaScript. Setup stores its completion state in your user directories and does not need write access to the Homebrew prefix. + +Later commands reuse that setup while the installed binary remains unchanged. To prefer your existing Node.js and package managers during the first run, use: + +```bash +VP_NODE_MANAGER=no VP_PM_MANAGER=no vp help +``` + +After setup, use `vp env off` to change this preference. Commands that need missing runtimes or project dependencies can still download them. + +Use Homebrew to [upgrade](/guide/upgrade#global-vp) or [remove](/guide/implode#homebrew) its package. + ## Use It Without a Local Package The global installation is enough for runtime, package-manager, and task-runner workflows: diff --git a/docs/guide/implode.md b/docs/guide/implode.md index 2584ce998d..26f78beea0 100644 --- a/docs/guide/implode.md +++ b/docs/guide/implode.md @@ -1,6 +1,6 @@ # Removing Vite+ -Use `vp implode` to remove the [global `vp` installation](/guide/global-cli) and all related Vite+ data from your machine. It does not remove `vite-plus` dependencies from projects. +Use `vp implode` to remove the Vite+-managed [global `vp` installation](/guide/global-cli) and related user data from your machine. It does not remove `vite-plus` dependencies from projects or packages owned by Homebrew. ## Overview @@ -21,3 +21,14 @@ Skip the confirmation prompt with: ```bash vp implode --yes ``` + +## Homebrew + +Run `vp implode` first to remove Vite+-managed runtimes, global packages, configuration, shims, and shell entries. Then remove the Homebrew package: + +```bash +vp implode +brew uninstall vite-plus +``` + +Running `vp` again before removing the Homebrew package starts first-run setup again. diff --git a/docs/guide/upgrade.md b/docs/guide/upgrade.md index cc9ad4a641..2ac31783d6 100644 --- a/docs/guide/upgrade.md +++ b/docs/guide/upgrade.md @@ -1,6 +1,6 @@ # Upgrading Vite+ -Use `vp upgrade` to update the global `vp` binary. To update the local `vite-plus` package in a project, see [Update Vite+](/guide/upgrade-project). +Use `vp upgrade` to update a Vite+-managed global `vp` binary. For a Homebrew installation, use `brew upgrade vite-plus`. To update the local `vite-plus` package in a project, see [Update Vite+](/guide/upgrade-project). ## Overview @@ -32,6 +32,14 @@ vp toolchain --global ## Global `vp` +Homebrew owns its installed binary and JavaScript package. Update them with: + +```bash +brew upgrade vite-plus +``` + +For installations managed by Vite+, use the commands below. Running `vp upgrade` from a Homebrew installation installs a separate Vite+-managed version; it does not update the Homebrew package. + ```bash vp upgrade # upgrade to the latest version vp upgrade --check # check for updates without installing diff --git a/rfcs/directory-layout.md b/rfcs/directory-layout.md index 4ec11b54af..d11041b283 100644 --- a/rfcs/directory-layout.md +++ b/rfcs/directory-layout.md @@ -86,7 +86,7 @@ platform conventions: | `data` | CLI versions, managed runtimes, package managers (`/current`, `/js_runtime`, `/package_manager`, `/packages`, `/bins`) | | `cache` | Disposable caches (`resolve_cache.json`, `.upgrade-check.json`, create-org tarballs) | | `config` | User configuration (`/env*`, `/config.json`) | -| `state` | State files (session version) | +| `state` | State files (session version, external-install setup receipts under `/self-setup`) | #### `` ownership invariant @@ -230,9 +230,10 @@ Each category checks the following sources in order. A source can provide a path or provide no value. The first path wins. `~/.vite-plus` is the only source that checks file-system state. It provides a -path only when the directory contains a `current` link. Each global install -creates this link. The check runs once during resolution and does not follow the -link. The installers use the same check. +path only when the directory contains a `current` link. A Vite+-managed CLI +install creates this link; an external install with a bundled CLI does not. +The check runs once during resolution and does not follow the link. The +installers use the same check. Directory existence alone is not sufficient. A local pre-split Vite+ dependency can create `~/.vite-plus` for caches, config, and managed runtimes. This source @@ -351,9 +352,42 @@ The installers write environment scripts under **config**. The split layout uses `~/.config/vite-plus/env*`, and the monolithic layout uses the install root. `PATH` entries point to the resolved **bin** directory. -External installers and integrations must get resolved paths from the Vite+ -binary through `VP_DUMP_DIRS`. They must not construct `` from `$HOME`, XDG -variables, or platform rules. +Integrations that need Vite+-managed paths must get them from the binary through +`VP_DUMP_DIRS`. They must not construct `` from `$HOME`, XDG variables, or +platform rules. An external package manager can keep its own installation +prefix and leave the Vite+ directory variables unset. + +#### First-run setup for external binaries + +Managed installations record completed setup in +`//bin/.vp-setup-complete`. A marker beside the running binary +continues to skip setup. Without that marker, a binary outside the resolved +`` uses a per-user receipt under `/self-setup/.json`. +The source hash identifies the executable path. The receipt records that path, +the CLI version, the file modification time and size, and the deployed binary. + +On Unix, setup reuses an external installation when its prefix contains both +`node_modules/vite-plus/package.json` and `node_modules/vite-plus/dist/bin.js` +beside the `bin` directory. This includes the Homebrew layout. Setup configures +the user's shell, preferences, and shims without copying the CLI, installing +its dependencies, changing `current`, or writing a marker in the external +prefix. Later launches with a matching receipt use the external CLI directly. + +An external binary without this bundled CLI installs a managed copy under +``. Later launches use the receipt to execute that copy. The copy must +still have its completion marker. Windows uses this managed layout even when +the external prefix contains JavaScript, because its trampolines require it. + +Setup writes a receipt only after it succeeds. A changed source, a missing +target, a missing managed marker, or an invalid receipt causes setup to retry. +When a failed attempt leaves a read-only binary copy, the retry replaces that +copy atomically. It does not open the old copy for writing. + +Explicit installer requests through `VP_SELF_SETUP_SHELL` or +`VP_SELF_SETUP_REPLACE_EXISTING` bypass external receipts, so same-version +reinstalls still run setup. Managed markers and the `env setup --refresh` +upgrade handoff remain unchanged. Older upgrade and rollback targets continue +to use that command without needing to understand receipts. #### Node-manager shim ownership From 1127c3a099311c6a858966de1b2fac5917fe642f Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 17 Sep 2026 12:54:55 +0800 Subject: [PATCH 03/14] fix(cli): preserve external shims and preferences across upgrades --- .../snapshots.toml | 13 ++ ...command_self_setup_external_preferences.md | 9 ++ ...command_self_setup_external_replacement.md | 9 ++ .../verify-refresh.mjs | 125 ++++++++++++++++++ .../vp_global_cli/src/commands/env/setup.rs | 8 +- .../src/commands/env/setup/unix.rs | 108 +++++++++++++++ crates/vp_global_cli/src/self_setup.rs | 10 +- docs/guide/global-cli.md | 4 +- rfcs/directory-layout.md | 11 ++ 9 files changed, 292 insertions(+), 5 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_preferences.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_replacement.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/verify-refresh.mjs create mode 100644 crates/vp_global_cli/src/commands/env/setup/unix.rs diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml index 93add4549d..90276d11de 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml @@ -29,6 +29,19 @@ after = [ ["vpt", "chmod", "755", "external/bin"], ] +[[case]] +name = "command_self_setup_external_replacement" +vp = "global" +skip-platforms = ["windows"] +requires = ["bash"] +steps = [["node", "verify-refresh.mjs", "replacement"]] + +[[case]] +name = "command_self_setup_external_preferences" +vp = "global" +skip-platforms = ["windows"] +steps = [["node", "verify-refresh.mjs", "preferences"]] + [[case]] name = "command_self_setup_external_bare" vp = "global" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_preferences.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_preferences.md new file mode 100644 index 0000000000..b93ad6b6da --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_preferences.md @@ -0,0 +1,9 @@ +# command_self_setup_external_preferences + +## `node verify-refresh.mjs preferences` + +``` +managed preferences survive executable replacement +mixed preferences survive executable replacement +explicit overrides still apply during setup without changing other preferences +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_replacement.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_replacement.md new file mode 100644 index 0000000000..e70c56bcf5 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_replacement.md @@ -0,0 +1,9 @@ +# command_self_setup_external_replacement + +## `node verify-refresh.mjs replacement` + +``` +vp follows the public entrypoint while the old package still exists +direct vp, cached Bash vp, and node shims survive removal of the old package +mixed preferences survive a new package path +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/verify-refresh.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/verify-refresh.mjs new file mode 100644 index 0000000000..882ababe23 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/verify-refresh.mjs @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const action = process.argv[2]; +const root = path.resolve(process.env.REPLACEMENT_ROOT ?? 'replacement'); +const publicVp = path.join(root, 'brew/bin/vp'); +const oldPrefix = path.join(root, 'brew/Cellar/vite-plus/old'); +const newPrefix = path.join(root, 'brew/Cellar/vite-plus/new'); + +// These actions run between commands in the same Bash session. +if (action === 'switch') { + fs.unlinkSync(publicVp); + fs.symlinkSync(path.join(newPrefix, 'bin/vp'), publicVp); +} else if (action === 'remove') { + fs.rmSync(oldPrefix, { recursive: true }); +} else { + const source = path.join(process.env.VP_HOME, 'bin/vp'); + const managed = { VP_NODE_MANAGER: 'yes', VP_PM_MANAGER: 'yes' }; + const mixed = { VP_NODE_MANAGER: 'yes', VP_PM_MANAGER: 'no', VP_PNPM_MANAGER: 'yes' }; + + function bundle(prefix, label) { + const bin = path.join(prefix, 'bin/vp'); + const pkg = path.join(prefix, 'node_modules/vite-plus'); + fs.mkdirSync(path.dirname(bin), { recursive: true }); + fs.copyFileSync(source, bin); + fs.chmodSync(bin, 0o555); + fs.mkdirSync(path.join(pkg, 'dist'), { recursive: true }); + fs.writeFileSync(path.join(pkg, 'package.json'), '{"name":"vite-plus"}'); + fs.writeFileSync(path.join(pkg, 'dist/bin.js'), `console.log(${JSON.stringify(label)});`); + return bin; + } + + function environment(directory) { + const home = path.join(directory, 'home'); + const system = path.join(directory, 'system/bin'); + const runtime = path.join(home, 'js_runtime/node', process.versions.node, 'bin'); + fs.mkdirSync(system, { recursive: true }); + fs.mkdirSync(runtime, { recursive: true }); + fs.symlinkSync(process.execPath, path.join(system, 'node')); + fs.symlinkSync(process.execPath, path.join(runtime, 'node')); + fs.writeFileSync(path.join(directory, '.node-version'), process.versions.node); + const env = { ...process.env }; + for (const key of Object.keys(env)) { + if (key.startsWith('VP_') || key.startsWith('XDG_') || key === 'CI') delete env[key]; + } + return { + ...env, + HOME: home, + VP_HOME: home, + VP_SELF_SETUP_NO_MODIFY_PATH: '1', + NPM_CONFIG_REGISTRY: 'http://127.0.0.1:9', + PATH: [path.join(home, 'bin'), path.join(directory, 'brew/bin'), system, env.PATH].join(path.delimiter), + }; + } + + function run(binary, args, cwd, env) { + const result = spawnSync(binary, args, { cwd, env, encoding: 'utf8', timeout: 30000 }); + assert.equal(result.status, 0, result.error?.message ?? result.stdout + result.stderr); + return result.stdout.trim(); + } + + function settings(env) { + return JSON.parse(fs.readFileSync(path.join(env.VP_HOME, 'config.json'), 'utf8')); + } + + if (action === 'preferences') { + for (const [label, choices] of [['managed', managed], ['mixed', mixed]]) { + const directory = path.resolve(label); + const binary = bundle(path.join(directory, 'external'), label); + const env = environment(directory); + run(binary, ['--help'], directory, { ...env, ...choices }); + const before = settings(env); + const modified = fs.statSync(binary).mtimeMs + 1000; + fs.utimesSync(binary, new Date(modified), new Date(modified)); + run(binary, ['--help'], directory, env); + assert.deepEqual(settings(env), before, `${label} choices changed after receipt expiry`); + console.log(`${label} preferences survive executable replacement`); + fs.utimesSync(binary, new Date(modified + 1000), new Date(modified + 1000)); + run(binary, ['--help'], directory, { ...env, VP_NODE_MANAGER: 'no', VP_PNPM_MANAGER: 'no' }); + assert.deepEqual(settings(env), { + ...before, + nodeShimMode: 'system_first', + packageManagerShimModes: { ...before.packageManagerShimModes, pnpm: 'system_first' }, + }); + } + console.log('explicit overrides still apply during setup without changing other preferences'); + } else { + assert.equal(action, 'replacement'); + bundle(oldPrefix, 'bundle-old'); + bundle(newPrefix, 'bundle-new'); + fs.mkdirSync(path.dirname(publicVp), { recursive: true }); + fs.symlinkSync(path.join(oldPrefix, 'bin/vp'), publicVp); + const env = environment(root); + run(publicVp, ['--help'], root, { ...env, ...mixed }); + const before = settings(env); + const output = run('bash', ['--noprofile', '--norc', '-c', [ + 'set -e', + 'vp sync-versions --json', + 'test "$(hash -t vp)" = "$VP_HOME/bin/vp"', + '"$TEST_NODE" "$TEST_SCRIPT" switch', + '"$VP_HOME/bin/node" -p "20 + 1"', + 'vp sync-versions --json', + '"$TEST_NODE" "$TEST_SCRIPT" remove', + '"$VP_HOME/bin/vp" sync-versions --json', + 'vp sync-versions --json', + '"$VP_HOME/bin/node" -p "40 + 2"', + ].join('\n')], root, { + ...env, + REPLACEMENT_ROOT: root, + TEST_NODE: process.execPath, + TEST_SCRIPT: fileURLToPath(import.meta.url), + }); + assert.equal(output, 'bundle-old\n21\nbundle-new\nbundle-new\nbundle-new\n42'); + assert.deepEqual(settings(env), before, 'mixed choices changed after a package upgrade'); + // Explicit setup must preserve the public entrypoint too. + run(publicVp, ['env', 'setup', '--refresh'], root, env); + assert.equal(fs.readlinkSync(path.join(env.VP_HOME, 'bin/vp')), publicVp); + console.log('vp follows the public entrypoint while the old package still exists'); + console.log('direct vp, cached Bash vp, and node shims survive removal of the old package'); + console.log('mixed preferences survive a new package path'); + } +} diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index e98b736bc9..314201f4b0 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -30,6 +30,9 @@ use crate::{ help, }; +#[cfg(unix)] +mod unix; + /// Shells that get a generated `/env.*` setup script. #[derive(Clone, Copy, Debug)] enum EnvShell { @@ -292,16 +295,17 @@ async fn setup_vp_wrapper( pub(crate) async fn resolve_unix_vp_shim_target( current_exe: &std::path::Path, ) -> Result { + let current_exe_canon = tokio::fs::canonicalize(current_exe).await.ok(); let current_vp = crate::commands::global::install::package_shim_target(); if tokio::fs::try_exists(¤t_vp).await.unwrap_or(false) { let current_vp_canon = tokio::fs::canonicalize(¤t_vp).await.ok(); - let current_exe_canon = tokio::fs::canonicalize(current_exe).await.ok(); if current_vp_canon.is_some() && current_vp_canon == current_exe_canon { return Ok(current_vp.as_path().to_path_buf()); } } - Ok(current_exe.to_path_buf()) + let binary = current_exe_canon.unwrap_or_else(|| current_exe.to_path_buf()); + Ok(unix::external_shim_target(current_exe).unwrap_or(binary)) } /// Create a single default tool shim. diff --git a/crates/vp_global_cli/src/commands/env/setup/unix.rs b/crates/vp_global_cli/src/commands/env/setup/unix.rs new file mode 100644 index 0000000000..097f7ae163 --- /dev/null +++ b/crates/vp_global_cli/src/commands/env/setup/unix.rs @@ -0,0 +1,108 @@ +//! Keep external shims linked through the package manager's public entrypoint. + +use std::path::{Path, PathBuf}; + +use vp_shared::EnvConfig; + +pub(super) fn external_shim_target(binary: &Path) -> Option { + let canonical = std::fs::canonicalize(binary).ok()?; + let env = EnvConfig::get(); + let bin = env.dirs.bin.as_path(); + let cwd = vt_path::current_dir().ok()?; + let path = std::env::var_os("PATH").unwrap_or_default(); + let mut candidates: Vec<_> = std::env::split_paths(&path).map(|dir| dir.join("vp")).collect(); + // An explicit invocation need not be on PATH. vpx/vpr use the sibling vp. + if let Some(invoked) = std::env::args_os().next().map(PathBuf::from) + && let Some(parent) = invoked.parent().filter(|parent| !parent.as_os_str().is_empty()) + { + candidates.push(parent.join("vp")); + } + // Retain a previously selected entrypoint when only the user shims are on PATH. + if let Ok(target) = std::fs::read_link(bin.join("vp")) { + candidates.push(bin.join(target)); + } + // Preserve the supplied path when it is itself an external entrypoint. + candidates.push(binary.to_path_buf()); + candidates.into_iter().map(|path| cwd.as_path().join(path)).find(|candidate| { + candidate != &canonical + && std::fs::canonicalize(candidate).is_ok_and(|target| target == canonical) + && !passes_through_shims(candidate, bin) + }) +} + +// Canonical equality alone would accept aliases back to our own vp/node shims, +// creating a cycle as soon as setup replaces them. Check every link in the chain. +fn passes_through_shims(candidate: &Path, bin: &Path) -> bool { + let bin = std::fs::canonicalize(bin).unwrap_or_else(|_| bin.to_path_buf()); + let mut path = candidate.to_path_buf(); + for _ in 0..40 { + let Some(parent) = path.parent() else { return true }; + if std::fs::canonicalize(parent).is_ok_and(|parent| parent.starts_with(&bin)) { + return true; + } + match std::fs::read_link(&path) { + Ok(target) => path = parent.join(target), + Err(error) if error.kind() == std::io::ErrorKind::InvalidInput => return false, + Err(_) => return true, + } + } + true +} + +#[cfg(test)] +mod tests { + use std::os::unix::fs::symlink; + + use super::*; + + #[test] + fn prefers_public_entrypoint_and_keeps_it_without_path() { + EnvConfig::scoped(|env| { + let root = tempfile::tempdir().unwrap(); + let binary = root.path().join("package/vp"); + let public = root.path().join("bin/vp"); + std::fs::create_dir_all(binary.parent().unwrap()).unwrap(); + std::fs::create_dir_all(public.parent().unwrap()).unwrap(); + std::fs::create_dir_all(&env.dirs.bin).unwrap(); + std::fs::write(&binary, b"vp").unwrap(); + let binary = std::fs::canonicalize(binary).unwrap(); + symlink("../package/vp", &public).unwrap(); + symlink(&binary, env.dirs.bin.join("vp")).unwrap(); + let path = + std::env::join_paths([env.dirs.bin.as_path(), public.parent().unwrap()]).unwrap(); + EnvConfig::with_vars([("PATH", Some(path))], |_| { + assert_eq!(external_shim_target(&binary), Some(public.clone())); + }); + std::fs::remove_file(env.dirs.bin.join("vp")).unwrap(); + symlink(&public, env.dirs.bin.join("vp")).unwrap(); + EnvConfig::with_vars([("PATH", None::<&str>)], |_| { + assert_eq!(external_shim_target(&binary), Some(public)); + }); + }); + } + + #[test] + fn rejects_aliases_through_user_shims_and_unrelated_binaries() { + EnvConfig::scoped(|env| { + let root = tempfile::tempdir().unwrap(); + let binary = root.path().join("vp"); + let alias = root.path().join("alias"); + let foreign = root.path().join("foreign"); + std::fs::create_dir_all(&alias).unwrap(); + std::fs::create_dir_all(&foreign).unwrap(); + std::fs::create_dir_all(&env.dirs.bin).unwrap(); + std::fs::write(&binary, b"vp").unwrap(); + let binary = std::fs::canonicalize(binary).unwrap(); + std::fs::write(foreign.join("vp"), b"another vp").unwrap(); + symlink(&binary, env.dirs.bin.join("node")).unwrap(); + symlink(env.dirs.bin.join("node"), alias.join("vp")).unwrap(); + let directory_alias = root.path().join("directory-alias"); + symlink(&env.dirs.bin, &directory_alias).unwrap(); + symlink(&binary, env.dirs.bin.join("vp")).unwrap(); + let path = std::env::join_paths([alias, directory_alias, foreign]).unwrap(); + EnvConfig::with_vars([("PATH", Some(path))], |_| { + assert_eq!(external_shim_target(&binary), None); + }); + }); + } +} diff --git a/crates/vp_global_cli/src/self_setup.rs b/crates/vp_global_cli/src/self_setup.rs index d5fbfe3b7f..967b526c60 100644 --- a/crates/vp_global_cli/src/self_setup.rs +++ b/crates/vp_global_cli/src/self_setup.rs @@ -171,9 +171,15 @@ async fn run(source: &Path, bundled: bool) -> Result { } let previous_install = if deploy { previous_install()? } else { None }; let node_override = manager_mode("VP_NODE_MANAGER"); + // A package upgrade can change the executable path or expire its receipt. + // Preferences belong to the user, not to that particular binary. + let configured = bundled && config::get_config_path()?.as_path().is_file(); // A supplied Node choice skips the combined prompt; upgrades preserve all saved choices. - let default_mode = - if in_place || node_override.is_some() { None } else { management_default()? }; + let default_mode = if in_place || configured || node_override.is_some() { + None + } else { + management_default()? + }; let node_mode = if in_place { None } else { node_override.or(default_mode) }; let version = env!("CARGO_PKG_VERSION"); let registry = std::env::var(env_vars::NPM_CONFIG_REGISTRY_UPPER) diff --git a/docs/guide/global-cli.md b/docs/guide/global-cli.md index 84283ac015..3f25f00564 100644 --- a/docs/guide/global-cli.md +++ b/docs/guide/global-cli.md @@ -354,7 +354,9 @@ For example, `VP_VERSION=1.0.0 vp-setup.exe --version 2.0.0` installs version 2. For a Homebrew installation, the first `vp` command sets up your shell, shims, and environment-management preferences. It reuses Homebrew's binary and bundled JavaScript. Setup stores its completion state in your user directories and does not need write access to the Homebrew prefix. -Later commands reuse that setup while the installed binary remains unchanged. To prefer your existing Node.js and package managers during the first run, use: +Later commands reuse that setup while the installed binary remains unchanged. Generated shims follow Homebrew's public `vp` entrypoint when Homebrew replaces a version. Setup preserves your saved management preferences during this replacement. + +To prefer your existing Node.js and package managers during the first run, use: ```bash VP_NODE_MANAGER=no VP_PM_MANAGER=no vp help diff --git a/rfcs/directory-layout.md b/rfcs/directory-layout.md index d11041b283..d3b47a9b5a 100644 --- a/rfcs/directory-layout.md +++ b/rfcs/directory-layout.md @@ -373,6 +373,14 @@ the user's shell, preferences, and shims without copying the CLI, installing its dependencies, changing `current`, or writing a marker in the external prefix. Later launches with a matching receipt use the external CLI directly. +Unix shims use a public `vp` entrypoint that resolves to the same binary when +one is available through `PATH`, the explicit invocation, or existing shims. +They retain that path instead of resolving it to a versioned package directory. +Aliases through the user's shim directory are excluded to prevent link cycles. +Thus, a package-manager upgrade can replace and remove the old prefix without +breaking saved shim paths. JavaScript resolution still uses the real binary's +prefix. With no public entrypoint, shims target the external binary directly. + An external binary without this bundled CLI installs a managed copy under ``. Later launches use the receipt to execute that copy. The copy must still have its completion marker. Windows uses this managed layout even when @@ -380,6 +388,9 @@ the external prefix contains JavaScript, because its trampolines require it. Setup writes a receipt only after it succeeds. A changed source, a missing target, a missing managed marker, or an invalid receipt causes setup to retry. +For bundled installations with an existing user configuration, this retry +preserves management preferences without prompting. Explicit management +variables can still change the preferences during setup. When a failed attempt leaves a read-only binary copy, the retry replaces that copy atomically. It does not open the old copy for writing. From 0fb33307bd2be4947046f303a97634d8c4b4ede2 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 17 Sep 2026 15:09:06 +0800 Subject: [PATCH 04/14] fix: respect Homebrew ownership in upgrade and removal --- .../snapshots.toml | 32 +++ .../command_self_setup_external_homebrew.md | 143 +++++++++++ .../verify-refresh.mjs | 225 ++++++++++-------- .../vp_global_cli/src/commands/env/setup.rs | 9 +- crates/vp_global_cli/src/commands/implode.rs | 26 +- .../vp_global_cli/src/commands/upgrade/mod.rs | 14 ++ crates/vp_global_cli/src/homebrew.rs | 66 +++++ crates/vp_global_cli/src/main.rs | 1 + crates/vp_global_cli/src/self_setup.rs | 11 +- .../vp_global_cli/src/self_setup/external.rs | 5 +- crates/vp_global_cli/src/upgrade_check.rs | 1 + docs/guide/implode.md | 2 + docs/guide/upgrade.md | 6 +- rfcs/directory-layout.md | 10 + 14 files changed, 434 insertions(+), 117 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_homebrew.md create mode 100644 crates/vp_global_cli/src/homebrew.rs diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml index 90276d11de..205b47bdbd 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml @@ -64,3 +64,35 @@ steps = [ ["vpt", "print-file", "home/.previous-version"], ["vpt", "stat-file", "home/current/bin/.vp-setup-complete", "--assert", "file"], ] + +[[case]] +name = "command_self_setup_external_homebrew" +vp = "global" +skip-platforms = ["windows"] +unset-env = ["VP_CLI_TEST", "VP_NO_UPDATE_CHECK", "CI"] +env = { VP_SELF_SETUP_NO_MODIFY_PATH = "1", NPM_CONFIG_REGISTRY = "http://127.0.0.1:9" } +steps = [ + { argv = ["vpt", "mkdir", "-p", "brew-prefix/bin", "home"], snapshot = false }, + { argv = ["vpt", "cp", "$VP_HOME/bin/vp", "brew-prefix/bin/vp"], snapshot = false }, + { argv = ["vpt", "write-file", "brew-prefix/INSTALL_RECEIPT.json", '{"homebrew_version":"7.0.2","source":{"tap":"fengmk2/core"}}'], snapshot = false }, + { argv = ["vpt", "write-file", "brew-prefix/node_modules/vite-plus/package.json", '{"name":"vite-plus"}'], snapshot = false }, + { argv = ["vpt", "write-file", "brew-prefix/node_modules/vite-plus/dist/bin.js", "console.log('bundled CLI');"], snapshot = false }, + { argv = ["./brew-prefix/bin/vp", "--help"], envs = [["VP_HOME", "${workspace}/home"], ["VP_NODE_MANAGER", "no"], ["VP_PM_MANAGER", "no"]], snapshot = false }, + { argv = ["./home/bin/vp", "upgrade"], envs = [["VP_HOME", "${workspace}/home"]], continue-on-failure = true }, + { argv = ["./home/bin/vp", "upgrade", "--force"], envs = [["VP_HOME", "${workspace}/home"]], continue-on-failure = true }, + { argv = ["./home/bin/vp", "upgrade", "0.0.1", "--registry", "http://127.0.0.1:9"], envs = [["VP_HOME", "${workspace}/home"]], continue-on-failure = true }, + { argv = ["./home/bin/vp", "upgrade", "--rollback"], envs = [["VP_HOME", "${workspace}/home"]], continue-on-failure = true }, + { argv = ["./home/bin/vp", "upgrade", "--silent"], envs = [["VP_HOME", "${workspace}/home"]], continue-on-failure = true }, + { argv = ["./home/bin/vp", "upgrade", "--check"], envs = [["VP_HOME", "${workspace}/home"]] }, + { argv = ["./home/bin/vp", "upgrade", "--check", "--silent"], envs = [["VP_HOME", "${workspace}/home"]] }, + { argv = ["./home/bin/vp", "upgrade", "--background-check"], envs = [["VP_HOME", "${workspace}/home"]] }, + { argv = ["./home/bin/vp", "env", "off"], envs = [["VP_HOME", "${workspace}/home"]] }, + ["vpt", "stat-file", "home/current", "--assert", "missing"], + ["vpt", "stat-file", "home/cache/upgrade-check.json", "--assert", "missing"], + ["vpt", "stat-file", "brew-prefix/bin/.vp-setup-complete", "--assert", "missing"], + { argv = ["./home/bin/vp", "implode", "--yes"], envs = [["VP_HOME", "${workspace}/home"]], comment = "Cleanup removes user data and leaves the Homebrew package installed" }, + ["vpt", "stat-file", "home", "--assert", "missing"], + ["vpt", "stat-file", "brew-prefix/bin/vp", "--assert", "file"], + ["vpt", "stat-file", "brew-prefix/INSTALL_RECEIPT.json", "--assert", "file"], + ["vpt", "stat-file", "brew-prefix/node_modules/vite-plus/dist/bin.js", "--assert", "file"], +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_homebrew.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_homebrew.md new file mode 100644 index 0000000000..f30ab1b6fa --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_homebrew.md @@ -0,0 +1,143 @@ +# command_self_setup_external_homebrew + +## `vpt mkdir -p brew-prefix/bin home` + + +## `vpt cp $VP_HOME/bin/vp brew-prefix/bin/vp` + + +## `vpt write-file brew-prefix/INSTALL_RECEIPT.json '{"homebrew_version":"7.0.2","source":{"tap":"fengmk2/core"}}'` + + +## `vpt write-file brew-prefix/node_modules/vite-plus/package.json '{"name":"vite-plus"}'` + + +## `vpt write-file brew-prefix/node_modules/vite-plus/dist/bin.js 'console.log('\''bundled CLI'\'');'` + + +## `VP_HOME=${workspace}/home VP_NODE_MANAGER=no VP_PM_MANAGER=no ./brew-prefix/bin/vp --help` + + +## `VP_HOME=${workspace}/home ./home/bin/vp upgrade` + +**Exit code:** 1 + +``` +error: Upgrade error: Homebrew manages this installation. Run `brew upgrade vite-plus` to update it. +``` + +## `VP_HOME=${workspace}/home ./home/bin/vp upgrade --force` + +**Exit code:** 1 + +``` +error: Upgrade error: Homebrew manages this installation. Run `brew upgrade vite-plus` to update it. +``` + +## `VP_HOME=${workspace}/home ./home/bin/vp upgrade 0.0.1 --registry http://127.0.0.1:9` + +**Exit code:** 1 + +``` +error: Upgrade error: Homebrew manages this installation. Run `brew upgrade vite-plus` to update it. +``` + +## `VP_HOME=${workspace}/home ./home/bin/vp upgrade --rollback` + +**Exit code:** 1 + +``` +error: Upgrade error: Homebrew manages this installation. Run `brew upgrade vite-plus` to update it. +``` + +## `VP_HOME=${workspace}/home ./home/bin/vp upgrade --silent` + +**Exit code:** 1 + +``` +error: Upgrade error: Homebrew manages this installation. Run `brew upgrade vite-plus` to update it. +``` + +## `VP_HOME=${workspace}/home ./home/bin/vp upgrade --check` + +``` +info: Homebrew manages this installation. Run `brew outdated vite-plus` to check for updates. +``` + +## `VP_HOME=${workspace}/home ./home/bin/vp upgrade --check --silent` + +``` +``` + +## `VP_HOME=${workspace}/home ./home/bin/vp upgrade --background-check` + +``` +``` + +## `VP_HOME=${workspace}/home ./home/bin/vp env off` + +``` +VITE+ - The Unified Toolchain for the Web + +✓ Node.js and package-manager management set to system-first. + +Selected commands and shims will now prefer system tools, falling back to managed tools. + +Run `vp env on` to always use Vite+ managed tools. +``` + +## `vpt stat-file home/current --assert missing` + +``` +home/current: missing +``` + +## `vpt stat-file home/cache/upgrade-check.json --assert missing` + +``` +home/cache/upgrade-check.json: missing +``` + +## `vpt stat-file brew-prefix/bin/.vp-setup-complete --assert missing` + +``` +brew-prefix/bin/.vp-setup-complete: missing +``` + +## `VP_HOME=${workspace}/home ./home/bin/vp implode --yes` + +Cleanup removes user data and leaves the Homebrew package installed + +``` +✓ Vite+ removed 12 shims from /home/bin +✓ Removed /home + +✓ Vite+ removed its managed files and shell entries from your system. +note: The Homebrew package remains installed. Run `brew uninstall vite-plus` to remove it. +note: Running `vp` again before uninstalling the package starts setup again. +note: Restart your terminal to apply shell changes. +``` + +## `vpt stat-file home --assert missing` + +``` +home: missing +``` + +## `vpt stat-file brew-prefix/bin/vp --assert file` + +``` +brew-prefix/bin/vp: file +``` + +## `vpt stat-file brew-prefix/INSTALL_RECEIPT.json --assert file` + +``` +brew-prefix/INSTALL_RECEIPT.json: file +``` + +## `vpt stat-file brew-prefix/node_modules/vite-plus/dist/bin.js --assert file` + +``` +brew-prefix/node_modules/vite-plus/dist/bin.js: file +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/verify-refresh.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/verify-refresh.mjs index 882ababe23..a36afe959c 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/verify-refresh.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/verify-refresh.mjs @@ -10,116 +10,137 @@ const publicVp = path.join(root, 'brew/bin/vp'); const oldPrefix = path.join(root, 'brew/Cellar/vite-plus/old'); const newPrefix = path.join(root, 'brew/Cellar/vite-plus/new'); -// These actions run between commands in the same Bash session. -if (action === 'switch') { - fs.unlinkSync(publicVp); - fs.symlinkSync(path.join(newPrefix, 'bin/vp'), publicVp); -} else if (action === 'remove') { - fs.rmSync(oldPrefix, { recursive: true }); -} else { - const source = path.join(process.env.VP_HOME, 'bin/vp'); - const managed = { VP_NODE_MANAGER: 'yes', VP_PM_MANAGER: 'yes' }; - const mixed = { VP_NODE_MANAGER: 'yes', VP_PM_MANAGER: 'no', VP_PNPM_MANAGER: 'yes' }; +const managed = { VP_NODE_MANAGER: 'yes', VP_PM_MANAGER: 'yes' }; +const mixed = { VP_NODE_MANAGER: 'yes', VP_PM_MANAGER: 'no', VP_PNPM_MANAGER: 'yes' }; - function bundle(prefix, label) { - const bin = path.join(prefix, 'bin/vp'); - const pkg = path.join(prefix, 'node_modules/vite-plus'); - fs.mkdirSync(path.dirname(bin), { recursive: true }); - fs.copyFileSync(source, bin); - fs.chmodSync(bin, 0o555); - fs.mkdirSync(path.join(pkg, 'dist'), { recursive: true }); - fs.writeFileSync(path.join(pkg, 'package.json'), '{"name":"vite-plus"}'); - fs.writeFileSync(path.join(pkg, 'dist/bin.js'), `console.log(${JSON.stringify(label)});`); - return bin; - } +function createBundle(source, prefix, label) { + const bin = path.join(prefix, 'bin/vp'); + const pkg = path.join(prefix, 'node_modules/vite-plus'); + fs.mkdirSync(path.dirname(bin), { recursive: true }); + fs.copyFileSync(source, bin); + fs.chmodSync(bin, 0o555); + fs.mkdirSync(path.join(pkg, 'dist'), { recursive: true }); + fs.writeFileSync(path.join(pkg, 'package.json'), '{"name":"vite-plus"}'); + fs.writeFileSync(path.join(pkg, 'dist/bin.js'), `console.log(${JSON.stringify(label)});`); + return bin; +} - function environment(directory) { - const home = path.join(directory, 'home'); - const system = path.join(directory, 'system/bin'); - const runtime = path.join(home, 'js_runtime/node', process.versions.node, 'bin'); - fs.mkdirSync(system, { recursive: true }); - fs.mkdirSync(runtime, { recursive: true }); - fs.symlinkSync(process.execPath, path.join(system, 'node')); - fs.symlinkSync(process.execPath, path.join(runtime, 'node')); - fs.writeFileSync(path.join(directory, '.node-version'), process.versions.node); - const env = { ...process.env }; - for (const key of Object.keys(env)) { - if (key.startsWith('VP_') || key.startsWith('XDG_') || key === 'CI') delete env[key]; - } - return { - ...env, - HOME: home, - VP_HOME: home, - VP_SELF_SETUP_NO_MODIFY_PATH: '1', - NPM_CONFIG_REGISTRY: 'http://127.0.0.1:9', - PATH: [path.join(home, 'bin'), path.join(directory, 'brew/bin'), system, env.PATH].join(path.delimiter), - }; +function createEnvironment(directory) { + const home = path.join(directory, 'home'); + const system = path.join(directory, 'system/bin'); + const runtime = path.join(home, 'js_runtime/node', process.versions.node, 'bin'); + fs.mkdirSync(system, { recursive: true }); + fs.mkdirSync(runtime, { recursive: true }); + fs.symlinkSync(process.execPath, path.join(system, 'node')); + fs.symlinkSync(process.execPath, path.join(runtime, 'node')); + fs.writeFileSync(path.join(directory, '.node-version'), process.versions.node); + const env = { ...process.env }; + for (const key of Object.keys(env)) { + if (key.startsWith('VP_') || key.startsWith('XDG_') || key === 'CI') delete env[key]; } + return { + ...env, + HOME: home, + VP_HOME: home, + VP_SELF_SETUP_NO_MODIFY_PATH: '1', + NPM_CONFIG_REGISTRY: 'http://127.0.0.1:9', + PATH: [path.join(home, 'bin'), path.join(directory, 'brew/bin'), system, env.PATH].join( + path.delimiter, + ), + }; +} + +function run(binary, args, cwd, env) { + const result = spawnSync(binary, args, { cwd, env, encoding: 'utf8', timeout: 30000 }); + assert.equal(result.status, 0, result.error?.message ?? result.stdout + result.stderr); + return result.stdout.trim(); +} + +function readSettings(env) { + return JSON.parse(fs.readFileSync(path.join(env.VP_HOME, 'config.json'), 'utf8')); +} - function run(binary, args, cwd, env) { - const result = spawnSync(binary, args, { cwd, env, encoding: 'utf8', timeout: 30000 }); - assert.equal(result.status, 0, result.error?.message ?? result.stdout + result.stderr); - return result.stdout.trim(); +function verifyPreferences(source) { + for (const [label, choices] of [ + ['managed', managed], + ['mixed', mixed], + ]) { + const directory = path.resolve(label); + const binary = createBundle(source, path.join(directory, 'external'), label); + const env = createEnvironment(directory); + run(binary, ['--help'], directory, { ...env, ...choices }); + const before = readSettings(env); + const modified = fs.statSync(binary).mtimeMs + 1000; + fs.utimesSync(binary, new Date(modified), new Date(modified)); + run(binary, ['--help'], directory, env); + assert.deepEqual(readSettings(env), before, `${label} choices changed after receipt expiry`); + console.log(`${label} preferences survive executable replacement`); + fs.utimesSync(binary, new Date(modified + 1000), new Date(modified + 1000)); + run(binary, ['--help'], directory, { ...env, VP_NODE_MANAGER: 'no', VP_PNPM_MANAGER: 'no' }); + assert.deepEqual(readSettings(env), { + ...before, + nodeShimMode: 'system_first', + packageManagerShimModes: { ...before.packageManagerShimModes, pnpm: 'system_first' }, + }); } + console.log('explicit overrides still apply during setup without changing other preferences'); +} + +function verifyReplacement(source) { + createBundle(source, oldPrefix, 'bundle-old'); + createBundle(source, newPrefix, 'bundle-new'); + fs.mkdirSync(path.dirname(publicVp), { recursive: true }); + fs.symlinkSync(path.join(oldPrefix, 'bin/vp'), publicVp); + const env = createEnvironment(root); + run(publicVp, ['--help'], root, { ...env, ...mixed }); + const before = readSettings(env); + const script = [ + 'set -e', + 'vp sync-versions --json', + 'test "$(hash -t vp)" = "$VP_HOME/bin/vp"', + '"$TEST_NODE" "$TEST_SCRIPT" switch', + '"$VP_HOME/bin/node" -p "20 + 1"', + 'vp sync-versions --json', + '"$TEST_NODE" "$TEST_SCRIPT" remove', + '"$VP_HOME/bin/vp" sync-versions --json', + 'vp sync-versions --json', + '"$VP_HOME/bin/node" -p "40 + 2"', + ].join('\n'); + const output = run('bash', ['--noprofile', '--norc', '-c', script], root, { + ...env, + REPLACEMENT_ROOT: root, + TEST_NODE: process.execPath, + TEST_SCRIPT: fileURLToPath(import.meta.url), + }); + assert.equal(output, 'bundle-old\n21\nbundle-new\nbundle-new\nbundle-new\n42'); + assert.deepEqual(readSettings(env), before, 'mixed choices changed after a package upgrade'); + // Explicit setup must preserve the public entrypoint too. + run(publicVp, ['env', 'setup', '--refresh'], root, env); + assert.equal(fs.readlinkSync(path.join(env.VP_HOME, 'bin/vp')), publicVp); + console.log('vp follows the public entrypoint while the old package still exists'); + console.log('direct vp, cached Bash vp, and node shims survive removal of the old package'); + console.log('mixed preferences survive a new package path'); +} - function settings(env) { - return JSON.parse(fs.readFileSync(path.join(env.VP_HOME, 'config.json'), 'utf8')); +function main() { + // These actions run between commands in the same Bash session. + if (action === 'switch') { + fs.unlinkSync(publicVp); + fs.symlinkSync(path.join(newPrefix, 'bin/vp'), publicVp); + return; + } + if (action === 'remove') { + fs.rmSync(oldPrefix, { recursive: true }); + return; } + const source = path.join(process.env.VP_HOME, 'bin/vp'); if (action === 'preferences') { - for (const [label, choices] of [['managed', managed], ['mixed', mixed]]) { - const directory = path.resolve(label); - const binary = bundle(path.join(directory, 'external'), label); - const env = environment(directory); - run(binary, ['--help'], directory, { ...env, ...choices }); - const before = settings(env); - const modified = fs.statSync(binary).mtimeMs + 1000; - fs.utimesSync(binary, new Date(modified), new Date(modified)); - run(binary, ['--help'], directory, env); - assert.deepEqual(settings(env), before, `${label} choices changed after receipt expiry`); - console.log(`${label} preferences survive executable replacement`); - fs.utimesSync(binary, new Date(modified + 1000), new Date(modified + 1000)); - run(binary, ['--help'], directory, { ...env, VP_NODE_MANAGER: 'no', VP_PNPM_MANAGER: 'no' }); - assert.deepEqual(settings(env), { - ...before, - nodeShimMode: 'system_first', - packageManagerShimModes: { ...before.packageManagerShimModes, pnpm: 'system_first' }, - }); - } - console.log('explicit overrides still apply during setup without changing other preferences'); - } else { - assert.equal(action, 'replacement'); - bundle(oldPrefix, 'bundle-old'); - bundle(newPrefix, 'bundle-new'); - fs.mkdirSync(path.dirname(publicVp), { recursive: true }); - fs.symlinkSync(path.join(oldPrefix, 'bin/vp'), publicVp); - const env = environment(root); - run(publicVp, ['--help'], root, { ...env, ...mixed }); - const before = settings(env); - const output = run('bash', ['--noprofile', '--norc', '-c', [ - 'set -e', - 'vp sync-versions --json', - 'test "$(hash -t vp)" = "$VP_HOME/bin/vp"', - '"$TEST_NODE" "$TEST_SCRIPT" switch', - '"$VP_HOME/bin/node" -p "20 + 1"', - 'vp sync-versions --json', - '"$TEST_NODE" "$TEST_SCRIPT" remove', - '"$VP_HOME/bin/vp" sync-versions --json', - 'vp sync-versions --json', - '"$VP_HOME/bin/node" -p "40 + 2"', - ].join('\n')], root, { - ...env, - REPLACEMENT_ROOT: root, - TEST_NODE: process.execPath, - TEST_SCRIPT: fileURLToPath(import.meta.url), - }); - assert.equal(output, 'bundle-old\n21\nbundle-new\nbundle-new\nbundle-new\n42'); - assert.deepEqual(settings(env), before, 'mixed choices changed after a package upgrade'); - // Explicit setup must preserve the public entrypoint too. - run(publicVp, ['env', 'setup', '--refresh'], root, env); - assert.equal(fs.readlinkSync(path.join(env.VP_HOME, 'bin/vp')), publicVp); - console.log('vp follows the public entrypoint while the old package still exists'); - console.log('direct vp, cached Bash vp, and node shims survive removal of the old package'); - console.log('mixed preferences survive a new package path'); + verifyPreferences(source); + return; } + assert.equal(action, 'replacement'); + verifyReplacement(source); } + +main(); diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index 314201f4b0..621f83d685 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -297,11 +297,10 @@ pub(crate) async fn resolve_unix_vp_shim_target( ) -> Result { let current_exe_canon = tokio::fs::canonicalize(current_exe).await.ok(); let current_vp = crate::commands::global::install::package_shim_target(); - if tokio::fs::try_exists(¤t_vp).await.unwrap_or(false) { - let current_vp_canon = tokio::fs::canonicalize(¤t_vp).await.ok(); - if current_vp_canon.is_some() && current_vp_canon == current_exe_canon { - return Ok(current_vp.as_path().to_path_buf()); - } + if let Some(binary) = ¤t_exe_canon + && tokio::fs::canonicalize(¤t_vp).await.is_ok_and(|target| target == *binary) + { + return Ok(current_vp.as_path().to_path_buf()); } let binary = current_exe_canon.unwrap_or_else(|| current_exe.to_path_buf()); diff --git a/crates/vp_global_cli/src/commands/implode.rs b/crates/vp_global_cli/src/commands/implode.rs index 0b3992c841..a8493f4ba7 100644 --- a/crates/vp_global_cli/src/commands/implode.rs +++ b/crates/vp_global_cli/src/commands/implode.rs @@ -53,6 +53,7 @@ fn lexical_path(path: &Path) -> PathBuf { pub fn execute(yes: bool) -> Result { let env_config = vp_shared::EnvConfig::get(); let dirs = &env_config.dirs; + let homebrew = crate::homebrew::owns_current_exe(); // Build a unique set of Vite+-owned roots. In a single-root layout, data, // config, and state use the same directory. Cache is inside that directory. @@ -78,7 +79,12 @@ pub fn execute(yes: bool) -> Result { } if !delete_set.iter().any(|root| root.as_path().exists()) { - output::info("vite-plus is not installed. No installation directory exists."); + if homebrew { + output::info("No Vite+-managed data directories exist."); + print_homebrew_uninstall_notice(); + } else { + output::info("vite-plus is not installed. No installation directory exists."); + } return Ok(exit_status(0)); } @@ -134,11 +140,21 @@ pub fn execute(yes: bool) -> Result { output::raw(""); output::success("Vite+ removed its managed files and shell entries from your system."); + if homebrew { + print_homebrew_uninstall_notice(); + } output::note("Restart your terminal to apply shell changes."); Ok(exit_status(0)) } +fn print_homebrew_uninstall_notice() { + output::note( + "The Homebrew package remains installed. Run `brew uninstall vite-plus` to remove it.", + ); + output::note("Running `vp` again before uninstalling the package starts setup again."); +} + /// Remove the shim files vite-plus owns from the bin directory. /// /// Do not remove the bin directory directly because a bin from an explicit @@ -311,7 +327,13 @@ fn confirm_implode( )); } - output::warn("This will completely remove vite-plus from your system!"); + if crate::homebrew::owns_current_exe() { + output::warn( + "This will remove Vite+-managed data, shims, and shell entries. The Homebrew package will remain installed.", + ); + } else { + output::warn("This will completely remove vite-plus from your system!"); + } output::raw(""); output::raw(" Directories to remove:"); for root in delete_set { diff --git a/crates/vp_global_cli/src/commands/upgrade/mod.rs b/crates/vp_global_cli/src/commands/upgrade/mod.rs index 758de45d6b..1fa8741fb0 100644 --- a/crates/vp_global_cli/src/commands/upgrade/mod.rs +++ b/crates/vp_global_cli/src/commands/upgrade/mod.rs @@ -41,6 +41,20 @@ pub async fn execute(options: UpgradeOptions) -> Result { return Ok(ExitStatus::default()); } + if crate::homebrew::owns_current_exe() { + if options.check && !options.rollback { + if !options.silent { + output::info( + "Homebrew manages this installation. Run `brew outdated vite-plus` to check for updates.", + ); + } + return Ok(ExitStatus::default()); + } + return Err(Error::Upgrade( + "Homebrew manages this installation. Run `brew upgrade vite-plus` to update it.".into(), + )); + } + let config = vp_shared::EnvConfig::get(); let install_dir = &config.dirs.data; diff --git a/crates/vp_global_cli/src/homebrew.rs b/crates/vp_global_cli/src/homebrew.rs new file mode 100644 index 0000000000..efbac33138 --- /dev/null +++ b/crates/vp_global_cli/src/homebrew.rs @@ -0,0 +1,66 @@ +//! Identify Homebrew ownership without requiring `brew` on PATH or a fixed prefix. + +use std::{path::Path, sync::OnceLock}; + +pub(crate) fn owns_current_exe() -> bool { + static HOMEBREW: OnceLock = OnceLock::new(); + *HOMEBREW.get_or_init(|| std::env::current_exe().is_ok_and(|binary| owns_binary(&binary))) +} + +fn owns_binary(binary: &Path) -> bool { + // Resolve both Homebrew's public entrypoint and Vite+'s generated shims. + let Ok(binary) = std::fs::canonicalize(binary) else { return false }; + let Some(bin) = binary.parent().filter(|bin| bin.file_name().is_some_and(|name| name == "bin")) + else { + return false; + }; + let Some(prefix) = bin.parent() else { return false }; + let Ok(data) = std::fs::read(prefix.join("INSTALL_RECEIPT.json")) else { return false }; + let Ok(receipt) = serde_json::from_slice::(&data) else { return false }; + receipt + .get("homebrew_version") + .and_then(serde_json::Value::as_str) + .is_some_and(|version| !version.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_receipt_under_custom_prefix() { + let temp = tempfile::tempdir().unwrap(); + let prefix = temp.path().join("custom-cellar/vite-plus/0.3.2"); + let binary = prefix.join("bin/vp"); + std::fs::create_dir_all(binary.parent().unwrap()).unwrap(); + std::fs::write(&binary, "vp").unwrap(); + assert!(!owns_binary(&binary)); + + let receipt = prefix.join("INSTALL_RECEIPT.json"); + for invalid in + ["not json", "{}", r#"{"homebrew_version":null}"#, r#"{"homebrew_version":""}"#] + { + std::fs::write(&receipt, invalid).unwrap(); + assert!(!owns_binary(&binary)); + } + std::fs::write(receipt, r#"{"homebrew_version":"7.0.2"}"#).unwrap(); + assert!(owns_binary(&binary)); + + // A separate managed installation stays independent of Homebrew. + let managed = temp.path().join("managed/0.3.2/bin/vp"); + std::fs::create_dir_all(managed.parent().unwrap()).unwrap(); + std::fs::copy(&binary, &managed).unwrap(); + assert!(!owns_binary(&managed)); + assert!(!owns_binary(&prefix.join("missing/bin/vp"))); + + #[cfg(unix)] + { + let public = temp.path().join("public-vp"); + let shim = temp.path().join("shim-vp"); + std::os::unix::fs::symlink(&binary, &public).unwrap(); + std::os::unix::fs::symlink(&public, &shim).unwrap(); + assert!(owns_binary(&public)); + assert!(owns_binary(&shim)); + } + } +} diff --git a/crates/vp_global_cli/src/main.rs b/crates/vp_global_cli/src/main.rs index 5f4cb4d341..292791805b 100644 --- a/crates/vp_global_cli/src/main.rs +++ b/crates/vp_global_cli/src/main.rs @@ -18,6 +18,7 @@ mod command_picker; mod commands; mod error; mod help; +mod homebrew; mod js_executor; mod self_setup; mod shim; diff --git a/crates/vp_global_cli/src/self_setup.rs b/crates/vp_global_cli/src/self_setup.rs index 967b526c60..a35521bb5d 100644 --- a/crates/vp_global_cli/src/self_setup.rs +++ b/crates/vp_global_cli/src/self_setup.rs @@ -117,10 +117,11 @@ fn has_bundled_package(binary: &Path) -> bool { } fn local_install_version() -> Option { - std::env::var_os("VP_SKIP_DEPS_INSTALL") - .is_some_and(|value| !value.is_empty()) - .then(|| std::env::var("VP_VERSION").ok()) - .flatten() + let skip_deps = std::env::var_os("VP_SKIP_DEPS_INSTALL")?; + if skip_deps.is_empty() { + return None; + } + std::env::var("VP_VERSION").ok() } // Only successful setup emits executable output; logs use stderr in this mode. @@ -280,8 +281,6 @@ async fn run(source: &Path, bundled: bool) -> Result { )); } } - } - if !in_place { let mut settings = config::load_config().await?; if let Some(mode) = node_mode { settings.node_shim_mode = mode; diff --git a/crates/vp_global_cli/src/self_setup/external.rs b/crates/vp_global_cli/src/self_setup/external.rs index b76fc62efe..0f1c2ff641 100644 --- a/crates/vp_global_cli/src/self_setup/external.rs +++ b/crates/vp_global_cli/src/self_setup/external.rs @@ -72,7 +72,10 @@ impl SetupState { receipt.binary != self.source.path && receipt.binary.parent().is_some_and(|bin| bin.join(SELF_SETUP_MARKER).is_file()) }; - Ok(complete.then(|| AbsolutePathBuf::new(receipt.binary)).flatten()) + if !complete { + return Ok(None); + } + Ok(AbsolutePathBuf::new(receipt.binary)) } pub(super) async fn save(self, binary: &AbsolutePath) -> Result<(), Error> { diff --git a/crates/vp_global_cli/src/upgrade_check.rs b/crates/vp_global_cli/src/upgrade_check.rs index 1fe991c285..98b641c2eb 100644 --- a/crates/vp_global_cli/src/upgrade_check.rs +++ b/crates/vp_global_cli/src/upgrade_check.rs @@ -139,6 +139,7 @@ fn checks_disabled() -> bool { std::env::var_os("VP_NO_UPDATE_CHECK").is_some() || vp_shared::EnvConfig::get().is_ci || std::env::var_os("VP_CLI_TEST").is_some() + || crate::homebrew::owns_current_exe() } fn should_check(cache: Option<&UpgradeCheckCache>, current_version: &str, now: u64) -> bool { diff --git a/docs/guide/implode.md b/docs/guide/implode.md index 26f78beea0..5e9c8c146a 100644 --- a/docs/guide/implode.md +++ b/docs/guide/implode.md @@ -31,4 +31,6 @@ vp implode brew uninstall vite-plus ``` +The confirmation prompt explains that the Homebrew package will remain installed. After cleanup, `vp implode` directs you to `brew uninstall vite-plus`. + Running `vp` again before removing the Homebrew package starts first-run setup again. diff --git a/docs/guide/upgrade.md b/docs/guide/upgrade.md index 2ac31783d6..5fb21e46b6 100644 --- a/docs/guide/upgrade.md +++ b/docs/guide/upgrade.md @@ -38,7 +38,11 @@ Homebrew owns its installed binary and JavaScript package. Update them with: brew upgrade vite-plus ``` -For installations managed by Vite+, use the commands below. Running `vp upgrade` from a Homebrew installation installs a separate Vite+-managed version; it does not update the Homebrew package. +`vp upgrade` detects Homebrew installations and directs you to this command without downloading or installing another version. This also applies to `--force`, a specific version, and `--rollback`. Use Homebrew to manage these installations. + +`vp upgrade --check` directs you to `brew outdated vite-plus`. Automatic npm update checks and notices are disabled for Homebrew installations. + +For installations managed by Vite+, use the commands below. ```bash vp upgrade # upgrade to the latest version diff --git a/rfcs/directory-layout.md b/rfcs/directory-layout.md index d3b47a9b5a..24adaf743a 100644 --- a/rfcs/directory-layout.md +++ b/rfcs/directory-layout.md @@ -373,6 +373,16 @@ the user's shell, preferences, and shims without copying the CLI, installing its dependencies, changing `current`, or writing a marker in the external prefix. Later launches with a matching receipt use the external CLI directly. +The Homebrew `INSTALL_RECEIPT.json` beside the resolved binary's `bin` directory +identifies Homebrew ownership. `vp upgrade` rejects changes to these installations +and directs users to `brew upgrade vite-plus`. Its `--check` option directs users +to `brew outdated vite-plus`. Automatic npm update checks and notices are disabled. +This check does not depend on the Homebrew prefix or `brew` being on `PATH`. +`vp implode` still removes Vite+-managed data and shell entries. Its confirmation +distinguishes this cleanup from removal of the Homebrew package. After cleanup, +it directs users to `brew uninstall vite-plus` and explains that another `vp` +invocation would start setup again. + Unix shims use a public `vp` entrypoint that resolves to the same binary when one is available through `PATH`, the explicit invocation, or existing shims. They retain that path instead of resolving it to a versioned package directory. From 9bd9b3d6ce4639d293afdca61c27fc6d619f062f Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 17 Sep 2026 15:15:57 +0800 Subject: [PATCH 05/14] docs: clarify shell cache after Homebrew cleanup --- .../snapshots/command_self_setup_external_homebrew.md | 2 +- crates/vp_global_cli/src/commands/implode.rs | 4 +++- docs/guide/implode.md | 2 +- rfcs/directory-layout.md | 4 ++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_homebrew.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_homebrew.md index f30ab1b6fa..c2d0d77954 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_homebrew.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_homebrew.md @@ -114,7 +114,7 @@ Cleanup removes user data and leaves the Homebrew package installed ✓ Vite+ removed its managed files and shell entries from your system. note: The Homebrew package remains installed. Run `brew uninstall vite-plus` to remove it. -note: Running `vp` again before uninstalling the package starts setup again. +note: To run `vp` again, restart your terminal or run `hash -r` in Bash. The remaining Homebrew package will start setup again. note: Restart your terminal to apply shell changes. ``` diff --git a/crates/vp_global_cli/src/commands/implode.rs b/crates/vp_global_cli/src/commands/implode.rs index a8493f4ba7..378cc0c582 100644 --- a/crates/vp_global_cli/src/commands/implode.rs +++ b/crates/vp_global_cli/src/commands/implode.rs @@ -152,7 +152,9 @@ fn print_homebrew_uninstall_notice() { output::note( "The Homebrew package remains installed. Run `brew uninstall vite-plus` to remove it.", ); - output::note("Running `vp` again before uninstalling the package starts setup again."); + output::note( + "To run `vp` again, restart your terminal or run `hash -r` in Bash. The remaining Homebrew package will start setup again.", + ); } /// Remove the shim files vite-plus owns from the bin directory. diff --git a/docs/guide/implode.md b/docs/guide/implode.md index 5e9c8c146a..b219b20027 100644 --- a/docs/guide/implode.md +++ b/docs/guide/implode.md @@ -33,4 +33,4 @@ brew uninstall vite-plus The confirmation prompt explains that the Homebrew package will remain installed. After cleanup, `vp implode` directs you to `brew uninstall vite-plus`. -Running `vp` again before removing the Homebrew package starts first-run setup again. +Restart your terminal before you run `vp` again. In Bash, you can run `hash -r` instead to clear cached command paths. If the Homebrew package is still installed, the next `vp` command starts first-run setup again. diff --git a/rfcs/directory-layout.md b/rfcs/directory-layout.md index 24adaf743a..9c6f4d9dcf 100644 --- a/rfcs/directory-layout.md +++ b/rfcs/directory-layout.md @@ -380,8 +380,8 @@ to `brew outdated vite-plus`. Automatic npm update checks and notices are disabl This check does not depend on the Homebrew prefix or `brew` being on `PATH`. `vp implode` still removes Vite+-managed data and shell entries. Its confirmation distinguishes this cleanup from removal of the Homebrew package. After cleanup, -it directs users to `brew uninstall vite-plus` and explains that another `vp` -invocation would start setup again. +it directs users to `brew uninstall vite-plus`. It also explains how to clear +cached shell command paths before another `vp` invocation starts setup again. Unix shims use a public `vp` entrypoint that resolves to the same binary when one is available through `PATH`, the explicit invocation, or existing shims. From 5af41e0be9347532535862cd9b2e10b82010c740 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 17 Sep 2026 15:20:40 +0800 Subject: [PATCH 06/14] docs: move Homebrew upgrade guidance after standard usage --- docs/guide/global-cli.md | 2 +- docs/guide/upgrade.md | 26 ++++++++++++++------------ 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/docs/guide/global-cli.md b/docs/guide/global-cli.md index 3f25f00564..8c8f357ae8 100644 --- a/docs/guide/global-cli.md +++ b/docs/guide/global-cli.md @@ -364,7 +364,7 @@ VP_NODE_MANAGER=no VP_PM_MANAGER=no vp help After setup, use `vp env off` to change this preference. Commands that need missing runtimes or project dependencies can still download them. -Use Homebrew to [upgrade](/guide/upgrade#global-vp) or [remove](/guide/implode#homebrew) its package. +Use Homebrew to [upgrade](/guide/upgrade#homebrew) or [remove](/guide/implode#homebrew) its package. ## Use It Without a Local Package diff --git a/docs/guide/upgrade.md b/docs/guide/upgrade.md index 5fb21e46b6..a838d6732b 100644 --- a/docs/guide/upgrade.md +++ b/docs/guide/upgrade.md @@ -1,6 +1,6 @@ # Upgrading Vite+ -Use `vp upgrade` to update a Vite+-managed global `vp` binary. For a Homebrew installation, use `brew upgrade vite-plus`. To update the local `vite-plus` package in a project, see [Update Vite+](/guide/upgrade-project). +Use `vp upgrade` to update the global `vp` binary. To update the local `vite-plus` package in a project, see [Update Vite+](/guide/upgrade-project). ## Overview @@ -32,17 +32,7 @@ vp toolchain --global ## Global `vp` -Homebrew owns its installed binary and JavaScript package. Update them with: - -```bash -brew upgrade vite-plus -``` - -`vp upgrade` detects Homebrew installations and directs you to this command without downloading or installing another version. This also applies to `--force`, a specific version, and `--rollback`. Use Homebrew to manage these installations. - -`vp upgrade --check` directs you to `brew outdated vite-plus`. Automatic npm update checks and notices are disabled for Homebrew installations. - -For installations managed by Vite+, use the commands below. +Update the global CLI with: ```bash vp upgrade # upgrade to the latest version @@ -89,6 +79,18 @@ vp upgrade --rollback Older versions are pruned automatically after each upgrade. The active version and the previous version are always kept, so a rollback target is never removed. +### Homebrew + +Homebrew owns its installed binary and JavaScript package. Update them with: + +```bash +brew upgrade vite-plus +``` + +`vp upgrade` detects Homebrew installations and directs you to this command without downloading or installing another version. This also applies to `--force`, a specific version, and `--rollback`. Use Homebrew to manage these installations. + +`vp upgrade --check` directs you to `brew outdated vite-plus`. Automatic npm update checks and notices are disabled for Homebrew installations. + ## Preview Builds Some Vite+ pull requests publish temporary packages for testing before an npm release. Treat these as nightly or bleeding-edge builds: they are useful when you want to verify a specific fix, test a fresh upstream dependency bump, or confirm a change before the next release. For day-to-day work, prefer the published `latest` release. From 09aaab86d605bbdb94bdfe040179c52e4a6b4ce2 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 17 Sep 2026 16:06:10 +0800 Subject: [PATCH 07/14] fix: report Homebrew paths correctly in env doctor --- ...mmand_env_doctor_system_package_manager.md | 3 +- .../snapshots.toml | 6 ++ .../command_self_setup_external_doctor.md | 24 +++++++ .../verify-refresh.mjs | 66 +++++++++++++++++++ .../vp_global_cli/src/commands/env/doctor.rs | 29 +++++++- docs/guide/global-cli.md | 2 + rfcs/directory-layout.md | 2 + 7 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_doctor.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md index ef7f36c6e8..89b288d13a 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md @@ -21,7 +21,8 @@ Configuration ✓ Package manager system-first mode PATH - ✓ vp in PATH + ✓ vp ~/.vite-plus/bin/vp + ✓ Shim dir ~/.vite-plus/bin ✓ pnpm ~/.vite-plus/bin/pnpm (vp shim) ✓ pnpx ~/.vite-plus/bin/pnpx (vp shim) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml index 205b47bdbd..1a99fcb201 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml @@ -42,6 +42,12 @@ vp = "global" skip-platforms = ["windows"] steps = [["node", "verify-refresh.mjs", "preferences"]] +[[case]] +name = "command_self_setup_external_doctor" +vp = "global" +skip-platforms = ["windows"] +steps = [["node", "verify-refresh.mjs", "doctor"]] + [[case]] name = "command_self_setup_external_bare" vp = "global" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_doctor.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_doctor.md new file mode 100644 index 0000000000..31ac5c29c8 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_doctor.md @@ -0,0 +1,24 @@ +# command_self_setup_external_doctor + +## `node verify-refresh.mjs doctor` + +``` +Homebrew with shims on PATH + CLI source Homebrew + CLI binary /doctor/cellar/vite-plus/0.3.2/bin/vp + ✓ vp ~/bin/vp + ✓ Shim dir ~/bin +Homebrew without shims on PATH + CLI source Homebrew + CLI binary /doctor/cellar/vite-plus/0.3.2/bin/vp + ✓ vp /doctor/brew/bin/vp + ✗ Shim dir not in PATH +Missing vp in the shim directory + CLI source Homebrew + CLI binary /doctor/cellar/vite-plus/0.3.2/bin/vp + ✗ vp not in PATH + ✓ Shim dir ~/bin +External package without a Homebrew receipt + ✓ vp ~/bin/vp + ✓ Shim dir ~/bin +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/verify-refresh.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/verify-refresh.mjs index a36afe959c..53ae9da438 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/verify-refresh.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/verify-refresh.mjs @@ -60,6 +60,68 @@ function readSettings(env) { return JSON.parse(fs.readFileSync(path.join(env.VP_HOME, 'config.json'), 'utf8')); } +function verifyDoctor(source) { + const directory = path.resolve('doctor'); + const prefix = path.join(directory, 'cellar/vite-plus/0.3.2'); + const binary = createBundle(source, prefix, 'bundled CLI'); + const receipt = path.join(prefix, 'INSTALL_RECEIPT.json'); + fs.writeFileSync(receipt, '{"homebrew_version":"7.0.2"}'); + const env = createEnvironment(directory); + const publicBin = path.join(directory, 'brew/bin'); + const shimBin = path.join(env.VP_HOME, 'bin'); + const systemBin = path.join(directory, 'system/bin'); + fs.mkdirSync(publicBin, { recursive: true }); + fs.symlinkSync(binary, path.join(publicBin, 'vp')); + run(path.join(publicBin, 'vp'), ['--help'], directory, { + ...env, + VP_NODE_MANAGER: 'no', + VP_PM_MANAGER: 'no', + }); + + const cases = [ + { label: 'Homebrew with shims on PATH', paths: [shimBin, publicBin, systemBin], status: 0 }, + { label: 'Homebrew without shims on PATH', paths: [publicBin, systemBin], status: 1 }, + { + label: 'Missing vp in the shim directory', + paths: [shimBin, systemBin], + status: 1, + missingVp: true, + }, + { + label: 'External package without a Homebrew receipt', + paths: [shimBin, publicBin, systemBin], + status: 0, + external: true, + }, + ]; + for (const { label, paths, status, missingVp, external } of cases) { + const shim = path.join(shimBin, 'vp'); + if (missingVp) fs.renameSync(shim, `${shim}.hidden`); + if (external) fs.unlinkSync(receipt); + let result; + try { + result = spawnSync(binary, ['env', 'doctor', 'node'], { + cwd: directory, + env: { ...env, PATH: paths.join(path.delimiter) }, + encoding: 'utf8', + timeout: 30000, + }); + } finally { + if (missingVp) fs.renameSync(`${shim}.hidden`, shim); + } + assert.equal(result.status, status, result.error?.message ?? result.stdout + result.stderr); + const text = result.stdout.replace(/\u001b\[[0-9;]*m/g, ''); + assert.equal(/CLI source\s+Homebrew/.test(text), !external, text); + console.log(label); + console.log( + text + .split('\n') + .filter((line) => /CLI source|CLI binary|[✓✗] (vp|Shim dir)\s/.test(line)) + .join('\n'), + ); + } +} + function verifyPreferences(source) { for (const [label, choices] of [ ['managed', managed], @@ -139,6 +201,10 @@ function main() { verifyPreferences(source); return; } + if (action === 'doctor') { + verifyDoctor(source); + return; + } assert.equal(action, 'replacement'); verifyReplacement(source); } diff --git a/crates/vp_global_cli/src/commands/env/doctor.rs b/crates/vp_global_cli/src/commands/env/doctor.rs index 01c673873b..166803bd9a 100644 --- a/crates/vp_global_cli/src/commands/env/doctor.rs +++ b/crates/vp_global_cli/src/commands/env/doctor.rs @@ -91,6 +91,12 @@ pub async fn execute(cwd: AbsolutePathBuf, scope: Option) -> Result bool { Err(_) => return false, }; + // The public vp can be on PATH even when the user's shim directory is not. + let vp_path = find_in_path("vp"); + if let Some(path) = &vp_path { + print_check( + &style(output::CHECK).green().to_string(), + "vp", + &abbreviate_home(&path.display().to_string()), + ); + } else { + print_check( + &style(output::CROSS).red().to_string(), + "vp", + &style("not in PATH").red().to_string(), + ); + print_hint("Run 'vp env setup' to create the vp shim."); + } + let path_var = std::env::var_os("PATH").unwrap_or_default(); let paths: Vec<_> = std::env::split_paths(&path_var).collect(); @@ -520,11 +543,11 @@ async fn check_path(scope: EnvScope) -> bool { let bin_display = abbreviate_home(&bin_dir.as_path().display().to_string()); if bin_in_path { - print_check(&style(output::CHECK).green().to_string(), "vp", "in PATH"); + print_check(&style(output::CHECK).green().to_string(), "Shim dir", &bin_display); } else { print_check( &style(output::CROSS).red().to_string(), - "vp", + "Shim dir", &style("not in PATH").red().to_string(), ); print_hint(&format!("Expected: {bin_display}")); @@ -556,7 +579,7 @@ async fn check_path(scope: EnvScope) -> bool { } } - true + vp_path.is_some() } /// Find an executable in PATH. diff --git a/docs/guide/global-cli.md b/docs/guide/global-cli.md index 8c8f357ae8..1f24c2bd25 100644 --- a/docs/guide/global-cli.md +++ b/docs/guide/global-cli.md @@ -366,6 +366,8 @@ After setup, use `vp env off` to change this preference. Commands that need miss Use Homebrew to [upgrade](/guide/upgrade#homebrew) or [remove](/guide/implode#homebrew) its package. +`vp env doctor` identifies the Homebrew installation and its binary path. It checks the `vp` command and the user shim directory on `PATH` separately. If only the shim directory is missing, follow its shell setup instructions to enable the shims. + ## Use It Without a Local Package The global installation is enough for runtime, package-manager, and task-runner workflows: diff --git a/rfcs/directory-layout.md b/rfcs/directory-layout.md index 9c6f4d9dcf..2be8a3e213 100644 --- a/rfcs/directory-layout.md +++ b/rfcs/directory-layout.md @@ -378,6 +378,8 @@ identifies Homebrew ownership. `vp upgrade` rejects changes to these installatio and directs users to `brew upgrade vite-plus`. Its `--check` option directs users to `brew outdated vite-plus`. Automatic npm update checks and notices are disabled. This check does not depend on the Homebrew prefix or `brew` being on `PATH`. +`vp env doctor` reports Homebrew ownership and the resolved CLI binary. It checks +the public `vp` command and the user's shim directory on `PATH` separately. `vp implode` still removes Vite+-managed data and shell entries. Its confirmation distinguishes this cleanup from removal of the Homebrew package. After cleanup, it directs users to `brew uninstall vite-plus`. It also explains how to clear From 506d51b765c171e583964062e62c0eaf16df6f9b Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 17 Sep 2026 16:16:08 +0800 Subject: [PATCH 08/14] fix: align env doctor diagnostic columns --- .../command_env_doctor_system_package_manager.md | 4 ++-- .../snapshots/command_self_setup_external_doctor.md | 12 ++++++------ crates/vp_global_cli/src/commands/env/doctor.rs | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md index 89b288d13a..1fd1834b97 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_package_manager_modes/snapshots/command_env_doctor_system_package_manager.md @@ -27,8 +27,8 @@ PATH ✓ pnpx ~/.vite-plus/bin/pnpx (vp shim) Package Manager Resolution - Source system PATH - Version pnpm@10.18.0 + Source system PATH + Version pnpm@10.18.0 ✓ PM binary /system-bin/pnpm ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_doctor.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_doctor.md index 31ac5c29c8..22f1ad7291 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_doctor.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_external_doctor.md @@ -4,18 +4,18 @@ ``` Homebrew with shims on PATH - CLI source Homebrew - CLI binary /doctor/cellar/vite-plus/0.3.2/bin/vp + CLI source Homebrew + CLI binary /doctor/cellar/vite-plus/0.3.2/bin/vp ✓ vp ~/bin/vp ✓ Shim dir ~/bin Homebrew without shims on PATH - CLI source Homebrew - CLI binary /doctor/cellar/vite-plus/0.3.2/bin/vp + CLI source Homebrew + CLI binary /doctor/cellar/vite-plus/0.3.2/bin/vp ✓ vp /doctor/brew/bin/vp ✗ Shim dir not in PATH Missing vp in the shim directory - CLI source Homebrew - CLI binary /doctor/cellar/vite-plus/0.3.2/bin/vp + CLI source Homebrew + CLI binary /doctor/cellar/vite-plus/0.3.2/bin/vp ✗ vp not in PATH ✓ Shim dir ~/bin External package without a Homebrew receipt diff --git a/crates/vp_global_cli/src/commands/env/doctor.rs b/crates/vp_global_cli/src/commands/env/doctor.rs index 166803bd9a..5916f82acb 100644 --- a/crates/vp_global_cli/src/commands/env/doctor.rs +++ b/crates/vp_global_cli/src/commands/env/doctor.rs @@ -53,7 +53,7 @@ fn print_section(name: &str) { /// Use `" "` for informational lines with no status. fn print_check(status: &str, key: &str, value: &str) { if status.trim().is_empty() { - println!(" {key: Date: Thu, 17 Sep 2026 16:29:06 +0800 Subject: [PATCH 09/14] refactor: simplify external setup and regression helpers --- .../verify-refresh.mjs | 28 +++++++++---------- crates/vp_global_cli/src/self_setup.rs | 13 ++++----- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/verify-refresh.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/verify-refresh.mjs index 53ae9da438..7d2d97c037 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/verify-refresh.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/verify-refresh.mjs @@ -50,10 +50,10 @@ function createEnvironment(directory) { }; } -function run(binary, args, cwd, env) { +function run(binary, args, cwd, env, expectedStatus = 0) { const result = spawnSync(binary, args, { cwd, env, encoding: 'utf8', timeout: 30000 }); - assert.equal(result.status, 0, result.error?.message ?? result.stdout + result.stderr); - return result.stdout.trim(); + assert.equal(result.status, expectedStatus, result.error?.message ?? result.stdout + result.stderr); + return result.stdout; } function readSettings(env) { @@ -94,23 +94,23 @@ function verifyDoctor(source) { external: true, }, ]; + const shim = path.join(shimBin, 'vp'); for (const { label, paths, status, missingVp, external } of cases) { - const shim = path.join(shimBin, 'vp'); if (missingVp) fs.renameSync(shim, `${shim}.hidden`); if (external) fs.unlinkSync(receipt); - let result; + let output; try { - result = spawnSync(binary, ['env', 'doctor', 'node'], { - cwd: directory, - env: { ...env, PATH: paths.join(path.delimiter) }, - encoding: 'utf8', - timeout: 30000, - }); + output = run( + binary, + ['env', 'doctor', 'node'], + directory, + { ...env, PATH: paths.join(path.delimiter) }, + status, + ); } finally { if (missingVp) fs.renameSync(`${shim}.hidden`, shim); } - assert.equal(result.status, status, result.error?.message ?? result.stdout + result.stderr); - const text = result.stdout.replace(/\u001b\[[0-9;]*m/g, ''); + const text = output.replace(/\u001b\[[0-9;]*m/g, ''); assert.equal(/CLI source\s+Homebrew/.test(text), !external, text); console.log(label); console.log( @@ -174,7 +174,7 @@ function verifyReplacement(source) { TEST_NODE: process.execPath, TEST_SCRIPT: fileURLToPath(import.meta.url), }); - assert.equal(output, 'bundle-old\n21\nbundle-new\nbundle-new\nbundle-new\n42'); + assert.equal(output.trim(), 'bundle-old\n21\nbundle-new\nbundle-new\nbundle-new\n42'); assert.deepEqual(readSettings(env), before, 'mixed choices changed after a package upgrade'); // Explicit setup must preserve the public entrypoint too. run(publicVp, ['env', 'setup', '--refresh'], root, env); diff --git a/crates/vp_global_cli/src/self_setup.rs b/crates/vp_global_cli/src/self_setup.rs index a35521bb5d..050d5655e7 100644 --- a/crates/vp_global_cli/src/self_setup.rs +++ b/crates/vp_global_cli/src/self_setup.rs @@ -181,7 +181,6 @@ async fn run(source: &Path, bundled: bool) -> Result { } else { management_default()? }; - let node_mode = if in_place { None } else { node_override.or(default_mode) }; let version = env!("CARGO_PKG_VERSION"); let registry = std::env::var(env_vars::NPM_CONFIG_REGISTRY_UPPER) .or_else(|_| std::env::var(env_vars::NPM_CONFIG_REGISTRY)) @@ -210,15 +209,15 @@ async fn run(source: &Path, bundled: bool) -> Result { // 1. Prepare the payload before activating it. Upgrade has already done this in the in-place case. let previous_version = install::read_current_version(&dirs.data).await; - let version_dir = if in_place || bundled { + let version_dir = if deploy { + let name = + install::target_install_dir_name(install_version, previous_version.as_deref(), true); + dirs.data.join(name) + } else { AbsolutePathBuf::new( source.parent().and_then(Path::parent).ok_or(Error::CliBinaryNotFound)?.to_path_buf(), ) .ok_or(Error::CliBinaryNotFound)? - } else { - let name = - install::target_install_dir_name(install_version, previous_version.as_deref(), true); - dirs.data.join(name) }; let binary = if bundled { AbsolutePathBuf::new(source.to_path_buf()).ok_or(Error::CliBinaryNotFound)? @@ -282,7 +281,7 @@ async fn run(source: &Path, bundled: bool) -> Result { } } let mut settings = config::load_config().await?; - if let Some(mode) = node_mode { + if let Some(mode) = node_override.or(default_mode) { settings.node_shim_mode = mode; } let pm_mode = manager_mode("VP_PM_MANAGER").or(default_mode); From 1664d6a438df1eabda1acde43e4a7f48a69e9d28 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 17 Sep 2026 17:07:11 +0800 Subject: [PATCH 10/14] ci: test Homebrew installation lifecycle on labeled PRs --- .../scripts/__tests__/homebrew-formula.mjs | 82 ++++ .github/scripts/homebrew-formula.mjs | 42 ++ .github/scripts/test-homebrew.mjs | 401 ++++++++++++++++++ .github/workflows/ci.yml | 34 ++ CONTRIBUTING.md | 16 + 5 files changed, 575 insertions(+) create mode 100644 .github/scripts/__tests__/homebrew-formula.mjs create mode 100644 .github/scripts/homebrew-formula.mjs create mode 100644 .github/scripts/test-homebrew.mjs diff --git a/.github/scripts/__tests__/homebrew-formula.mjs b/.github/scripts/__tests__/homebrew-formula.mjs new file mode 100644 index 0000000000..5cb63bc4bf --- /dev/null +++ b/.github/scripts/__tests__/homebrew-formula.mjs @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { prepareFormula } from '../homebrew-formula.mjs'; + +const official = `class VitePlus < Formula + url "https://example.com/v0.1.0.tar.gz" + sha256 "${'a'.repeat(64)}" + license "MIT" + + bottle do + sha256 arm64_tahoe: "published-bottle" + end + + resource "rolldown" do + url "https://github.com/rolldown/rolldown.git", + revision: "${'1'.repeat(40)}" + version "${'1'.repeat(40)}" + end + + resource "vite" do + url "https://github.com/vitejs/vite.git", + revision: "${'2'.repeat(40)}" + version "${'2'.repeat(40)}" + end + + def install + system "just", "build" + bin.install "vp" + end + + test do + system bin/"vp", "--version" + end +end +`; + +const inputs = { + url: 'file:///tmp/source%20archive.tar.gz', + sha256: 'b'.repeat(64), + version: '0.3.2', + revision: 1, + upstream: { + rolldown: { repo: 'https://github.com/rolldown/rolldown.git', hash: '3'.repeat(40) }, + vite: { repo: 'https://github.com/vitejs/vite.git', hash: '4'.repeat(40) }, + }, +}; + +test('updates source inputs and preserves the official installation and tests', () => { + const formula = prepareFormula(official, inputs); + assert(formula.includes(`url "${inputs.url}"`)); + assert(formula.includes(`sha256 "${inputs.sha256}"`)); + assert.match(formula, /version "0.3.2"\n revision 1/); + for (const { hash } of Object.values(inputs.upstream)) { + assert(formula.includes(`revision: "${hash}"\n version "${hash}"`)); + } + assert.doesNotMatch(formula, /bottle do|published-bottle|example.com/); + assert.equal( + formula.slice(formula.indexOf(' def install')), + official.slice(official.indexOf(' def install')), + ); +}); + +test('replaces an existing formula revision for a real Homebrew upgrade', () => { + const withRevision = official.replace(' license', ' version "0.1.0"\n revision 7\n license'); + const first = prepareFormula(withRevision, inputs); + const second = prepareFormula(withRevision, { ...inputs, revision: 2 }); + assert.equal(second, first.replace(' revision 1', ' revision 2')); +}); + +test('rejects formula changes that require a new source adapter', () => { + assert.throws( + () => prepareFormula(official.replace(' url', ' url'), inputs), + /layout changed/, + ); + assert.throws( + () => prepareFormula(official.replace(' resource "vite"', ' resource "renamed"'), inputs), + /layout changed/, + ); + assert.throws(() => prepareFormula(`${official}\n__END__\npatch`, inputs), /release patches/); + assert.throws(() => prepareFormula(official, { ...inputs, version: 'main"' })); +}); diff --git a/.github/scripts/homebrew-formula.mjs b/.github/scripts/homebrew-formula.mjs new file mode 100644 index 0000000000..13646cbfdc --- /dev/null +++ b/.github/scripts/homebrew-formula.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; + +function replaceOnce(source, pattern, replacement) { + assert.equal( + [...source.matchAll(new RegExp(pattern, 'gm'))].length, + 1, + `Homebrew formula layout changed: expected one ${pattern}`, + ); + return source.replace(new RegExp(pattern, 'm'), replacement); +} + +/** Adapt only source inputs; keep Homebrew's build, install, and test methods. */ +export function prepareFormula(source, { url, sha256, version, revision, upstream }) { + assert.match(version, /^\d+\.\d+\.\d+(?:-[\w.-]+)?$/); + assert.match(sha256, /^[a-f0-9]{64}$/); + assert(Number.isSafeInteger(revision) && revision > 0); + assert( + !/\bpatch\b|^__END__|^ stable do/m.test(source), + 'Review the formula adapter before applying release patches or a stable block to PR source', + ); + let formula = replaceOnce(source, '^ url "[^"]+"$', ` url ${JSON.stringify(url)}`); + formula = replaceOnce(formula, '^ sha256 "[a-f0-9]+"$', ` sha256 "${sha256}"`); + formula = formula.replace(/^ (?:version|revision) .*\n/gm, ''); + formula = replaceOnce( + formula, + '^ license (.+)$', + ` version "${version}"\n revision ${revision}\n license $1`, + ); + // Published bottles contain release code. Both revisions must build the tested source. + formula = formula.replace(/\n bottle do\n[\s\S]*?\n end\n/, '\n'); + for (const name of ['rolldown', 'vite']) { + const { repo, hash } = upstream[name]; + assert.match(repo, /^https:\/\/github\.com\/[\w-]+\/[\w.-]+\.git$/); + assert.match(hash, /^[a-f0-9]{40}$/); + formula = replaceOnce( + formula, + `(^ resource "${name}" do\\n) url [^\\n]+\\n revision: "[a-f0-9]+"\\n version "[a-f0-9]+"`, + `$1 url "${repo}",\n revision: "${hash}"\n version "${hash}"`, + ); + } + return formula; +} diff --git a/.github/scripts/test-homebrew.mjs b/.github/scripts/test-homebrew.mjs new file mode 100644 index 0000000000..3e5e44463e --- /dev/null +++ b/.github/scripts/test-homebrew.mjs @@ -0,0 +1,401 @@ +// Real Homebrew lifecycle test. Run on a disposable Mac with no installed vite-plus: +// node .github/scripts/test-homebrew.mjs /absolute/path/to/artifacts +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { prepareFormula } from './homebrew-formula.mjs'; + +const script = fileURLToPath(import.meta.url); +const repo = path.resolve(path.dirname(script), '../..'); +assert(process.argv[2], 'Usage: node .github/scripts/test-homebrew.mjs '); +const artifacts = path.resolve(process.argv[2]); +const tap = 'voidzero-e2e/install'; +const formula = `${tap}/vite-plus`; +const stateFile = path.join(artifacts, 'state.json'); + +function run(binary, args, options = {}) { + const { status = 0, ...spawnOptions } = options; + const result = spawnSync(binary, args, { + cwd: artifacts, + encoding: 'utf8', + timeout: 120_000, + maxBuffer: 10 * 1024 * 1024, + ...spawnOptions, + }); + assert.equal( + result.status, + status, + `${binary} ${args.join(' ')}\n${result.error?.message ?? ''}\n${result.stdout ?? ''}${result.stderr ?? ''}`, + ); + return result.stdout; +} + +function buildEnvironment(state) { + const env = { ...process.env, HOME: state.home, PATH: state.path }; + if (state.ci === undefined) { + delete env.CI; + } else { + env.CI = state.ci; + } + for (const key of Object.keys(env)) { + if (/^(VP_|XDG_|NPM_CONFIG_REGISTRY$|npm_config_registry$)/.test(key)) { + delete env[key]; + } + } + return { + ...env, + HOMEBREW_NO_AUTO_UPDATE: '1', + HOMEBREW_NO_INSTALL_FROM_API: '1', + HOMEBREW_NO_INSTALL_CLEANUP: '1', + HOMEBREW_NO_ASK: '1', + }; +} + +function brew(state, args) { + return run(state.brew, args, { env: buildEnvironment(state) }).trim(); +} + +function brewLogged(state, phase, args) { + console.log(`::group::Homebrew ${phase}`); + fs.writeFileSync(path.join(artifacts, 'phase.txt'), phase); + const log = path.join(artifacts, `${phase}.log`); + const fd = fs.openSync(log, 'w'); + try { + run(state.brew, args, { + env: buildEnvironment(state), + stdio: ['ignore', fd, fd], + timeout: 30 * 60_000, + }); + } catch (error) { + console.error(fs.readFileSync(log, 'utf8').split('\n').slice(-100).join('\n')); + throw error; + } finally { + fs.closeSync(fd); + // Homebrew reuses its log directory for the next build. + const logs = path.join(state.home, 'Library/Logs/Homebrew/vite-plus'); + if (fs.existsSync(logs)) { + fs.cpSync(logs, path.join(artifacts, `${phase}-build`), { recursive: true }); + } + console.log('::endgroup::'); + } +} + +async function prepare() { + fs.mkdirSync(artifacts, { recursive: true }); + assert.equal(process.platform, 'darwin', 'Run this test on macOS'); + const state = { + home: os.homedir(), + path: process.env.PATH, + ci: process.env.CI, + brew: run('which', ['brew']).trim(), + }; + assert( + !brew(state, ['list', '--formula']).split('\n').includes('vite-plus'), + 'Use a disposable runner: vite-plus is already installed', + ); + const tapPath = brew(state, ['--repository', tap]); + assert(!fs.existsSync(tapPath), `Refusing to replace existing tap ${tapPath}`); + state.prefix = brew(state, ['--prefix']); + state.formulaPath = path.join(tapPath, 'Formula/vite-plus.rb'); + const sha = run('git', ['rev-parse', 'HEAD'], { cwd: repo }).trim(); + const coreSha = run('git', [ + 'ls-remote', + 'https://github.com/Homebrew/homebrew-core.git', + 'HEAD', + ]).split(/\s/)[0]; + assert.match(coreSha, /^[a-f0-9]{40}$/); + const response = await fetch( + `https://raw.githubusercontent.com/Homebrew/homebrew-core/${coreSha}/Formula/v/vite-plus.rb`, + ); + assert(response.ok, `Cannot fetch official formula: ${response.status}`); + const official = await response.text(); + fs.writeFileSync(path.join(artifacts, 'official-vite-plus.rb'), official); + const sourceJson = (file) => JSON.parse(run('git', ['show', `${sha}:${file}`], { cwd: repo })); + state.version = sourceJson('packages/cli/package.json').version; + const upstream = sourceJson('packages/tools/.upstream-versions.json'); + const archive = path.join(artifacts, `vite-plus-${state.version}.tar.gz`); + run( + 'git', + [ + 'archive', + '--format=tar.gz', + `--prefix=vite-plus-${state.version}/`, + `--output=${archive}`, + sha, + ], + { cwd: repo }, + ); + const sha256 = createHash('sha256').update(fs.readFileSync(archive)).digest('hex'); + for (const revision of [1, 2]) { + fs.writeFileSync( + path.join(artifacts, `vite-plus-${revision}.rb`), + prepareFormula(official, { + url: pathToFileURL(archive).href, + sha256, + version: state.version, + revision, + upstream, + }), + ); + } + fs.writeFileSync( + path.join(artifacts, 'inputs.json'), + JSON.stringify({ sha, coreSha, sha256, version: state.version, upstream }, null, 2), + ); + fs.writeFileSync(stateFile, JSON.stringify(state, null, 2)); + brewLogged(state, 'tap-core', ['tap', '--force', 'homebrew/core']); + brew(state, ['tap-new', '--no-git', tap]); + fs.copyFileSync(path.join(artifacts, 'vite-plus-1.rb'), state.formulaPath); + if (spawnSync(state.brew, ['command', 'trust']).status === 0) { + brew(state, ['trust', '--formula', formula]); + } + fs.writeFileSync(path.join(artifacts, 'brew-config.log'), brew(state, ['config'])); + brewLogged(state, 'install', ['install', '--build-from-source', formula]); + brewLogged(state, 'formula-test', ['test', formula]); + state.node = path.join(brew(state, ['--prefix', 'node']), 'bin/node'); + state.nodeVersion = run(state.node, ['-p', 'process.versions.node']).trim(); + state.oldPrefix = fs.realpathSync(path.join(state.prefix, 'opt/vite-plus')); + fs.writeFileSync(stateFile, JSON.stringify(state, null, 2)); + return state; +} + +function command(test, binary, args, options = {}) { + const { expectedStatus = 0, ...spawnOptions } = options; + const result = spawnSync(binary, args, { + cwd: test.directory, + env: test.env, + encoding: 'utf8', + timeout: 120_000, + ...spawnOptions, + }); + fs.appendFileSync( + path.join(artifacts, `${test.name}.log`), + `$ ${binary} ${args.join(' ')}\n${result.stdout ?? ''}${result.stderr ?? ''}\n`, + ); + assert.equal( + result.status, + expectedStatus, + `${result.error?.message ?? ''}\n${result.stdout ?? ''}${result.stderr ?? ''}`, + ); + return `${result.stdout}${result.stderr}`; +} + +function settings(test) { + return JSON.parse(fs.readFileSync(path.join(test.dirs.config, 'config.json'), 'utf8')); +} + +function shimMode(choice) { + return choice === 'yes' ? 'managed' : 'system_first'; +} + +function createCase(state, name, choices, singleRoot) { + const directory = path.join(artifacts, name); + const home = path.join(directory, 'home'); + fs.mkdirSync(home, { recursive: true }); + const env = buildEnvironment(state); + delete env.CI; + env.HOME = home; + env.SHELL = '/bin/bash'; + env.PATH = `${path.dirname(state.node)}:${state.prefix}/bin:/usr/bin:/bin:/usr/sbin:/sbin`; + env.NPM_CONFIG_REGISTRY = 'http://127.0.0.1:9'; + env.VP_SELF_SETUP_NO_MODIFY_PATH = '1'; + if (singleRoot) { + env.VP_HOME = path.join(home, 'vp'); + } + const vp = path.join(state.prefix, 'bin/vp'); + const dump = run(vp, [], { cwd: directory, env: { ...env, VP_DUMP_DIRS: '1' } }); + const dirs = Object.fromEntries( + dump + .trim() + .split('\n') + .map((line) => line.split('\t')), + ); + for (const [key, value] of Object.entries(dirs)) { + if (key !== 'layout') { + assert(value.startsWith(`${home}/`), `${key} escaped isolated HOME: ${value}`); + } + } + assert.equal(dirs.layout, singleRoot ? 'single-root' : 'split'); + const runtime = path.join(dirs.data, 'js_runtime/node', state.nodeVersion, 'bin'); + fs.mkdirSync(runtime, { recursive: true }); + fs.symlinkSync(state.node, path.join(runtime, 'node')); + fs.writeFileSync(path.join(directory, '.node-version'), state.nodeVersion); + fs.writeFileSync( + path.join(directory, 'package.json'), + JSON.stringify({ + private: true, + scripts: { smoke: 'node -p "21 * 2"' }, + }), + ); + fs.writeFileSync(path.join(directory, 'example.js'), 'const value={answer:42};\n'); + const test = { name, directory, env, dirs, vp }; + const first = command(test, vp, ['--version'], { env: { ...env, ...choices } }); + assert.match(first, /Vite\+ setup complete/); + assert.match(first, new RegExp(state.version.replaceAll('.', '\\.'))); + const receiptDir = path.join(dirs.state, 'self-setup'); + const receipts = fs.readdirSync(receiptDir); + assert.equal(receipts.length, 1); + const receipt = fs.readFileSync(path.join(receiptDir, receipts[0]), 'utf8'); + const second = command(test, vp, ['--version']); + assert.doesNotMatch(second, /setup complete|installing vite-plus|Would you like/); + assert.equal(fs.readFileSync(path.join(receiptDir, receipts[0]), 'utf8'), receipt); + assert(!fs.existsSync(path.join(dirs.data, 'current'))); + assert.equal(fs.readlinkSync(path.join(dirs.bin, 'vp')), vp); + env.PATH = `${dirs.bin}:${env.PATH}`; + test.preferences = settings(test); + assert.equal(test.preferences.nodeShimMode ?? 'managed', shimMode(choices.VP_NODE_MANAGER)); + for (const family of ['npm', 'pnpm', 'yarn', 'bun']) { + const choice = choices[`VP_${family.toUpperCase()}_MANAGER`] ?? choices.VP_PM_MANAGER; + assert.equal(test.preferences.packageManagerShimModes?.[family] ?? 'managed', shimMode(choice)); + } + return test; +} + +function checkCase(state, test) { + const shim = path.join(test.dirs.bin, 'vp'); + command(test, shim, ['--version']); + assert.deepEqual(settings(test), test.preferences); + command(test, shim, ['env', 'setup', '--refresh']); + assert.deepEqual(settings(test), test.preferences); + assert.equal(fs.realpathSync(shim), fs.realpathSync(test.vp)); + assert.match(command(test, path.join(test.dirs.bin, 'node'), ['-p', '21 * 2']), /42/); + assert.match(command(test, path.join(state.prefix, 'bin/vpr'), ['smoke']), /42/); + command(test, path.join(state.prefix, 'bin/vpx'), ['--help']); + command(test, shim, ['fmt', 'example.js']); + command(test, shim, ['fmt', '--check', 'example.js']); + const doctor = command(test, shim, ['env', 'doctor', 'node']); + assert.match(doctor, /CLI source\s+Homebrew/); + assert.match(doctor, /CLI binary\s+/); + assert.match(doctor, /Shim dir\s+/); + assert.match(command(test, shim, ['upgrade', '--check']), /brew outdated vite-plus/); + for (const args of [['upgrade'], ['upgrade', '--force'], ['upgrade', '--rollback']]) { + assert.match(command(test, shim, args, { expectedStatus: 1 }), /brew upgrade vite-plus/); + } + assert(!fs.existsSync(path.join(test.dirs.data, 'current'))); +} + +function withReadOnlyPrefix(prefix, check) { + const modes = []; + function protect(file) { + const stat = fs.lstatSync(file); + if (stat.isSymbolicLink()) { + return; + } + modes.push([file, stat.mode & 0o777]); + if (stat.isDirectory()) { + for (const name of fs.readdirSync(file)) { + protect(path.join(file, name)); + } + } + fs.chmodSync(file, stat.mode & 0o555); + } + try { + protect(prefix); + check(); + assert(!fs.existsSync(path.join(prefix, 'bin/.vp-setup-complete'))); + } finally { + for (const [file, mode] of modes) { + fs.chmodSync(file, mode); + } + } +} + +function upgrade(state) { + fs.copyFileSync(path.join(artifacts, 'vite-plus-2.rb'), state.formulaPath); + brewLogged(state, 'upgrade', ['upgrade', '--build-from-source', formula]); + assert.notEqual(fs.realpathSync(path.join(state.prefix, 'opt/vite-plus')), state.oldPrefix); + brewLogged(state, 'cleanup', ['cleanup', formula]); + assert(!fs.existsSync(state.oldPrefix), 'Homebrew did not remove the old keg'); +} + +function verify(state) { + fs.writeFileSync(path.join(artifacts, 'phase.txt'), 'runtime-before-upgrade'); + const cases = []; + withReadOnlyPrefix(state.oldPrefix, () => { + cases.push( + createCase(state, 'default-system', { VP_NODE_MANAGER: 'no', VP_PM_MANAGER: 'no' }, false), + ); + cases.push( + createCase(state, 'managed', { VP_NODE_MANAGER: 'yes', VP_PM_MANAGER: 'yes' }, true), + ); + cases.push( + createCase( + state, + 'mixed', + { VP_NODE_MANAGER: 'yes', VP_PM_MANAGER: 'no', VP_PNPM_MANAGER: 'yes' }, + true, + ), + ); + for (const test of cases) { + checkCase(state, test); + } + }); + console.log('First-run setup, offline bundled CLI, preferences, and ownership checks passed.'); + const mixed = cases.find((test) => test.name === 'mixed'); + // Keep Bash's command cache alive across an actual brew upgrade and cleanup. + command( + mixed, + '/bin/bash', + [ + '--noprofile', + '--norc', + '-c', + [ + 'set -eu', + 'vp --version', + 'test "$(hash -t vp)" = "$E2E_SHIM/vp"', + '"$E2E_NODE" "$E2E_SCRIPT" "$E2E_ARTIFACTS" upgrade', + 'vp --version', + '"$E2E_SHIM/vp" --version', + 'test "$("$E2E_SHIM/node" -p "21 * 2")" = 42', + ].join('\n'), + ], + { + timeout: 40 * 60_000, + env: { + ...mixed.env, + E2E_SHIM: mixed.dirs.bin, + E2E_NODE: process.execPath, + E2E_SCRIPT: script, + E2E_ARTIFACTS: artifacts, + }, + }, + ); + const prefix = fs.realpathSync(path.join(state.prefix, 'opt/vite-plus')); + fs.writeFileSync(path.join(artifacts, 'phase.txt'), 'runtime-after-upgrade'); + withReadOnlyPrefix(prefix, () => { + for (const test of cases) { + checkCase(state, test); + const output = command(test, test.vp, ['implode', '--yes']); + assert.match(output, /brew uninstall vite-plus/); + assert.match(output, /hash -r/); + for (const [key, directory] of Object.entries(test.dirs)) { + if (key !== 'layout' && key !== 'bin') { + assert(!fs.existsSync(directory), `implode left ${directory}`); + } + } + assert(!fs.existsSync(path.join(test.dirs.bin, 'vp'))); + assert(!fs.existsSync(path.join(test.dirs.bin, 'node'))); + assert(fs.existsSync(path.join(prefix, 'bin/vp'))); + } + }); + brewLogged(state, 'uninstall', ['uninstall', formula]); + assert(!fs.existsSync(prefix)); + assert(!fs.existsSync(path.join(state.prefix, 'bin/vp'))); + brew(state, ['untap', tap]); + fs.writeFileSync(path.join(artifacts, 'phase.txt'), 'passed'); + console.log( + 'Homebrew upgrade, old-keg removal, cached Bash commands, and uninstall checks passed.', + ); +} + +if (process.argv[3] === 'upgrade') { + upgrade(JSON.parse(fs.readFileSync(stateFile, 'utf8'))); +} else { + verify(await prepare()); +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 897275001d..1c5709c312 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1337,6 +1337,39 @@ jobs: echo "" done + homebrew-install-e2e: + name: Homebrew install E2E test (macOS ARM64) + if: >- + github.event_name == 'pull_request' && + contains(github.event.pull_request.labels.*.name, 'test: install-e2e') + runs-on: namespace-profile-mac-default + timeout-minutes: 60 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Test formula adaptation + run: node --test .github/scripts/__tests__/homebrew-formula.mjs + + - name: Build and exercise Homebrew installation + run: node .github/scripts/test-homebrew.mjs "$RUNNER_TEMP/homebrew-e2e" + + - name: Upload Homebrew diagnostics + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: homebrew-install-e2e + path: | + ${{ runner.temp }}/homebrew-e2e/*.log + ${{ runner.temp }}/homebrew-e2e/*.json + ${{ runner.temp }}/homebrew-e2e/*.rb + ${{ runner.temp }}/homebrew-e2e/phase.txt + ${{ runner.temp }}/homebrew-e2e/*-build/** + retention-days: 7 + install-e2e-test-sfw: name: Local CLI `vp install` E2E test (Socket Firewall Free) env: @@ -1496,6 +1529,7 @@ jobs: # Skipped on unlabeled PRs; counted on push-to-main and labeled PRs. # `contains(needs.*.result, 'failure')` ignores "skipped" results. - install-e2e-test + - homebrew-install-e2e - install-e2e-test-sfw steps: - run: exit 1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9a2f66a24e..9ce13ab50c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -171,6 +171,22 @@ gh extension install github/gh-stack Stacked pull requests require all branches to be in this repository; GitHub does not support cross-fork stacks ([reference](https://docs.github.com/en/pull-requests/reference/stacked-pull-requests)). If you contribute from a fork, split large work into a sequence of standalone PRs instead. +## Homebrew installation checks + +Add the `test: install-e2e` label to a pull request to run the Homebrew E2E job on macOS ARM64. New commits rerun the job while the label remains. Unlabeled pull requests and pushes to `main` skip this job. + +The job adapts the current official formula to build the tested commit and its pinned upstream resources. It checks first-run setup, bundled commands without npm access, management preferences, diagnostics, and removal. It also installs two formula revisions to check real `brew upgrade` and `brew cleanup` behavior, including cached Bash commands and saved shim paths. + +The `homebrew-install-e2e` artifact contains the source revisions, generated formulae, command output, and Homebrew build logs. Its `phase.txt` identifies the last build or test phase. A formula adaptation failure means the official formula changed and the adapter needs review. + +To run the same checks on a disposable Mac with Homebrew and Node.js: + +```bash +node .github/scripts/test-homebrew.mjs /absolute/path/to/homebrew-e2e-results +``` + +The script requires a committed checkout and refuses to run if `vite-plus` or its temporary `voidzero-e2e/install` tap already exists. Successful runs remove the test package and tap. + ## Verified Commits All commits in PR branches should be GitHub-verified so reviewers can confirm commit authenticity. From 8992d25a974cca606392413655f6c8daf068245d Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 17 Sep 2026 17:08:10 +0800 Subject: [PATCH 11/14] docs: keep Homebrew test instructions with the workflow --- .github/scripts/test-homebrew.mjs | 1 + CONTRIBUTING.md | 16 ---------------- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/.github/scripts/test-homebrew.mjs b/.github/scripts/test-homebrew.mjs index 3e5e44463e..e8a31cf009 100644 --- a/.github/scripts/test-homebrew.mjs +++ b/.github/scripts/test-homebrew.mjs @@ -1,5 +1,6 @@ // Real Homebrew lifecycle test. Run on a disposable Mac with no installed vite-plus: // node .github/scripts/test-homebrew.mjs /absolute/path/to/artifacts +// Requires Homebrew and Node.js. CI runs with the test: install-e2e PR label. import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9ce13ab50c..9a2f66a24e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -171,22 +171,6 @@ gh extension install github/gh-stack Stacked pull requests require all branches to be in this repository; GitHub does not support cross-fork stacks ([reference](https://docs.github.com/en/pull-requests/reference/stacked-pull-requests)). If you contribute from a fork, split large work into a sequence of standalone PRs instead. -## Homebrew installation checks - -Add the `test: install-e2e` label to a pull request to run the Homebrew E2E job on macOS ARM64. New commits rerun the job while the label remains. Unlabeled pull requests and pushes to `main` skip this job. - -The job adapts the current official formula to build the tested commit and its pinned upstream resources. It checks first-run setup, bundled commands without npm access, management preferences, diagnostics, and removal. It also installs two formula revisions to check real `brew upgrade` and `brew cleanup` behavior, including cached Bash commands and saved shim paths. - -The `homebrew-install-e2e` artifact contains the source revisions, generated formulae, command output, and Homebrew build logs. Its `phase.txt` identifies the last build or test phase. A formula adaptation failure means the official formula changed and the adapter needs review. - -To run the same checks on a disposable Mac with Homebrew and Node.js: - -```bash -node .github/scripts/test-homebrew.mjs /absolute/path/to/homebrew-e2e-results -``` - -The script requires a committed checkout and refuses to run if `vite-plus` or its temporary `voidzero-e2e/install` tap already exists. Successful runs remove the test package and tap. - ## Verified Commits All commits in PR branches should be GitHub-verified so reviewers can confirm commit authenticity. From 76ee5f6357f87d57d7ce23fff96ed8c31036b2d5 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 17 Sep 2026 17:14:10 +0800 Subject: [PATCH 12/14] ci: simplify Homebrew test setup --- .../scripts/__tests__/homebrew-formula.mjs | 82 ------------------- .github/scripts/test-homebrew.mjs | 4 +- .github/workflows/ci.yml | 3 - 3 files changed, 2 insertions(+), 87 deletions(-) delete mode 100644 .github/scripts/__tests__/homebrew-formula.mjs diff --git a/.github/scripts/__tests__/homebrew-formula.mjs b/.github/scripts/__tests__/homebrew-formula.mjs deleted file mode 100644 index 5cb63bc4bf..0000000000 --- a/.github/scripts/__tests__/homebrew-formula.mjs +++ /dev/null @@ -1,82 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; - -import { prepareFormula } from '../homebrew-formula.mjs'; - -const official = `class VitePlus < Formula - url "https://example.com/v0.1.0.tar.gz" - sha256 "${'a'.repeat(64)}" - license "MIT" - - bottle do - sha256 arm64_tahoe: "published-bottle" - end - - resource "rolldown" do - url "https://github.com/rolldown/rolldown.git", - revision: "${'1'.repeat(40)}" - version "${'1'.repeat(40)}" - end - - resource "vite" do - url "https://github.com/vitejs/vite.git", - revision: "${'2'.repeat(40)}" - version "${'2'.repeat(40)}" - end - - def install - system "just", "build" - bin.install "vp" - end - - test do - system bin/"vp", "--version" - end -end -`; - -const inputs = { - url: 'file:///tmp/source%20archive.tar.gz', - sha256: 'b'.repeat(64), - version: '0.3.2', - revision: 1, - upstream: { - rolldown: { repo: 'https://github.com/rolldown/rolldown.git', hash: '3'.repeat(40) }, - vite: { repo: 'https://github.com/vitejs/vite.git', hash: '4'.repeat(40) }, - }, -}; - -test('updates source inputs and preserves the official installation and tests', () => { - const formula = prepareFormula(official, inputs); - assert(formula.includes(`url "${inputs.url}"`)); - assert(formula.includes(`sha256 "${inputs.sha256}"`)); - assert.match(formula, /version "0.3.2"\n revision 1/); - for (const { hash } of Object.values(inputs.upstream)) { - assert(formula.includes(`revision: "${hash}"\n version "${hash}"`)); - } - assert.doesNotMatch(formula, /bottle do|published-bottle|example.com/); - assert.equal( - formula.slice(formula.indexOf(' def install')), - official.slice(official.indexOf(' def install')), - ); -}); - -test('replaces an existing formula revision for a real Homebrew upgrade', () => { - const withRevision = official.replace(' license', ' version "0.1.0"\n revision 7\n license'); - const first = prepareFormula(withRevision, inputs); - const second = prepareFormula(withRevision, { ...inputs, revision: 2 }); - assert.equal(second, first.replace(' revision 1', ' revision 2')); -}); - -test('rejects formula changes that require a new source adapter', () => { - assert.throws( - () => prepareFormula(official.replace(' url', ' url'), inputs), - /layout changed/, - ); - assert.throws( - () => prepareFormula(official.replace(' resource "vite"', ' resource "renamed"'), inputs), - /layout changed/, - ); - assert.throws(() => prepareFormula(`${official}\n__END__\npatch`, inputs), /release patches/); - assert.throws(() => prepareFormula(official, { ...inputs, version: 'main"' })); -}); diff --git a/.github/scripts/test-homebrew.mjs b/.github/scripts/test-homebrew.mjs index e8a31cf009..829675ee58 100644 --- a/.github/scripts/test-homebrew.mjs +++ b/.github/scripts/test-homebrew.mjs @@ -38,6 +38,8 @@ function run(binary, args, options = {}) { function buildEnvironment(state) { const env = { ...process.env, HOME: state.home, PATH: state.path }; + // Only the candidate formula needs a local tap; resolve core dependencies via the API. + delete env.HOMEBREW_NO_INSTALL_FROM_API; if (state.ci === undefined) { delete env.CI; } else { @@ -51,7 +53,6 @@ function buildEnvironment(state) { return { ...env, HOMEBREW_NO_AUTO_UPDATE: '1', - HOMEBREW_NO_INSTALL_FROM_API: '1', HOMEBREW_NO_INSTALL_CLEANUP: '1', HOMEBREW_NO_ASK: '1', }; @@ -149,7 +150,6 @@ async function prepare() { JSON.stringify({ sha, coreSha, sha256, version: state.version, upstream }, null, 2), ); fs.writeFileSync(stateFile, JSON.stringify(state, null, 2)); - brewLogged(state, 'tap-core', ['tap', '--force', 'homebrew/core']); brew(state, ['tap-new', '--no-git', tap]); fs.copyFileSync(path.join(artifacts, 'vite-plus-1.rb'), state.formulaPath); if (spawnSync(state.brew, ['command', 'trust']).status === 0) { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c5709c312..b05307b496 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1351,9 +1351,6 @@ jobs: with: persist-credentials: false - - name: Test formula adaptation - run: node --test .github/scripts/__tests__/homebrew-formula.mjs - - name: Build and exercise Homebrew installation run: node .github/scripts/test-homebrew.mjs "$RUNNER_TEMP/homebrew-e2e" From 290daf89102482ab45d8c9f949d9c75fd51b07e5 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 17 Sep 2026 17:15:36 +0800 Subject: [PATCH 13/14] ci: update Homebrew before testing the current formula --- .github/scripts/test-homebrew.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/scripts/test-homebrew.mjs b/.github/scripts/test-homebrew.mjs index 829675ee58..a97d9b1592 100644 --- a/.github/scripts/test-homebrew.mjs +++ b/.github/scripts/test-homebrew.mjs @@ -100,6 +100,8 @@ async function prepare() { !brew(state, ['list', '--formula']).split('\n').includes('vite-plus'), 'Use a disposable runner: vite-plus is already installed', ); + // Runner images can carry an older Homebrew that cannot load current formulae. + brewLogged(state, 'update', ['update']); const tapPath = brew(state, ['--repository', tap]); assert(!fs.existsSync(tapPath), `Refusing to replace existing tap ${tapPath}`); state.prefix = brew(state, ['--prefix']); From cdc2219d1ae7de10327bc3a9a22a7af6ea6e83f3 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 17 Sep 2026 22:07:31 +0800 Subject: [PATCH 14/14] ci: remove conflicting runner pnpm links before Homebrew install --- .github/scripts/test-homebrew.mjs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/scripts/test-homebrew.mjs b/.github/scripts/test-homebrew.mjs index a97d9b1592..92b2d9751e 100644 --- a/.github/scripts/test-homebrew.mjs +++ b/.github/scripts/test-homebrew.mjs @@ -105,6 +105,18 @@ async function prepare() { const tapPath = brew(state, ['--repository', tap]); assert(!fs.existsSync(tapPath), `Refusing to replace existing tap ${tapPath}`); state.prefix = brew(state, ['--prefix']); + // Runner images install pnpm through npm, which blocks Homebrew's pnpm dependency. + for (const name of ['pnpm', 'pnpx']) { + const link = path.join(state.prefix, 'bin', name); + if ( + fs.lstatSync(link, { throwIfNoEntry: false })?.isSymbolicLink() && + path.resolve(path.dirname(link), fs.readlinkSync(link)) === + path.join(state.prefix, 'lib/node_modules/pnpm/bin', `${name}.cjs`) + ) { + console.log(`Removing npm-installed link ${link}`); + fs.unlinkSync(link); + } + } state.formulaPath = path.join(tapPath, 'Formula/vite-plus.rb'); const sha = run('git', ['rev-parse', 'HEAD'], { cwd: repo }).trim(); const coreSha = run('git', [