Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
6fbffa0
feat(shim): add post-install hint for `npm install -g` in shim
fengmk2 Mar 6, 2026
8578b5f
test(shim): add snap tests for npm global install hint, remove unused…
fengmk2 Mar 6, 2026
6958718
fix(shim): use PATH-scope check for npm global install hint
fengmk2 Mar 6, 2026
0aab9e8
refactor(shim): replace `vp:` log prefix with standard output functions
fengmk2 Mar 6, 2026
6ad9c6c
fix(snap-test): remove hyphens from UUID in temp dir path
fengmk2 Mar 6, 2026
6260a04
fix(global-install): suppress npm output and clean up CLI messages
fengmk2 Mar 6, 2026
fa8867f
fix(shim): protect vp-managed shims from npm install/uninstall -g
fengmk2 Mar 6, 2026
2fed91b
refactor(shim): deduplicate npm global install/uninstall parsing
fengmk2 Mar 6, 2026
7f194f1
fix(shim): track npm-created bin links via BinConfig source field
fengmk2 Mar 6, 2026
79c7751
fix(shim): resolve relative --prefix and check package ownership on n…
fengmk2 Mar 6, 2026
f95665c
fix: address PR review comments for npm global shim dispatch
fengmk2 Mar 6, 2026
0392d15
fix(shim): fix Windows compilation errors in dispatch.rs
fengmk2 Mar 6, 2026
4b2a7a0
fix(shim): include package name in vp-managed conflict message
fengmk2 Mar 6, 2026
093e6f4
fix(shim): recreate shared bin link on ownership change and fix subco…
fengmk2 Mar 6, 2026
b217977
fix(shim): remove unused node_dir parameter from remove_npm_global_un…
fengmk2 Mar 6, 2026
668653e
fix(tools): strip leaked node bin dir from snap test PATH
fengmk2 Mar 6, 2026
677fc96
fix(tools): normalize npm "changed" to "added" in snap test output
fengmk2 Mar 6, 2026
89999e2
Revert "fix(tools): normalize npm "changed" to "added" in snap test o…
fengmk2 Mar 6, 2026
61440fa
fix(snap): add npm cleanup to already-linked test for stable output
fengmk2 Mar 6, 2026
a3216b7
fix(shim): deduplicate bin entries for multi-package npm installs
fengmk2 Mar 6, 2026
1824df7
fix(shim): preserve signal exit codes and fix Windows uninstall repair
fengmk2 Mar 6, 2026
82c4886
debug(shim): add logging to diagnose npm-global-install-already-linke…
fengmk2 Mar 7, 2026
8054ec3
debug(shim): use println instead of eprintln for debug logs
fengmk2 Mar 7, 2026
5459784
debug(shim): add dispatch-level debug logs for npm global install flow
fengmk2 Mar 7, 2026
44e6537
fix(ci): remove debug logs, bust binary cache, and stabilize snap test
fengmk2 Mar 7, 2026
7591bed
debug(shim): add tracing debug logs and VITE_LOG=trace for npm global…
fengmk2 Mar 7, 2026
136123e
debug(snap): add --verbose to npm install for CI debugging
fengmk2 Mar 7, 2026
fa314b5
fix(snap): clear VITE_PLUS_TOOL_RECURSION env var in snap tests
fengmk2 Mar 7, 2026
0e6559d
fix(shim): use absolute paths in exec tests for CI compatibility
fengmk2 Mar 7, 2026
702b6d5
fix(shim): recognize bare `.` and `..` as local paths in npm global i…
fengmk2 Mar 7, 2026
29f16b8
refactor(output): replace raw print calls with output module functions
fengmk2 Mar 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 121 additions & 2 deletions crates/vite_global_cli/src/commands/env/bin_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ use vite_path::AbsolutePathBuf;
use super::config::get_vite_plus_home;
use crate::error::Error;

/// Source that installed a binary.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BinSource {
/// Installed via `vp install -g` (managed shim)
#[default]
Vp,
/// Installed via `npm install -g` shim interception (direct symlink)
Npm,
}

/// Config for a single binary, stored at ~/.vite-plus/bins/{name}.json
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
Expand All @@ -25,12 +36,20 @@ pub struct BinConfig {
pub version: String,
/// Node.js version used
pub node_version: String,
/// How this binary was installed
#[serde(default)]
pub source: BinSource,
}

