Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 0 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,6 @@ jobs:
Get-Command tsc

# Test 2: Verify the package was installed correctly
Get-ChildItem (Join-Path $vpHome "packages/typescript")
Comment thread
liangmiQwQ marked this conversation as resolved.
Get-ChildItem $vpBin

# Test 3: Uninstall
Expand Down Expand Up @@ -386,7 +385,6 @@ jobs:
which tsc

# Test 2: Verify the package was installed correctly
ls -la ~/.vite-plus/packages/typescript/
ls -la ~/.vite-plus/bin/

# Test 3: Uninstall
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/vite_global_cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ vite_setup = { workspace = true }
vite_shared = { workspace = true }
vite_str = { workspace = true }
vite_workspace = { workspace = true }
uuid = { workspace = true, features = ["v4"] }

[dev-dependencies]
serial_test = { workspace = true }
Expand Down
5 changes: 0 additions & 5 deletions crates/vite_global_cli/src/commands/env/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,6 @@ pub fn get_packages_dir() -> Result<AbsolutePathBuf, Error> {
Ok(get_vp_home()?.join("packages"))
}

/// Get the tmp directory path for staging (~/.vite-plus/tmp/).
pub fn get_tmp_dir() -> Result<AbsolutePathBuf, Error> {
Ok(get_vp_home()?.join("tmp"))
}

/// Get the node_modules directory path for a package.
///
/// npm uses different layouts on Unix vs Windows:
Expand Down
82 changes: 82 additions & 0 deletions crates/vite_global_cli/src/commands/env/package_metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,25 @@ use std::collections::HashSet;

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::{Uuid, Version};
use vite_path::AbsolutePathBuf;

use super::config::get_packages_dir;
use crate::error::Error;

// `#` is filesystem-safe but invalid in npm package names, so sibling installs cannot collide.
pub(crate) const INSTALL_ID_PREFIX: char = '#';
// Keeps npm's 214-byte maximum package name within the common 255-byte filename limit.
pub(crate) const INSTALL_ID_LENGTH: usize = 37;

pub(crate) fn is_install_id(value: &str) -> bool {
value.len() == INSTALL_ID_LENGTH
&& value
.strip_prefix(INSTALL_ID_PREFIX)
.and_then(|uuid| Uuid::parse_str(uuid).ok())
.is_some_and(|uuid| uuid.get_version() == Some(Version::Random))
}

