diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_lifecycle_env/check-env.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_lifecycle_env/check-env.js new file mode 100644 index 0000000000..3f11cac548 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_lifecycle_env/check-env.js @@ -0,0 +1,17 @@ +// Surfaces the package-manager lifecycle env that `vp run` stamps for +// package.json scripts (#2317): before the fix every variable below printed +// `(undefined)`, so child tooling (npm-run-all, `ni`) could not detect pnpm +// and fell back to npm. The stamp is deliberately limited to the two +// package-manager detection channels; Bun remains outside that narrow +// compatibility contract. The user-agent platform/arch tail (`linux x64`) +// and pnpm native executable suffix are normalized across platforms. +const vars = ['npm_execpath', 'npm_config_user_agent']; +for (const name of vars) { + let value = process.env[name] ?? '(undefined)'; + if (name === 'npm_config_user_agent') { + value = value.replace(`${process.platform} ${process.arch}`, ' '); + } else { + value = value.replace(/pnpm\.native\.exe$/, 'pnpm.native'); + } + console.log(`${name}=${value}`); +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_lifecycle_env/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_lifecycle_env/package.json new file mode 100644 index 0000000000..e37829ce61 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_lifecycle_env/package.json @@ -0,0 +1,7 @@ +{ + "name": "vite-task-lifecycle-env", + "private": true, + "scripts": { + "check-env": "node check-env.js" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_lifecycle_env/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_lifecycle_env/snapshots.toml new file mode 100644 index 0000000000..e1fa0552e2 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_lifecycle_env/snapshots.toml @@ -0,0 +1,66 @@ +[[case]] +name = "vite_task_lifecycle_env_npm" +vp = "local" +comment = """ +Regression test for #2317 using Vite+'s real managed npm distribution. `vp run` +stamps npm's CLI entry and user agent for package-manager detection. +""" +steps = [ + { argv = ["vpt", "json-edit", "package.json", "packageManager", "npm@10.9.8"], snapshot = false }, + { argv = ["npm", "--version"], comment = "Execute the pinned npm binary", timeout = 120000 }, + { argv = ["vp", "run", "check-env"] }, +] + +[[case]] +name = "vite_task_lifecycle_env_yarn" +vp = "local" +env = { YARN_ENABLE_TELEMETRY = "0" } +comment = """ +Regression test for #2317 using Vite+'s real managed Yarn distribution. `vp run` +stamps Yarn's CLI entry and user agent for package-manager detection. +""" +steps = [ + { argv = ["vpt", "json-edit", "package.json", "packageManager", "yarn@4.17.1"], snapshot = false }, + { argv = ["yarn", "--version"], comment = "Execute the pinned Yarn binary", timeout = 120000 }, + { argv = ["vp", "run", "check-env"] }, +] + +[[case]] +name = "vite_task_lifecycle_env_pnpm11" +vp = "local" +comment = """ +Regression test for #2317 using Vite+'s real managed pnpm 11 distribution. +`vp run` stamps pnpm's JavaScript CLI entry and Node-backed user agent. +""" +steps = [ + { argv = ["vpt", "json-edit", "package.json", "packageManager", "pnpm@11.20.0"], snapshot = false }, + { argv = ["pnpm", "--version"], comment = "Execute the pinned pnpm 11 binary", timeout = 120000 }, + { argv = ["vp", "run", "check-env"] }, +] + +[[case]] +name = "vite_task_lifecycle_env_pnpm12" +vp = "local" +comment = """ +Regression test for #2317 using Vite+'s real managed native pnpm 12 +distribution. `npm_execpath` uses the native entry and the user agent reports +`node/?` instead of leaking the Node.js version that hosts Vite+. +""" +steps = [ + { argv = ["vpt", "json-edit", "package.json", "packageManager", "pnpm@12.0.0"], snapshot = false }, + { argv = ["pnpm", "--version"], comment = "Execute the pinned native pnpm 12 binary", timeout = 120000 }, + { argv = ["vp", "run", "check-env"] }, +] + +[[case]] +name = "vite_task_lifecycle_env_bun" +vp = "local" +comment = """ +Boundary coverage with Vite+'s real managed Bun distribution. The narrow #2317 +fix does not emulate Bun's lifecycle environment, so both values stay undefined. +""" +steps = [ + { argv = ["vpt", "json-edit", "package.json", "packageManager", "bun@1.3.14"], snapshot = false }, + { argv = ["bun", "--version"], comment = "Execute the pinned Bun binary", timeout = 120000 }, + { argv = ["vp", "run", "check-env"] }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_lifecycle_env/snapshots/vite_task_lifecycle_env_bun.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_lifecycle_env/snapshots/vite_task_lifecycle_env_bun.md new file mode 100644 index 0000000000..411d74a3e9 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_lifecycle_env/snapshots/vite_task_lifecycle_env_bun.md @@ -0,0 +1,23 @@ +# vite_task_lifecycle_env_bun + +Boundary coverage with Vite+'s real managed Bun distribution. The narrow #2317 +fix does not emulate Bun's lifecycle environment, so both values stay undefined. + +## `vpt json-edit package.json packageManager bun@1.3.14` + + +## `bun --version` + +Execute the pinned Bun binary + +``` +1.3.14 +``` + +## `vp run check-env` + +``` +$ node check-env.js ⊘ cache disabled +npm_execpath=(undefined) +npm_config_user_agent=(undefined) +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_lifecycle_env/snapshots/vite_task_lifecycle_env_npm.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_lifecycle_env/snapshots/vite_task_lifecycle_env_npm.md new file mode 100644 index 0000000000..f96afd4f6d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_lifecycle_env/snapshots/vite_task_lifecycle_env_npm.md @@ -0,0 +1,23 @@ +# vite_task_lifecycle_env_npm + +Regression test for #2317 using Vite+'s real managed npm distribution. `vp run` +stamps npm's CLI entry and user agent for package-manager detection. + +## `vpt json-edit package.json packageManager npm@10.9.8` + + +## `npm --version` + +Execute the pinned npm binary + +``` + +``` + +## `vp run check-env` + +``` +$ node check-env.js ⊘ cache disabled +npm_execpath=/.vite-plus/package_manager/npm//npm/bin/npm-cli.js +npm_config_user_agent=npm/ node/ workspaces/false +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_lifecycle_env/snapshots/vite_task_lifecycle_env_pnpm11.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_lifecycle_env/snapshots/vite_task_lifecycle_env_pnpm11.md new file mode 100644 index 0000000000..82e93c24a9 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_lifecycle_env/snapshots/vite_task_lifecycle_env_pnpm11.md @@ -0,0 +1,23 @@ +# vite_task_lifecycle_env_pnpm11 + +Regression test for #2317 using Vite+'s real managed pnpm 11 distribution. +`vp run` stamps pnpm's JavaScript CLI entry and Node-backed user agent. + +## `vpt json-edit package.json packageManager pnpm@11.20.0` + + +## `pnpm --version` + +Execute the pinned pnpm 11 binary + +``` +11.20.0 +``` + +## `vp run check-env` + +``` +$ node check-env.js ⊘ cache disabled +npm_execpath=/.vite-plus/package_manager/pnpm//pnpm/bin/pnpm.cjs +npm_config_user_agent=pnpm/ npm/? node/ +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_lifecycle_env/snapshots/vite_task_lifecycle_env_pnpm12.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_lifecycle_env/snapshots/vite_task_lifecycle_env_pnpm12.md new file mode 100644 index 0000000000..f75ea724f1 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_lifecycle_env/snapshots/vite_task_lifecycle_env_pnpm12.md @@ -0,0 +1,24 @@ +# vite_task_lifecycle_env_pnpm12 + +Regression test for #2317 using Vite+'s real managed native pnpm 12 +distribution. `npm_execpath` uses the native entry and the user agent reports +`node/?` instead of leaking the Node.js version that hosts Vite+. + +## `vpt json-edit package.json packageManager pnpm@12.0.0` + + +## `pnpm --version` + +Execute the pinned native pnpm 12 binary + +``` +12.0.0 +``` + +## `vp run check-env` + +``` +$ node check-env.js ⊘ cache disabled +npm_execpath=/.vite-plus/package_manager/pnpm//pnpm/bin/pnpm.native +npm_config_user_agent=pnpm/ npm/? node/? +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_lifecycle_env/snapshots/vite_task_lifecycle_env_yarn.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_lifecycle_env/snapshots/vite_task_lifecycle_env_yarn.md new file mode 100644 index 0000000000..d052d80f49 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vite_task_lifecycle_env/snapshots/vite_task_lifecycle_env_yarn.md @@ -0,0 +1,23 @@ +# vite_task_lifecycle_env_yarn + +Regression test for #2317 using Vite+'s real managed Yarn distribution. `vp run` +stamps Yarn's CLI entry and user agent for package-manager detection. + +## `vpt json-edit package.json packageManager yarn@4.17.1` + + +## `yarn --version` + +Execute the pinned Yarn binary + +``` +4.17.1 +``` + +## `vp run check-env` + +``` +$ node check-env.js ⊘ cache disabled +npm_execpath=/.vite-plus/package_manager/yarn//yarn/bin/yarn.js +npm_config_user_agent=yarn/ npm/? node/ +``` diff --git a/crates/vp_pm_cli/src/lib.rs b/crates/vp_pm_cli/src/lib.rs index fe0ec60df7..6b3a75b2bd 100644 --- a/crates/vp_pm_cli/src/lib.rs +++ b/crates/vp_pm_cli/src/lib.rs @@ -12,6 +12,7 @@ mod config; mod dispatch; mod error; mod helpers; +mod lifecycle_env; mod package_manager; mod request; pub(crate) mod resolution; @@ -24,6 +25,7 @@ pub use dispatch::{ dispatch_with_resolved_package_manager, }; pub use error::Error; +pub use lifecycle_env::LifecycleEnvContext; pub use package_manager::{ EnvironmentPackageManagerResolution, PackageManager, PackageManagerBuilder, PackageManagerResolution, PackageManagerSource, PackageManagerType, download_package_manager, diff --git a/crates/vp_pm_cli/src/lifecycle_env.rs b/crates/vp_pm_cli/src/lifecycle_env.rs new file mode 100644 index 0000000000..2e9e12e711 --- /dev/null +++ b/crates/vp_pm_cli/src/lifecycle_env.rs @@ -0,0 +1,400 @@ +//! Package-manager lifecycle environment for script execution. +//! +//! When pnpm, npm, or yarn run a `package.json` script, they stamp environment +//! variables (`npm_execpath`, `npm_config_user_agent`) that let child +//! tooling — npm-run-all, `ni`, package-manager detectors — identify which +//! package manager owns the script run. `vp run` executes scripts itself, so +//! without stamping those variables child runners fall back to npm even in +//! pnpm projects (voidzero-dev/vite-plus#2317). +//! +//! The stamp is deliberately limited to those two package-manager detection +//! channels: every additional emulated variable becomes a compatibility +//! contract with npm/pnpm/Yarn, so more variables are only added when an +//! actual compatibility case requires them. Per-script variables +//! (`npm_lifecycle_event`, `npm_lifecycle_script`, `npm_package_*`, +//! `PNPM_SCRIPT_SRC_DIR`) name the script being run or the package that owns +//! it, so they belong to the task engine, which knows each script's name and +//! package. + +use std::{env, ffi::OsString}; + +use vt_path::AbsolutePathBuf; + +use crate::package_manager::{PackageManager, PackageManagerType, package_manager_bin_path}; + +/// Everything [`PackageManager::lifecycle_env_vars`] needs beyond the package +/// manager itself. +#[derive(Debug)] +pub struct LifecycleEnvContext { + /// Node.js version (i.e. `process.version`) for the user-agent string. + pub node_version: Option, +} + +impl PackageManager { + /// The path the package manager identifies itself with in `npm_execpath` + /// when it runs lifecycle scripts: the JS CLI entry for JS distributions + /// (`pnpm.cjs`, `npm-cli.js`, `yarn.js`), the native binary for pnpm >= 12, + /// with the bin shim as fallback. + /// + /// Mirrors the package managers' own stamping code: npm sets + /// `npm_execpath = config.npmBin` (its `bin/npm-cli.js`), and pnpm sets it + /// to the CLI's `process.argv[1]` — or `process.execPath` when bundled as + /// a binary, the case the native pnpm >= 12 layout mirrors + /// (https://github.com/npm/cli/blob/latest/workspaces/config/lib/set-envs.js, + /// https://github.com/pnpm/npm-lifecycle/blob/main/index.js). + /// + /// Child runners (e.g. npm-run-all) execute `.js`/`.cjs` values through the + /// current Node.js binary, which works on every platform — unlike + /// extensionless shims on Windows. + #[must_use] + pub fn lifecycle_exec_path(&self) -> AbsolutePathBuf { + let bin_dir = self.install_dir.join("bin"); + let js_entry_name = match self.client { + PackageManagerType::Pnpm => Some("pnpm.cjs"), + PackageManagerType::Npm => Some("npm-cli.js"), + PackageManagerType::Yarn => Some("yarn.js"), + // bun is a native binary; it has no JS CLI entry. + PackageManagerType::Bun => None, + }; + if let Some(name) = js_entry_name { + let entry = bin_dir.join(name); + if entry.as_path().is_file() { + return entry; + } + } + // pnpm >= 12 ships a native binary (see `download_pnpm_native_package_manager`) + // and identifies with the running executable itself. + if matches!(self.client, PackageManagerType::Pnpm) { + let native = if cfg!(windows) { + bin_dir.join("pnpm.native.exe") + } else { + bin_dir.join("pnpm.native") + }; + if native.as_path().is_file() { + return native; + } + } + let shim = package_manager_bin_path(&self.install_dir, &self.client.to_string()); + // The shim breaks child runners on Windows (see above), so if this + // shows up in a log the on-disk layout probably changed. (bun never + // has a JS CLI entry, so the message would be misleading there.) + if js_entry_name.is_some() { + tracing::debug!( + "No JS CLI entry under {bin_dir:?}, using package-manager shim {shim:?} for npm_execpath" + ); + } + shim + } + + /// Environment variables the package manager would stamp when running a + /// `package.json` script, limited to the package-manager detection + /// channels (`npm_execpath`, `npm_config_user_agent`). Empty for bun: this + /// narrow #2317 compatibility fix deliberately leaves Bun's lifecycle + /// environment untouched instead of expanding the emulation contract. + /// + /// Names follow npm's lifecycle script environment + /// (https://docs.npmjs.com/cli/v10/using-npm/scripts#environment), which + /// pnpm reproduces by routing `pnpm run` scripts through + /// `@pnpm/npm-lifecycle` + /// (https://github.com/pnpm/pnpm/blob/main/pnpm11/exec/lifecycle/src/runLifecycleHook.ts). + /// Verified against pnpm 11.20.0, native pnpm 12.0.0, npm 10.9.8, + /// and Yarn 4.17.1. + #[must_use] + pub fn lifecycle_env_vars( + &self, + context: &LifecycleEnvContext, + ) -> Vec<(&'static str, OsString)> { + if matches!(self.client, PackageManagerType::Bun) { + return Vec::new(); + } + + let exec_path = self.lifecycle_exec_path(); + let node_version = if matches!(self.client, PackageManagerType::Pnpm) + && matches!( + exec_path.as_path().file_name().and_then(|name| name.to_str()), + Some("pnpm.native" | "pnpm.native.exe") + ) { + // Native pnpm 12 is not running inside Node.js. It stamps `node/?` + // rather than leaking the Node.js version that happens to host vp. + Some("?") + } else { + context.node_version.as_deref() + }; + + vec![ + ("npm_execpath", exec_path.as_path().as_os_str().to_os_string()), + ( + "npm_config_user_agent", + OsString::from(user_agent(self.client, &self.version, node_version)), + ), + ] + } +} + +/// `npm_config_user_agent`, formatted the way the package manager itself does: +/// `pnpm/11.20.0 npm/? node/v22.23.1 linux x64` (pnpm 11, yarn), +/// `pnpm/12.0.0 npm/? node/? linux x64` (native pnpm 12), or +/// `npm/10.9.8 node/v22.23.1 linux x64 workspaces/false` (npm). +/// +/// Formats follow pnpm's resolved `userAgent` config +/// (`{name}/{version} npm/? node/{version} {platform} {arch}`, +/// https://github.com/pnpm/pnpm/blob/main/pnpm11/config/reader/src/index.ts) +/// and npm's `user-agent` definition (`npm/{npm-version} node/{node-version} +/// {platform} {arch} workspaces/{workspaces}`, +/// https://github.com/npm/cli/blob/latest/workspaces/config/lib/definitions/definitions.js). +fn user_agent( + package_manager_type: PackageManagerType, + version: &str, + node_version: Option<&str>, +) -> String { + let node = node_version.map_or_else(String::new, |v| vt_str::format!(" node/{v}").to_string()); + let platform = node_platform(env::consts::OS); + let arch = node_arch(env::consts::ARCH); + match package_manager_type { + PackageManagerType::Pnpm | PackageManagerType::Yarn => { + vt_str::format!("{package_manager_type}/{version} npm/?{node} {platform} {arch}") + .to_string() + } + // npm's `workspaces/` flag reflects the `--workspaces` command flag, + // which `vp run` has no analogue of, so it stays `false` (verified + // against npm 10.9.8, including inside a workspace root). + PackageManagerType::Npm => { + vt_str::format!("npm/{version}{node} {platform} {arch} workspaces/false").to_string() + } + // Callers skip bun before building a user agent. + PackageManagerType::Bun => String::new(), + } +} + +/// Map Rust's `env::consts::OS` to Node.js `process.platform` spellings. +fn node_platform(os: &'static str) -> &'static str { + match os { + "macos" => "darwin", + "windows" => "win32", + "solaris" | "illumos" => "sunos", + other => other, + } +} + +/// Map Rust's `env::consts::ARCH` to Node.js `process.arch` spellings. +fn node_arch(arch: &'static str) -> &'static str { + match arch { + "x86" => "ia32", + "x86_64" => "x64", + "aarch64" => "arm64", + "powerpc" => "ppc", + "powerpc64" => "ppc64", + "loongarch64" => "loong64", + other => other, + } +} + +#[cfg(test)] +mod tests { + use std::ffi::OsStr; + + use super::*; + + fn package_manager( + package_manager_type: PackageManagerType, + version: &str, + install_dir: &std::path::Path, + ) -> PackageManager { + PackageManager { + client: package_manager_type, + version: version.into(), + install_dir: AbsolutePathBuf::new(install_dir.to_path_buf()).unwrap(), + } + } + + fn context(node_version: Option<&str>) -> LifecycleEnvContext { + LifecycleEnvContext { node_version: node_version.map(str::to_string) } + } + + fn vars_map<'a>( + vars: &'a [(&'static str, OsString)], + ) -> std::collections::HashMap<&'a str, &'a OsStr> { + vars.iter().map(|(k, v)| (*k, v.as_os_str())).collect() + } + + fn write_file(path: &std::path::Path) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, "").unwrap(); + } + + #[test] + fn exec_path_prefers_js_cli_entry() { + let cases = [ + (PackageManagerType::Pnpm, "pnpm.cjs"), + (PackageManagerType::Npm, "npm-cli.js"), + (PackageManagerType::Yarn, "yarn.js"), + ]; + for (pm_type, js_entry) in cases { + let dir = tempfile::tempdir().unwrap(); + let install_dir = dir.path().join("pm"); + write_file(&install_dir.join("bin").join(js_entry)); + let pm = package_manager(pm_type, "1.0.0", &install_dir); + assert_eq!( + pm.lifecycle_exec_path().as_path(), + install_dir.join("bin").join(js_entry).as_path() + ); + } + } + + #[test] + fn exec_path_uses_native_binary_for_native_pnpm() { + let dir = tempfile::tempdir().unwrap(); + let install_dir = dir.path().join("pm"); + let native_name = if cfg!(windows) { "pnpm.native.exe" } else { "pnpm.native" }; + write_file(&install_dir.join("bin").join(native_name)); + let pm = package_manager(PackageManagerType::Pnpm, "12.0.0", &install_dir); + assert_eq!( + pm.lifecycle_exec_path().as_path(), + install_dir.join("bin").join(native_name).as_path() + ); + } + + #[test] + fn exec_path_falls_back_to_bin_shim() { + let dir = tempfile::tempdir().unwrap(); + let install_dir = dir.path().join("pm"); + std::fs::create_dir_all(install_dir.join("bin")).unwrap(); + let pm = package_manager(PackageManagerType::Pnpm, "1.0.0", &install_dir); + let expected = if cfg!(windows) { + install_dir.join("bin").join("pnpm.cmd") + } else { + install_dir.join("bin").join("pnpm") + }; + assert_eq!(pm.lifecycle_exec_path().as_path(), expected.as_path()); + } + + #[test] + fn pnpm_vars_match_pnpm_stamps() { + let dir = tempfile::tempdir().unwrap(); + let install_dir = dir.path().join("pm"); + write_file(&install_dir.join("bin").join("pnpm.cjs")); + let pm = package_manager(PackageManagerType::Pnpm, "11.20.0", &install_dir); + + let vars = pm.lifecycle_env_vars(&context(Some("v22.23.1"))); + let map = vars_map(&vars); + + assert_eq!( + map["npm_execpath"], + install_dir.join("bin").join("pnpm.cjs").as_path().as_os_str() + ); + assert_eq!( + map["npm_config_user_agent"], + OsStr::new(&vt_str::format!( + "pnpm/11.20.0 npm/? node/v22.23.1 {} {}", + node_platform(env::consts::OS), + node_arch(env::consts::ARCH) + )) + ); + // Deliberately not emulated: variables outside the two detection + // channels are a compatibility contract we only take on for an actual + // compatibility case. Per-script variables like PNPM_SCRIPT_SRC_DIR + // belong to the task engine either way. + assert!(!map.contains_key("INIT_CWD")); + assert!(!map.contains_key("npm_node_execpath")); + assert!(!map.contains_key("NODE")); + assert!(!map.contains_key("PNPM_SCRIPT_SRC_DIR")); + } + + #[test] + fn native_pnpm_vars_do_not_leak_host_node_version() { + let dir = tempfile::tempdir().unwrap(); + let install_dir = dir.path().join("pm"); + let native_name = if cfg!(windows) { "pnpm.native.exe" } else { "pnpm.native" }; + write_file(&install_dir.join("bin").join(native_name)); + let pm = package_manager(PackageManagerType::Pnpm, "12.0.0", &install_dir); + + let vars = pm.lifecycle_env_vars(&context(Some("v22.23.1"))); + let map = vars_map(&vars); + + assert_eq!( + map["npm_execpath"], + install_dir.join("bin").join(native_name).as_path().as_os_str() + ); + assert_eq!( + map["npm_config_user_agent"], + OsStr::new(&vt_str::format!( + "pnpm/12.0.0 npm/? node/? {} {}", + node_platform(env::consts::OS), + node_arch(env::consts::ARCH) + )) + ); + assert!(!map["npm_config_user_agent"].to_string_lossy().contains("v22.23.1")); + } + + #[test] + fn npm_vars_match_npm_stamps() { + let dir = tempfile::tempdir().unwrap(); + let install_dir = dir.path().join("pm"); + write_file(&install_dir.join("bin").join("npm-cli.js")); + let pm = package_manager(PackageManagerType::Npm, "10.9.8", &install_dir); + + let vars = pm.lifecycle_env_vars(&context(Some("v22.23.1"))); + let map = vars_map(&vars); + + assert_eq!( + map["npm_execpath"], + install_dir.join("bin").join("npm-cli.js").as_path().as_os_str() + ); + assert_eq!( + map["npm_config_user_agent"], + OsStr::new(&vt_str::format!( + "npm/10.9.8 node/v22.23.1 {} {} workspaces/false", + node_platform(env::consts::OS), + node_arch(env::consts::ARCH) + )) + ); + } + + #[test] + fn yarn_user_agent_matches_yarn_stamps() { + let ua = user_agent(PackageManagerType::Yarn, "1.22.22", Some("v22.23.1")); + assert_eq!( + ua, + vt_str::format!( + "yarn/1.22.22 npm/? node/v22.23.1 {} {}", + node_platform(env::consts::OS), + node_arch(env::consts::ARCH) + ) + .to_string() + ); + } + + #[test] + fn user_agent_omits_node_segment_without_version() { + let ua = user_agent(PackageManagerType::Pnpm, "11.20.0", None); + assert_eq!( + ua, + vt_str::format!( + "pnpm/11.20.0 npm/? {} {}", + node_platform(env::consts::OS), + node_arch(env::consts::ARCH) + ) + .to_string() + ); + assert!(!ua.contains("node/")); + } + + #[test] + fn bun_stamps_no_lifecycle_vars() { + let dir = tempfile::tempdir().unwrap(); + let install_dir = dir.path().join("pm"); + let pm = package_manager(PackageManagerType::Bun, "1.3.0", &install_dir); + assert!(pm.lifecycle_env_vars(&context(Some("v22.23.1"))).is_empty()); + } + + #[test] + fn node_platform_and_arch_match_node_spellings() { + assert_eq!(node_platform("macos"), "darwin"); + assert_eq!(node_platform("windows"), "win32"); + assert_eq!(node_platform("linux"), "linux"); + assert_eq!(node_arch("x86_64"), "x64"); + assert_eq!(node_arch("x86"), "ia32"); + assert_eq!(node_arch("aarch64"), "arm64"); + assert_eq!(node_arch("powerpc"), "ppc"); + } +} diff --git a/packages/cli/binding/index.d.cts b/packages/cli/binding/index.d.cts index b89566d9f0..78ca103443 100644 --- a/packages/cli/binding/index.d.cts +++ b/packages/cli/binding/index.d.cts @@ -3465,6 +3465,11 @@ export interface CliOptions { explicitChdir?: boolean; /** CLI arguments (should be process.argv.slice(2) from JavaScript) */ args?: Array; + /** + * Host Node.js version (`process.version`), used for the package-manager + * lifecycle env user agent. + */ + nodeVersion?: string; /** Generated toolchain manifest shipped with this vite-plus package. */ toolchainManifestPath: string; /** Root directory of this vite-plus package. */ diff --git a/packages/cli/binding/src/cli/lifecycle_env.rs b/packages/cli/binding/src/cli/lifecycle_env.rs new file mode 100644 index 0000000000..4f1a1a9eda --- /dev/null +++ b/packages/cli/binding/src/cli/lifecycle_env.rs @@ -0,0 +1,33 @@ +//! Package-manager lifecycle environment for `vp run`/`vpr` script execution. +//! +//! pnpm, npm, and yarn stamp `npm_execpath` and `npm_config_user_agent` when +//! running `package.json` scripts so child tooling (npm-run-all, `ni`, +//! package-manager detectors) can tell which package manager owns the run. +//! vite-task spawns scripts with the session env snapshot only, so under +//! `vp run` those variables are missing and child runners fall back to npm +//! even in pnpm projects (#2317). Stamping happens here, before +//! `Session::init` snapshots the process env. + +use vp_pm_cli::{LifecycleEnvContext, PackageManager}; + +/// Stamp the package-manager lifecycle env into the process environment. +/// +/// `node_version` is the host Node.js `process.version` when the JS side +/// provides it, keeping the user agent in the shape the package managers +/// produce. +pub(super) fn stamp_package_manager_lifecycle_env(pm: &PackageManager, node_version: Option<&str>) { + if node_version.is_none() { + tracing::debug!( + "Host Node.js version not provided; stamping the package-manager lifecycle env without it" + ); + } + let context = LifecycleEnvContext { node_version: node_version.map(str::to_string) }; + for (name, value) in pm.lifecycle_env_vars(&context) { + // SAFETY: `set_var` is unsound while another thread may read the + // environment. This runs in the same startup window as the PATH + // prepend right above it in `execute_vite_task_command` (before + // `Session::init` spawns task threads), so it adds no exposure + // beyond that existing call. + unsafe { std::env::set_var(name, value) }; + } +} diff --git a/packages/cli/binding/src/cli/mod.rs b/packages/cli/binding/src/cli/mod.rs index e370d1eaea..f2ac4831a5 100644 --- a/packages/cli/binding/src/cli/mod.rs +++ b/packages/cli/binding/src/cli/mod.rs @@ -7,6 +7,7 @@ mod app_target; mod execution; mod handler; mod help; +mod lifecycle_env; mod resolver; mod script_note; mod types; @@ -245,6 +246,8 @@ async fn execute_vite_task_command( let (workspace_root, _) = vt_workspace::find_workspace_root(&cwd)?; let workspace_path: Arc = workspace_root.path.into(); + let node_version = options.as_ref().and_then(|o| o.node_version.clone()); + let resolve_vite_config_fn = options .as_ref() .map(|o| Arc::clone(&o.resolve_universal_vite_config)) @@ -268,6 +271,13 @@ async fn execute_vite_task_command( Ok(pm) => { let bin_prefix = pm.get_bin_prefix(); let _ = prepend_to_path_env(&bin_prefix, PrependOptions::default()); + + // Stamp the package-manager lifecycle env (`npm_execpath`, + // `npm_config_user_agent`) like pnpm/npm/yarn would, so child + // runners inside scripts can detect the package manager (#2317). + // Session::init snapshots the process env, so this must also happen + // before it. + lifecycle_env::stamp_package_manager_lifecycle_env(&pm, node_version.as_deref()); } Err(error) if error.is_integrity_failure() => return Err(error), Err(error) => { diff --git a/packages/cli/binding/src/cli/types.rs b/packages/cli/binding/src/cli/types.rs index c2fda6c821..aeb55f95dd 100644 --- a/packages/cli/binding/src/cli/types.rs +++ b/packages/cli/binding/src/cli/types.rs @@ -157,6 +157,9 @@ pub struct CliOptions { pub toolchain_manifest_path: String, pub vite_plus_package_path: String, pub resolve_universal_vite_config: ViteConfigResolverFn, + /// Host Node.js version (`process.version`), used for the package-manager + /// lifecycle env user agent. + pub node_version: Option, } /// A resolved subcommand ready for execution. diff --git a/packages/cli/binding/src/lib.rs b/packages/cli/binding/src/lib.rs index 0c8f00ff66..088364fefc 100644 --- a/packages/cli/binding/src/lib.rs +++ b/packages/cli/binding/src/lib.rs @@ -78,6 +78,9 @@ pub struct CliOptions { pub explicit_chdir: Option, /// CLI arguments (should be process.argv.slice(2) from JavaScript) pub args: Option>, + /// Host Node.js version (`process.version`), used for the package-manager + /// lifecycle env user agent. + pub node_version: Option, /// Generated toolchain manifest shipped with this vite-plus package. pub toolchain_manifest_path: String, /// Root directory of this vite-plus package. @@ -181,6 +184,7 @@ pub async fn run(options: CliOptions) -> Result { let doc_tsf = options.doc; let resolve_universal_vite_config_tsf = options.resolve_universal_vite_config; let args = options.args; + let node_version = options.node_version; let explicit_chdir = options.explicit_chdir.unwrap_or(false); let toolchain_manifest_path = options.toolchain_manifest_path; let vite_plus_package_path = options.vite_plus_package_path; @@ -205,6 +209,7 @@ pub async fn run(options: CliOptions) -> Result { resolve_universal_vite_config: create_vite_config_resolver( resolve_universal_vite_config_tsf, ), + node_version, }; // Create a new single-threaded runtime for non-Send futures diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 8d86e0586e..6aca77fde7 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -151,6 +151,7 @@ if (maybePrintCommandHelp(args)) { toolchainManifestPath: path.join(cliDistDir, 'toolchain.json'), vitePlusPackagePath, resolveUniversalViteConfig, + nodeVersion: process.version, explicitChdir, args: rustCliArgs, });