impl BinConfig {
/// Create a new BinConfig.
/// Create a new BinConfig with `Vp` source (used by `vp install -g`).
pub fn new(name: String, package: String, version: String, node_version: String) -> Self {
Self { name, package, version, node_version }
Self { name, package, version, node_version, source: BinSource::Vp }
}

/// Create a new BinConfig with `Npm` source (used by npm install -g interception).
pub fn new_npm(name: String, package: String, node_version: String) -> Self {
Self { name, package, version: String::new(), node_version, source: BinSource::Npm }
}

/// Get the bins directory path (~/.vite-plus/bins/).
Expand All @@ -43,6 +62,44 @@ impl BinConfig {
Ok(Self::bins_dir()?.join(format!("{bin_name}.json")))
}

/// Load config for a binary (synchronous).
pub fn load_sync(bin_name: &str) -> Result<Option<Self>, Error> {
let path = Self::path(bin_name)?;
match std::fs::read_to_string(path.as_path()) {
Ok(content) => {
let config: Self = serde_json::from_str(&content).map_err(|e| {
Error::ConfigError(format!("Failed to parse bin config: {e}").into())
})?;
Ok(Some(config))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}

/// Save config for a binary (synchronous).
pub fn save_sync(&self) -> Result<(), Error> {
let path = Self::path(&self.name)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = serde_json::to_string_pretty(self).map_err(|e| {
Error::ConfigError(format!("Failed to serialize bin config: {e}").into())
})?;
std::fs::write(path.as_path(), content)?;
Ok(())
}

/// Delete config for a binary (synchronous).
pub fn delete_sync(bin_name: &str) -> Result<(), Error> {
let path = Self::path(bin_name)?;
match std::fs::remove_file(path.as_path()) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}

/// Load config for a binary.
pub async fn load(bin_name: &str) -> Result<Option<Self>, Error> {
let path = Self::path(bin_name)?;
Expand Down Expand Up @@ -227,4 +284,66 @@ mod tests {
let loaded = BinConfig::load("nonexistent").await.unwrap();
assert!(loaded.is_none());
}

#[test]
fn test_source_defaults_to_vp() {
let config = BinConfig::new(
"tsc".to_string(),
"typescript".to_string(),
"5.0.0".to_string(),
"20.18.0".to_string(),
);
assert_eq!(config.source, BinSource::Vp);
}

#[test]
fn test_new_npm_source() {
let config = BinConfig::new_npm(
"codex".to_string(),
"@openai/codex".to_string(),
"22.22.0".to_string(),
);
assert_eq!(config.source, BinSource::Npm);
assert_eq!(config.name, "codex");
assert_eq!(config.package, "@openai/codex");
assert!(config.version.is_empty());
assert_eq!(config.node_version, "22.22.0");
}

#[test]
fn test_source_backward_compat_deserialize() {
// Old BinConfig files without "source" field should default to "vp"
let json =
r#"{"name":"tsc","package":"typescript","version":"5.0.0","nodeVersion":"20.18.0"}"#;
let config: BinConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.source, BinSource::Vp);
}

#[test]
fn test_sync_save_load_delete() {
let temp_dir = TempDir::new().unwrap();
let _guard = vite_shared::EnvConfig::test_guard(
vite_shared::EnvConfig::for_test_with_home(temp_dir.path()),
);

let config = BinConfig::new_npm(
"codex".to_string(),
"@openai/codex".to_string(),
"22.22.0".to_string(),
);
config.save_sync().unwrap();

let loaded = BinConfig::load_sync("codex").unwrap();
assert!(loaded.is_some());
let loaded = loaded.unwrap();
assert_eq!(loaded.source, BinSource::Npm);
assert_eq!(loaded.package, "@openai/codex");

BinConfig::delete_sync("codex").unwrap();
let loaded = BinConfig::load_sync("codex").unwrap();
assert!(loaded.is_none());

// Delete again should not error
BinConfig::delete_sync("codex").unwrap();
}
}
66 changes: 37 additions & 29 deletions crates/vite_global_cli/src/commands/env/global_install.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
//! Global package installation handling.

use std::{collections::HashSet, io::Read, process::Stdio};
use std::{
collections::HashSet,
io::{Read, Write},
process::Stdio,
};

use tokio::process::Command;
use vite_js_runtime::NodeProvider;
use vite_path::{AbsolutePath, current_dir};
use vite_shared::format_path_prepended;
use vite_shared::{format_path_prepended, output};

use super::{
bin_config::BinConfig,
Expand All @@ -29,7 +33,7 @@ pub async fn install(
// Parse package spec (e.g., "typescript", "typescript@5.0.0", "@scope/pkg")
let (package_name, _version_spec) = parse_package_spec(package_spec);

println!(" Installing {} globally...", package_spec);
output::raw(&format!("Installing {} globally...", package_spec));

// 1. Resolve Node.js version
let version = if let Some(v) = node_version {
Expand Down Expand Up @@ -63,22 +67,24 @@ pub async fn install(
tokio::fs::create_dir_all(&staging_dir).await?;

// 4. Run npm install with prefix set to staging directory
println!(" Running npm install...");

let status = Command::new(npm_path.as_path())
.args(["install", "-g", package_spec])
// Pipe stdout/stderr so npm output is hidden on success, shown on failure
let output = Command::new(npm_path.as_path())
.args(["install", "-g", "--no-fund", package_spec])
.env("npm_config_prefix", staging_dir.as_path())
.env("PATH", format_path_prepended(node_bin_dir.as_path()))
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await?;

if !status.success() {
if !output.status.success() {
// Clean up staging directory
let _ = tokio::fs::remove_dir_all(&staging_dir).await;
// Show captured output to help debug the failure
let _ = std::io::stdout().write_all(&output.stdout);
let _ = std::io::stderr().write_all(&output.stderr);
return Err(Error::ConfigError(
format!("npm install failed with exit code: {:?}", status.code()).into(),
format!("npm install failed with exit code: {:?}", output.status.code()).into(),
));
}

Expand Down Expand Up @@ -136,7 +142,7 @@ pub async fn install(
let packages_to_remove: HashSet<_> =
conflicts.iter().map(|(_, pkg)| pkg.clone()).collect();
for pkg in packages_to_remove {
println!(" Uninstalling {} (conflicts with {})...", pkg, package_name);
output::raw(&format!("Uninstalling {} (conflicts with {})...", pkg, package_name));
// Use Box::pin to avoid recursive async type issues
Box::pin(uninstall(&pkg, false)).await?;
}
Expand Down Expand Up @@ -194,9 +200,9 @@ pub async fn install(
bin_config.save().await?;
}

println!(" Installed {} v{}", package_name, installed_version);
output::raw(&format!("Installed {} v{}", package_name, installed_version));
if !bin_names.is_empty() {
println!(" Binaries: {}", bin_names.join(", "));
output::raw(&format!("Binaries: {}", bin_names.join(", ")));
}

Ok(())
Expand Down Expand Up @@ -230,17 +236,15 @@ pub async fn uninstall(package_name: &str, dry_run: bool) -> Result<(), Error> {
let package_dir = packages_dir.join(&package_name);
let metadata_path = PackageMetadata::metadata_path(&package_name)?;

println!(" Would uninstall {}:", package_name);
output::raw(&format!("Would uninstall {}:", package_name));
for bin_name in &bins {
println!(" - shim: {}", bin_dir.join(bin_name).as_path().display());
output::raw(&format!(" - shim: {}", bin_dir.join(bin_name).as_path().display()));
}
println!(" - package dir: {}", package_dir.as_path().display());
println!(" - metadata: {}", metadata_path.as_path().display());
output::raw(&format!(" - package dir: {}", package_dir.as_path().display()));
output::raw(&format!(" - metadata: {}", metadata_path.as_path().display()));
return Ok(());
}

println!(" Uninstalling {}...", package_name);

// Remove shims and bin configs
let bin_dir = get_bin_dir()?;
for bin_name in &bins {
Expand All @@ -258,7 +262,7 @@ pub async fn uninstall(package_name: &str, dry_run: bool) -> Result<(), Error> {
// Remove metadata file
PackageMetadata::delete(&package_name).await?;

println!(" Uninstalled {}", package_name);
output::raw(&format!("Uninstalled {}", package_name));

Ok(())
}
Expand Down Expand Up @@ -359,7 +363,7 @@ fn is_javascript_binary(path: &AbsolutePath) -> bool {
}

/// Core shims that should not be overwritten by package binaries.
const CORE_SHIMS: &[&str] = &["node", "npm", "npx", "vp"];
pub(crate) const CORE_SHIMS: &[&str] = &["node", "npm", "npx", "vp"];

/// Create a shim for a package binary.
///
Expand All @@ -372,10 +376,10 @@ async fn create_package_shim(
) -> Result<(), Error> {
// Check for conflicts with core shims
if CORE_SHIMS.contains(&bin_name) {
println!(
" Warning: Package '{}' provides '{}' binary, but it conflicts with a core shim. Skipping.",
output::warn(&format!(
"Package '{}' provides '{}' binary, but it conflicts with a core shim. Skipping.",
package_name, bin_name
);
));
return Ok(());
}

Expand All @@ -386,9 +390,13 @@ async fn create_package_shim(
{
let shim_path = bin_dir.join(bin_name);

// Skip if already exists (e.g., re-installing the same package)
if tokio::fs::try_exists(&shim_path).await.unwrap_or(false) {
return Ok(());
// Check if already a managed shim (symlink to ../current/bin/vp)
if let Ok(target) = tokio::fs::read_link(&shim_path).await {
if target == std::path::Path::new("../current/bin/vp") {
return Ok(());
}
// Exists but points elsewhere (e.g., npm-installed direct symlink) — replace it
tokio::fs::remove_file(&shim_path).await?;
}

// Create symlink to ../current/bin/vp
Expand Down
Loading
Loading