Skip to content

Commit a5f1d42

Browse files
committed
fix(dev): stabilize local path global installs
1 parent 558a9c9 commit a5f1d42

File tree

3 files changed

+147
-8
lines changed
  • crates/vite_global_cli/src/commands/env
  • packages/cli/snap-tests-global
    • npm-global-install-already-linked
    • npm-global-uninstall-vp-managed

3 files changed

+147
-8
lines changed

crates/vite_global_cli/src/commands/env/global_install.rs

Lines changed: 145 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
use std::{
44
collections::HashSet,
55
io::{Read, Write},
6+
path::Path,
67
process::Stdio,
78
};
89

@@ -113,6 +114,8 @@ pub async fn install(
113114

114115
let binary_infos = extract_binaries(&package_json);
115116

117+
materialize_symlinked_package_dir(&node_modules_dir).await?;
118+
116119
// Detect which binaries are JavaScript files
117120
let mut bin_names = Vec::new();
118121
let mut js_bins = HashSet::new();
@@ -142,7 +145,7 @@ pub async fn install(
142145
let packages_to_remove: HashSet<_> =
143146
conflicts.iter().map(|(_, pkg)| pkg.clone()).collect();
144147
for pkg in packages_to_remove {
145-
output::raw(&format!("Uninstalling {} (conflicts with {})...", pkg, package_name));
148+
output::raw(&format!("Uninstalling {} (conflicts with {})...", pkg, package_spec));
146149
// Use Box::pin to avoid recursive async type issues
147150
Box::pin(uninstall(&pkg, false)).await?;
148151
}
@@ -153,7 +156,7 @@ pub async fn install(
153156
return Err(Error::BinaryConflict {
154157
bin_name: conflicts[0].0.clone(),
155158
existing_package: conflicts[0].1.clone(),
156-
new_package: package_name.clone(),
159+
new_package: package_spec.to_string(),
157160
});
158161
}
159162
}
@@ -188,7 +191,7 @@ pub async fn install(
188191
// 8. Create shims for binaries and save per-binary configs
189192
let bin_dir = get_bin_dir()?;
190193
for bin_name in &bin_names {
191-
create_package_shim(&bin_dir, bin_name, &package_name).await?;
194+
create_package_shim(&bin_dir, bin_name, package_spec).await?;
192195

193196
// Write per-binary config
194197
let bin_config = BinConfig::new(
@@ -200,7 +203,7 @@ pub async fn install(
200203
bin_config.save().await?;
201204
}
202205

203-
output::raw(&format!("Installed {} v{}", package_name, installed_version));
206+
output::raw(&format!("Installed {} v{}", package_spec, installed_version));
204207
if !bin_names.is_empty() {
205208
output::raw(&format!("Binaries: {}", bin_names.join(", ")));
206209
}
@@ -269,6 +272,10 @@ pub async fn uninstall(package_name: &str, dry_run: bool) -> Result<(), Error> {
269272

270273
/// Parse package spec into name and optional version.
271274
fn parse_package_spec(spec: &str) -> (String, Option<String>) {
275+
if is_local_path(spec) {
276+
return (resolve_local_package_name(spec).unwrap_or_else(|| spec.to_string()), None);
277+
}
278+
272279
// Handle scoped packages: @scope/name@version
273280
if spec.starts_with('@') {
274281
// Find the second @ for version
@@ -287,6 +294,89 @@ fn parse_package_spec(spec: &str) -> (String, Option<String>) {
287294
(spec.to_string(), None)
288295
}
289296

297+
fn is_local_path(spec: &str) -> bool {
298+
spec == "."
299+
|| spec == ".."
300+
|| spec.starts_with("./")
301+
|| spec.starts_with("../")
302+
|| spec.starts_with('/')
303+
|| (cfg!(windows)
304+
&& spec.len() >= 3
305+
&& spec.as_bytes()[1] == b':'
306+
&& (spec.as_bytes()[2] == b'\\' || spec.as_bytes()[2] == b'/'))
307+
}
308+
309+
fn resolve_local_package_name(spec: &str) -> Option<String> {
310+
let pkg_json_path = current_dir().ok()?.join(spec).join("package.json");
311+
let content = std::fs::read_to_string(pkg_json_path.as_path()).ok()?;
312+
let json: serde_json::Value = serde_json::from_str(&content).ok()?;
313+
json.get("name").and_then(|name| name.as_str()).map(str::to_string)
314+
}
315+
316+
async fn materialize_symlinked_package_dir(node_modules_dir: &AbsolutePath) -> Result<(), Error> {
317+
let metadata = tokio::fs::symlink_metadata(node_modules_dir.as_path()).await?;
318+
if !metadata.file_type().is_symlink() {
319+
return Ok(());
320+
}
321+
322+
let symlink_target = tokio::fs::read_link(node_modules_dir.as_path()).await?;
323+
let resolved_target = if symlink_target.is_absolute() {
324+
symlink_target
325+
} else {
326+
node_modules_dir
327+
.parent()
328+
.expect("package dir should have a parent")
329+
.as_path()
330+
.join(&symlink_target)
331+
};
332+
333+
let package_dir_name =
334+
node_modules_dir.as_path().file_name().and_then(|name| name.to_str()).unwrap_or("package");
335+
let temp_dir = node_modules_dir.parent().expect("package dir should have a parent").as_path().join(
336+
format!(".{package_dir_name}-materialized"),
337+
);
338+
339+
if temp_dir.as_path().exists() {
340+
std::fs::remove_dir_all(&temp_dir)?;
341+
}
342+
343+
copy_dir_recursive(&resolved_target, &temp_dir)?;
344+
tokio::fs::remove_file(node_modules_dir.as_path()).await?;
345+
tokio::fs::rename(&temp_dir, node_modules_dir.as_path()).await?;
346+
347+
Ok(())
348+
}
349+
350+
fn copy_dir_recursive(source: &Path, destination: &Path) -> Result<(), Error> {
351+
std::fs::create_dir_all(destination)?;
352+
353+
for entry in std::fs::read_dir(source)? {
354+
let entry = entry?;
355+
let source_path = entry.path();
356+
let destination_path = destination.join(entry.file_name());
357+
let file_type = entry.file_type()?;
358+
359+
if file_type.is_dir() {
360+
copy_dir_recursive(&source_path, &destination_path)?;
361+
continue;
362+
}
363+
364+
if file_type.is_symlink() {
365+
let resolved_path = std::fs::canonicalize(&source_path)?;
366+
if resolved_path.is_dir() {
367+
copy_dir_recursive(&resolved_path, &destination_path)?;
368+
} else {
369+
std::fs::copy(&resolved_path, &destination_path)?;
370+
}
371+
continue;
372+
}
373+
374+
std::fs::copy(&source_path, &destination_path)?;
375+
}
376+
377+
Ok(())
378+
}
379+
290380
/// Binary info extracted from package.json.
291381
struct BinaryInfo {
292382
/// Binary name (the command users will run)
@@ -372,13 +462,13 @@ pub(crate) const CORE_SHIMS: &[&str] = &["node", "npm", "npx", "vp"];
372462
async fn create_package_shim(
373463
bin_dir: &vite_path::AbsolutePath,
374464
bin_name: &str,
375-
package_name: &str,
465+
package_display_name: &str,
376466
) -> Result<(), Error> {
377467
// Check for conflicts with core shims
378468
if CORE_SHIMS.contains(&bin_name) {
379469
output::warn(&format!(
380470
"Package '{}' provides '{}' binary, but it conflicts with a core shim. Skipping.",
381-
package_name, bin_name
471+
package_display_name, bin_name
382472
));
383473
return Ok(());
384474
}
@@ -729,6 +819,55 @@ mod tests {
729819
assert_eq!(version, Some("20.0.0".to_string()));
730820
}
731821

822+
#[test]
823+
fn test_parse_package_spec_local_path_uses_package_name() {
824+
use tempfile::TempDir;
825+
826+
let temp_dir = TempDir::new().unwrap();
827+
let package_dir = temp_dir.path().join("local-pkg");
828+
std::fs::create_dir_all(&package_dir).unwrap();
829+
std::fs::write(
830+
package_dir.join("package.json"),
831+
r#"{"name":"resolved-local-pkg","version":"1.0.0"}"#,
832+
)
833+
.unwrap();
834+
835+
let (name, version) = parse_package_spec(package_dir.to_str().unwrap());
836+
assert_eq!(name, "resolved-local-pkg");
837+
assert_eq!(version, None);
838+
}
839+
840+
#[test]
841+
#[cfg(unix)]
842+
fn test_materialize_symlinked_package_dir() {
843+
use tempfile::TempDir;
844+
use vite_path::AbsolutePathBuf;
845+
846+
let temp_dir = TempDir::new().unwrap();
847+
let source_dir = temp_dir.path().join("source-pkg");
848+
std::fs::create_dir_all(&source_dir).unwrap();
849+
std::fs::write(
850+
source_dir.join("package.json"),
851+
r#"{"name":"materialized-pkg","version":"1.0.0"}"#,
852+
)
853+
.unwrap();
854+
std::fs::write(source_dir.join("cli.js"), "console.log('ok')").unwrap();
855+
856+
let node_modules_parent = temp_dir.path().join("prefix/lib/node_modules");
857+
std::fs::create_dir_all(&node_modules_parent).unwrap();
858+
let symlink_path = node_modules_parent.join("materialized-pkg");
859+
std::os::unix::fs::symlink(&source_dir, &symlink_path).unwrap();
860+
861+
let symlink_path = AbsolutePathBuf::new(symlink_path).unwrap();
862+
let runtime = tokio::runtime::Runtime::new().unwrap();
863+
runtime.block_on(materialize_symlinked_package_dir(&symlink_path)).unwrap();
864+
865+
let metadata = std::fs::symlink_metadata(symlink_path.as_path()).unwrap();
866+
assert!(!metadata.file_type().is_symlink());
867+
assert!(symlink_path.as_path().join("package.json").exists());
868+
assert!(symlink_path.as_path().join("cli.js").exists());
869+
}
870+
732871
#[test]
733872
fn test_is_javascript_binary_with_js_extension() {
734873
use tempfile::TempDir;

packages/cli/snap-tests-global/npm-global-install-already-linked/snap.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ npm-global-linked-cli works
1414
> npm install -g ./npm-global-linked-pkg # Should NOT show hint (binary already exists)
1515

1616
added 1 package in <variable>ms
17-
Skipped 'npm-global-linked-cli': managed by `vp install -g ./npm-global-linked-pkg`. Run `vp uninstall -g ./npm-global-linked-pkg` to remove it first.
17+
Skipped 'npm-global-linked-cli': managed by `vp install -g npm-global-linked-pkg`. Run `vp uninstall -g npm-global-linked-pkg` to remove it first.
1818

1919
> vp remove -g npm-global-linked-pkg # Cleanup
2020
Uninstalled npm-global-linked-pkg

packages/cli/snap-tests-global/npm-global-uninstall-vp-managed/snap.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Binaries: npm-global-vp-managed-cli
88
> npm install -g ./npm-global-vp-managed-pkg # npm install (should warn about conflict)
99

1010
added 1 package in <variable>ms
11-
Skipped 'npm-global-vp-managed-cli': managed by `vp install -g ./npm-global-vp-managed-pkg`. Run `vp uninstall -g ./npm-global-vp-managed-pkg` to remove it first.
11+
Skipped 'npm-global-vp-managed-cli': managed by `vp install -g npm-global-vp-managed-pkg`. Run `vp uninstall -g npm-global-vp-managed-pkg` to remove it first.
1212

1313
> npm uninstall -g npm-global-vp-managed-pkg # npm uninstall should NOT remove the vp-managed shim
1414

0 commit comments

Comments
 (0)