diff --git a/packages/cli/binding/src/cli/mod.rs b/packages/cli/binding/src/cli/mod.rs index 872e0802e3..5033a24339 100644 --- a/packages/cli/binding/src/cli/mod.rs +++ b/packages/cli/binding/src/cli/mod.rs @@ -60,6 +60,7 @@ async fn execute_direct_subcommand( .map(|(k, v)| (Arc::from(k.as_os_str()), Arc::from(v.as_os_str()))) .collect(), ); + let envs = envs_with_explicit_package_manager_path(cwd, envs).await?; let status = match subcommand { SynthesizableSubcommand::Check { @@ -113,6 +114,96 @@ async fn execute_direct_subcommand( Ok(status) } +fn is_path_env_key(key: &OsStr) -> bool { + if cfg!(windows) { key.eq_ignore_ascii_case("PATH") } else { key == "PATH" } +} + +fn try_prepend_to_env_path( + envs: &Arc, Arc>>, + bin_prefix: &AbsolutePath, +) -> Result, Arc>>, Error> { + let path_key = envs + .keys() + .find(|key| is_path_env_key(key.as_ref())) + .cloned() + .unwrap_or_else(|| Arc::from(OsStr::new("PATH"))); + let current_path = + envs.get(&path_key).map_or_else(Default::default, |path| path.to_os_string()); + let paths = if current_path.is_empty() { + Vec::new() + } else { + env::split_paths(¤t_path).collect::>() + }; + + if paths.first().is_some_and(|path| path == bin_prefix.as_path()) { + return Ok(Arc::clone(envs)); + } + + let new_path = env::join_paths( + std::iter::once(bin_prefix.as_path().to_path_buf()).chain(paths.into_iter()), + ) + .map_err(|error| Error::Anyhow(anyhow::Error::new(error)))?; + + let mut envs = FxHashMap::clone(envs); + envs.insert(path_key, Arc::from(new_path.as_os_str())); + Ok(Arc::new(envs)) +} + +fn prepend_to_env_path( + envs: &Arc, Arc>>, + bin_prefix: &AbsolutePath, +) -> Arc, Arc>> { + match try_prepend_to_env_path(envs, bin_prefix) { + Ok(updated_envs) => updated_envs, + Err(error) => { + tracing::debug!( + ?error, + "failed to prepend managed package manager bin to direct command PATH" + ); + Arc::clone(envs) + } + } +} + +async fn envs_with_explicit_package_manager_path( + cwd: &AbsolutePath, + envs: Arc, Arc>>, +) -> Result, Arc>>, Error> { + let Some(resolution) = + (match vite_install::package_manager::resolve_package_manager_from_package_json(cwd) { + Ok(resolution) => resolution, + Err(error) => { + tracing::debug!( + ?error, + "failed to resolve explicit packageManager for direct command PATH setup" + ); + return Ok(envs); + } + }) + else { + return Ok(envs); + }; + + let (install_dir, _, _) = match vite_install::download_package_manager( + resolution.package_manager_type, + &resolution.version, + resolution.hash.as_deref(), + ) + .await + { + Ok(result) => result, + Err(error) => { + tracing::debug!( + ?error, + "failed to ensure managed package manager for direct command PATH setup" + ); + return Ok(envs); + } + }; + + Ok(prepend_to_env_path(&envs, &install_dir.join("bin"))) +} + /// Execute a vite-task command (run, cache) through Session. async fn execute_vite_task_command( command: vite_task::Command, @@ -224,10 +315,136 @@ async fn execute_pm_command( #[cfg(test)] mod tests { - use std::path::PathBuf; + use std::{ + ffi::OsStr, + fs, + path::PathBuf, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, + }; + use rustc_hash::FxHashMap; + use vite_path::AbsolutePathBuf; use vite_task::config::UserRunConfig; + use super::{envs_with_explicit_package_manager_path, prepend_to_env_path}; + + fn envs_with_path(path: &std::ffi::OsStr) -> Arc, Arc>> { + Arc::new(FxHashMap::from_iter([(Arc::from(OsStr::new("PATH")), Arc::from(path))])) + } + + #[test] + fn prepends_package_manager_bin_to_env_path() { + let cwd = std::env::current_dir().expect("current_dir should exist"); + let old_bin = cwd.join("old-bin"); + let pm_bin = AbsolutePathBuf::new(cwd.join("pm-bin")).expect("pm bin should be absolute"); + let original_path = std::env::join_paths([old_bin.as_path()]).expect("valid PATH"); + let envs = envs_with_path(original_path.as_os_str()); + + let updated = prepend_to_env_path(&envs, &pm_bin); + let path_value = updated.get(OsStr::new("PATH")).expect("PATH should exist"); + let paths = std::env::split_paths(path_value).collect::>(); + + assert_eq!(paths.first().map(std::path::PathBuf::as_path), Some(pm_bin.as_path())); + assert_eq!(paths.get(1).map(std::path::PathBuf::as_path), Some(old_bin.as_path())); + } + + #[test] + fn does_not_duplicate_package_manager_bin_when_already_first() { + let cwd = std::env::current_dir().expect("current_dir should exist"); + let pm_bin = AbsolutePathBuf::new(cwd.join("pm-bin")).expect("pm bin should be absolute"); + let original_path = std::env::join_paths([pm_bin.as_path()]).expect("valid PATH"); + let envs = envs_with_path(original_path.as_os_str()); + + let updated = prepend_to_env_path(&envs, &pm_bin); + let path_value = updated.get(OsStr::new("PATH")).expect("PATH should exist"); + let paths = std::env::split_paths(path_value).collect::>(); + + assert_eq!(paths, vec![pm_bin.as_path().to_path_buf()]); + } + + #[test] + fn creates_path_when_env_map_has_no_path() { + let cwd = std::env::current_dir().expect("current_dir should exist"); + let pm_bin = AbsolutePathBuf::new(cwd.join("pm-bin")).expect("pm bin should be absolute"); + let envs = Arc::new(FxHashMap::default()); + + let updated = prepend_to_env_path(&envs, &pm_bin); + let path_value = updated.get(OsStr::new("PATH")).expect("PATH should be created"); + let paths = std::env::split_paths(path_value).collect::>(); + + assert_eq!(paths, vec![pm_bin.as_path().to_path_buf()]); + } + + #[test] + fn preserves_path_key_casing_on_windows() { + let cwd = std::env::current_dir().expect("current_dir should exist"); + let old_bin = cwd.join("old-bin"); + let pm_bin = AbsolutePathBuf::new(cwd.join("pm-bin")).expect("pm bin should be absolute"); + let original_path = std::env::join_paths([old_bin.as_path()]).expect("valid PATH"); + let key = if cfg!(windows) { "Path" } else { "PATH" }; + let envs = Arc::new(FxHashMap::from_iter([( + Arc::from(OsStr::new(key)), + Arc::from(original_path.as_os_str()), + )])); + + let updated = prepend_to_env_path(&envs, &pm_bin); + let path_value = updated.get(OsStr::new(key)).expect("existing PATH key should be updated"); + let paths = std::env::split_paths(path_value).collect::>(); + + assert_eq!(paths.first().map(std::path::PathBuf::as_path), Some(pm_bin.as_path())); + assert_eq!(paths.get(1).map(std::path::PathBuf::as_path), Some(old_bin.as_path())); + } + + #[tokio::test] + async fn ignores_invalid_explicit_package_manager() { + let suffix = + SystemTime::now().duration_since(UNIX_EPOCH).expect("time should be valid").as_nanos(); + let temp_dir = std::env::temp_dir().join(format!("vite-plus-invalid-pm-{suffix}")); + fs::create_dir_all(&temp_dir).expect("temp dir should be created"); + fs::write( + temp_dir.join("package.json"), + r#"{"name":"fixture","packageManager":"unknown@1.0.0"}"#, + ) + .expect("package.json should be written"); + let cwd = AbsolutePathBuf::new(temp_dir.clone()).expect("temp dir should be absolute"); + let original_path = std::env::join_paths([temp_dir.join("old-bin")]).expect("valid PATH"); + let envs = envs_with_path(original_path.as_os_str()); + + let updated = envs_with_explicit_package_manager_path(&cwd, Arc::clone(&envs)) + .await + .expect("package manager preflight errors should not fail direct commands"); + + assert_eq!(updated.get(OsStr::new("PATH")), envs.get(OsStr::new("PATH"))); + fs::remove_dir_all(temp_dir).expect("temp dir should be removed"); + } + + #[tokio::test] + async fn ignores_lockfile_without_explicit_package_manager() { + let suffix = + SystemTime::now().duration_since(UNIX_EPOCH).expect("time should be valid").as_nanos(); + let temp_dir = std::env::temp_dir().join(format!("vite-plus-no-pm-{suffix}")); + fs::create_dir_all(&temp_dir).expect("temp dir should be created"); + fs::write(temp_dir.join("package.json"), r#"{"name":"fixture"}"#) + .expect("package.json should be written"); + fs::write(temp_dir.join("pnpm-lock.yaml"), "lockfileVersion: '9.0'\n") + .expect("lockfile should be written"); + let cwd = AbsolutePathBuf::new(temp_dir.clone()).expect("temp dir should be absolute"); + let original_path = std::env::join_paths([temp_dir.join("old-bin")]).expect("valid PATH"); + let envs = envs_with_path(original_path.as_os_str()); + + let updated = envs_with_explicit_package_manager_path(&cwd, Arc::clone(&envs)) + .await + .expect("missing packageManager should not error"); + + assert_eq!(updated.get(OsStr::new("PATH")), envs.get(OsStr::new("PATH"))); + assert_eq!( + fs::read_to_string(temp_dir.join("package.json")).expect("package.json should exist"), + r#"{"name":"fixture"}"# + ); + fs::remove_dir_all(temp_dir).expect("temp dir should be removed"); + } + #[test] fn run_config_types_in_sync() { // Remove \r for cross-platform consistency diff --git a/packages/cli/snap-tests-global/test-managed-package-manager-path/package.json b/packages/cli/snap-tests-global/test-managed-package-manager-path/package.json new file mode 100644 index 0000000000..c54481aefb --- /dev/null +++ b/packages/cli/snap-tests-global/test-managed-package-manager-path/package.json @@ -0,0 +1,6 @@ +{ + "name": "test-managed-package-manager-path", + "private": true, + "type": "module", + "packageManager": "pnpm@11.2.2" +} diff --git a/packages/cli/snap-tests-global/test-managed-package-manager-path/snap.txt b/packages/cli/snap-tests-global/test-managed-package-manager-path/snap.txt new file mode 100644 index 0000000000..3d83ee3c9e --- /dev/null +++ b/packages/cli/snap-tests-global/test-managed-package-manager-path/snap.txt @@ -0,0 +1,10 @@ +> sh -c 'vp_bin=$(command -v vp) && node_path=$(command -v node) && node_bin=$(mktemp -d) && ln -s "$node_path" "$node_bin/node" && sanitized_path="$node_bin:/bin:/usr/bin" && PATH="$sanitized_path" node --version >/dev/null && if PATH="$sanitized_path" command -v pnpm >/dev/null; then echo "pnpm unexpectedly available on sanitized PATH"; exit 1; fi && PATH="$sanitized_path" "$vp_bin" test --slowTestThreshold 10000' + RUN + + ✓ src/managed-pm-path.test.ts (1 test) ms + + Test Files 1 passed (1) + Tests 1 passed (1) + Start at + Duration ms (transform ms, setup ms, import ms, tests ms, environment ms) + diff --git a/packages/cli/snap-tests-global/test-managed-package-manager-path/src/managed-pm-path.test.ts b/packages/cli/snap-tests-global/test-managed-package-manager-path/src/managed-pm-path.test.ts new file mode 100644 index 0000000000..1e9c1ffb06 --- /dev/null +++ b/packages/cli/snap-tests-global/test-managed-package-manager-path/src/managed-pm-path.test.ts @@ -0,0 +1,8 @@ +import { execFileSync } from 'node:child_process'; + +import { expect, test } from '@voidzero-dev/vite-plus-test'; + +test('direct test command exposes the configured package manager on PATH', () => { + const version = execFileSync('pnpm', ['--version'], { encoding: 'utf8' }).trim(); + expect(version).toBe('11.2.2'); +}); diff --git a/packages/cli/snap-tests-global/test-managed-package-manager-path/steps.json b/packages/cli/snap-tests-global/test-managed-package-manager-path/steps.json new file mode 100644 index 0000000000..e119af84ef --- /dev/null +++ b/packages/cli/snap-tests-global/test-managed-package-manager-path/steps.json @@ -0,0 +1,6 @@ +{ + "ignoredPlatforms": ["win32"], + "commands": [ + "sh -c 'vp_bin=$(command -v vp) && node_path=$(command -v node) && node_bin=$(mktemp -d) && ln -s \"$node_path\" \"$node_bin/node\" && sanitized_path=\"$node_bin:/bin:/usr/bin\" && PATH=\"$sanitized_path\" node --version >/dev/null && if PATH=\"$sanitized_path\" command -v pnpm >/dev/null; then echo \"pnpm unexpectedly available on sanitized PATH\"; exit 1; fi && PATH=\"$sanitized_path\" \"$vp_bin\" test --slowTestThreshold 10000'" + ] +} diff --git a/vite.config.ts b/vite.config.ts index 18e9c63990..4ddb9187f3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -55,6 +55,7 @@ export default defineConfig({ './rolldown/**', '**/node_modules/**', '**/snap-tests/**', + '**/snap-tests-global/**', // FIXME: Error: failed to prepare the command for injection: Invalid argument (os error 22) 'packages/*/binding/__tests__/', ],