/// Metadata for a globally installed package.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
Expand All @@ -17,6 +31,9 @@ pub struct PackageMetadata {
pub name: String,
/// Package version
pub version: String,
/// Directory identifier for this installation. Empty for legacy installs.
#[serde(default)]
pub install_id: String,
/// Platform versions used during installation
pub platform: Platform,
/// Binary names provided by this package
Expand Down Expand Up @@ -59,6 +76,7 @@ impl PackageMetadata {
Self {
name,
version,
install_id: String::new(),
platform: Platform { node: node_version, npm: npm_version },
bins,
js_bins,
Expand All @@ -73,6 +91,28 @@ impl PackageMetadata {
self.js_bins.contains(bin_name)
}

/// Get the package installation prefix.
pub fn installation_dir(&self) -> Result<AbsolutePathBuf, Error> {
Self::installation_dir_for(&self.name, &self.install_id)
}

/// Resolve an installation prefix, including the legacy empty-ID layout.
pub fn installation_dir_for(
package_name: &str,
install_id: &str,
) -> Result<AbsolutePathBuf, Error> {
let packages_dir = get_packages_dir()?;
if install_id.is_empty() {
Ok(packages_dir.join(package_name))
} else if is_install_id(install_id) {
Ok(packages_dir.join(format!("{package_name}{install_id}")))
} else {
Err(Error::ConfigError(
format!("Invalid global package install ID: {install_id}").into(),
))
}
}

/// Get the metadata file path for a package.
pub fn metadata_path(package_name: &str) -> Result<AbsolutePathBuf, Error> {
let packages_dir = get_packages_dir()?;
Expand Down Expand Up @@ -202,6 +242,48 @@ mod tests {
);
}

#[test]
fn test_legacy_metadata_defaults_to_empty_install_id() {
let metadata: PackageMetadata = serde_json::from_str(
r#"{
"name": "typescript",
"version": "5.9.3",
"platform": { "node": "22.0.0" },
"bins": ["tsc"],
"manager": "npm",
"installedAt": "2026-01-01T00:00:00Z"
}"#,
)
.unwrap();

assert!(metadata.install_id.is_empty());
}

#[test]
fn test_installation_dir_uses_install_id() {
use tempfile::TempDir;

let temp_dir = TempDir::new().unwrap();
let _guard = vite_shared::EnvConfig::test_guard(
vite_shared::EnvConfig::for_test_with_home(temp_dir.path()),
);

let legacy = PackageMetadata::installation_dir_for("@scope/pkg", "").unwrap();
let identified = PackageMetadata::installation_dir_for(
"@scope/pkg",
"#123e4567-e89b-42d3-a456-426614174000",
)
.unwrap();

assert!(legacy.as_path().ends_with("packages/@scope/pkg"));
assert!(
identified
.as_path()
.ends_with("packages/@scope/pkg#123e4567-e89b-42d3-a456-426614174000")
);
assert!(PackageMetadata::installation_dir_for("@scope/pkg", "invalid").is_err());
}

#[tokio::test]
async fn test_save_scoped_package_metadata() {
use tempfile::TempDir;
Expand Down
15 changes: 9 additions & 6 deletions crates/vite_global_cli/src/commands/env/which.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use vite_path::{AbsolutePath, AbsolutePathBuf};
use vite_shared::output;

use super::{
config::{VERSION_ENV_VAR, get_node_modules_dir, get_packages_dir, resolve_version},
config::{VERSION_ENV_VAR, get_node_modules_dir, resolve_version},
package_metadata::PackageMetadata,
};
use crate::error::Error;
Expand All @@ -43,7 +43,7 @@ pub async fn execute(cwd: AbsolutePathBuf, tool: &str) -> Result<ExitStatus, Err
// state is unusable, so the diagnostic matches what actually runs.
if tool == "corepack" {
match crate::shim::dispatch::find_package_for_binary(tool).await {
Ok(Some(metadata)) => match locate_package_binary(&metadata.name, tool) {
Ok(Some(metadata)) => match locate_package_binary(&metadata, tool) {
Ok(_) => return execute_package_binary(tool, &metadata).await,
Err(e) => warn_unusable_managed_corepack(&e.to_string()),
},
Expand Down Expand Up @@ -188,7 +188,7 @@ async fn execute_package_binary(
metadata: &PackageMetadata,
) -> Result<ExitStatus, Error> {
// Locate the binary path
let binary_path = locate_package_binary(&metadata.name, tool)?;
let binary_path = locate_package_binary(metadata, tool)?;

// Check if binary exists
if !tokio::fs::try_exists(&binary_path).await.unwrap_or(false) {
Expand Down Expand Up @@ -219,9 +219,12 @@ async fn execute_package_binary(
}

/// Locate a binary within a package's installation directory.
fn locate_package_binary(package_name: &str, binary_name: &str) -> Result<AbsolutePathBuf, Error> {
let packages_dir = get_packages_dir()?;
let package_dir = packages_dir.join(package_name);
fn locate_package_binary(
metadata: &PackageMetadata,
binary_name: &str,
) -> Result<AbsolutePathBuf, Error> {
let package_dir = metadata.installation_dir()?;
let package_name = &metadata.name;

// The binary is referenced in package.json's bin field
// npm uses different layouts: Unix=lib/node_modules, Windows=node_modules
Expand Down
Loading
Loading