Skip to content

Commit 3a95df9

Browse files
author
leihaohao
committed
feat(passthrough): add low-Node passthrough for eligible commands
Closes #1909 Closes #1916 When the project's resolved Node version is below the supported minimum (20.19.0), eligible commands (vp run/vpr + package-manager family) bypass the Vite+ JS CLI and run the project's package manager directly. - Add shared passthrough module (vite_shared) with version comparison - Add passthrough command routing (vite_global_cli) with PATH handling - Add dispatch_with_pm for externally-provided PackageManager - Add detect_only on PackageManagerBuilder (no devEngines pin) - Defer is_low_node check in vpx until actually needed - Use vite_shared path utilities for safe non-UTF-8 PATH handling
1 parent b386620 commit 3a95df9

18 files changed

Lines changed: 1113 additions & 21 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/vite_global_cli/src/cli.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -901,6 +901,19 @@ pub async fn run_command_with_options(
901901
return Ok(std::process::ExitStatus::default());
902902
};
903903

904+
// Low-Node passthrough precheck: when the project's resolved Node is below
905+
// the supported minimum AND the command is eligible (run / package manager),
906+
// bypass the Vite+ JS CLI and run the project's own package manager directly.
907+
if commands::passthrough::is_eligible(&command) {
908+
if let Some(node_version) = commands::passthrough::resolve_project_node_version(&cwd).await {
909+
if commands::passthrough::should_passthrough(&command, &node_version) {
910+
let mut executor = crate::js_executor::JsExecutor::new(None);
911+
let runtime = executor.ensure_project_runtime(&cwd).await?;
912+
return commands::passthrough::execute(&cwd, &command, runtime).await;
913+
}
914+
}
915+
}
916+
904917
match command {
905918
// Category A: Package Manager Commands
906919
// Print the runtime header for `vp install` (when not silent).

crates/vite_global_cli/src/commands/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ pub mod upgrade;
110110
// Category C: Local CLI Delegation
111111
pub mod delegate;
112112

113+
// Low-Node passthrough (degrades eligible commands to the project's package manager)
114+
pub mod passthrough;
115+
113116
#[cfg(test)]
114117
mod tests {
115118
use vite_path::AbsolutePathBuf;
Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
//! Low-Node passthrough: when the project's Node is below the supported
2+
//! minimum, eligible commands (`vpr`/`vp run` + the package-manager family)
3+
//! bypass the Vite+ JS CLI and run the project's own package manager directly,
4+
//! skipping `devEngines` pinning.
5+
6+
use std::{collections::HashMap, process::ExitStatus};
7+
8+
use vite_command::run_command;
9+
use vite_install::PackageManager;
10+
use vite_js_runtime::JsRuntime;
11+
use vite_path::AbsolutePath;
12+
use vite_shared::{PrependOptions, is_node_below_min, prepend_to_path_env};
13+
14+
use crate::{cli::Commands, error::Error};
15+
16+
/// Commands that degrade to passthrough on low Node.
17+
///
18+
/// Add new commands here ONLY if they are pure script-run / package-manager
19+
/// operations. Dev/build/test/lint/fmt/check/pack depend on bundled tools and
20+
/// are NEVER eligible.
21+
///
22+
/// Global PM operations (`-g`/`--global`) are excluded because they should use
23+
/// VP's managed global install system, which has its own Node runtime.
24+
#[allow(dead_code)] // called by run_command_with_options and execute_vpr
25+
#[must_use]
26+
pub fn is_eligible(command: &Commands) -> bool {
27+
match command {
28+
Commands::Run { .. } => true,
29+
Commands::PackageManager(pm_cmd) => !is_global_pm_command(pm_cmd),
30+
_ => false,
31+
}
32+
}
33+
34+
/// Returns true if the PM command is a global operation that should bypass
35+
/// passthrough and use VP's managed install system instead.
36+
///
37+
/// Mirrors [`PackageManagerCommand::is_managed_global`] — keep in sync.
38+
fn is_global_pm_command(command: &vite_pm_cli::PackageManagerCommand) -> bool {
39+
command.is_managed_global()
40+
}
41+
42+
/// Print the one-line passthrough notice. Pure I/O, no version check.
43+
pub(crate) fn print_passthrough_notice(node_version: &str, min: &str) {
44+
// Reuse shared output style; keep to a single concise line.
45+
vite_shared::output::warn(&format!(
46+
"Node {node_version} is below the Vite+ minimum ({min}); using passthrough mode — \
47+
running the project's package manager directly without loading the Vite+ CLI. \
48+
Upgrade Node to restore full Vite+ functionality."
49+
));
50+
}
51+
52+
/// Returns true when passthrough should activate: eligible command AND the
53+
/// resolved Node version is below the supported minimum.
54+
#[allow(dead_code)] // called by run_command_with_options and execute_vpr
55+
#[must_use]
56+
pub fn should_passthrough(command: &Commands, node_version: &str) -> bool {
57+
is_eligible(command) && is_node_below_min(node_version)
58+
}
59+
60+
/// Run an eligible command in passthrough mode.
61+
///
62+
/// `runtime` is the resolved project Node (already ensured/downloaded by the
63+
/// precheck caller in `run_command_with_options`). The package manager is
64+
/// resolved via `PackageManager::detect_only` (no `devEngines` pin):
65+
/// - `Commands::PackageManager` delegates to `vite_pm_cli::dispatch_with_pm`,
66+
/// reusing the existing `resolve_*` parameter generation (zero drift).
67+
/// - `Commands::Run` (`vpr`/`vp run <script>`) builds `<pm> run <script>` via
68+
/// `resolve_install_command` and executes it directly (nr-style).
69+
///
70+
/// The Node runtime bin is prepended to PATH so the pm shim resolves node.
71+
#[allow(dead_code)] // called by run_command_with_options and execute_vpr
72+
pub async fn execute(
73+
cwd: &AbsolutePath,
74+
command: &Commands,
75+
runtime: &JsRuntime,
76+
) -> Result<ExitStatus, Error> {
77+
let node_version = runtime.version();
78+
print_passthrough_notice(node_version, vite_shared::MIN_SUPPORTED_NODE);
79+
80+
let runtime_bin = runtime.get_bin_prefix();
81+
let pm = PackageManager::builder(cwd).detect_only(&runtime_bin).await.map_err(map_pm_error)?;
82+
83+
match command {
84+
Commands::Run { args } => {
85+
// vpr/vp run <script> -> <pm> run <script> [args] (nr-style).
86+
// NOTE: `resolve_install_command` auto-prepends "install", so it can't
87+
// be reused here. Build the args + envs directly: bin_path = pm's bin
88+
// name, PATH = node runtime bin (for the pm shim) + pm's bin dir.
89+
let mut full_args = vec!["run".to_string()];
90+
full_args.extend(args.iter().cloned());
91+
let pm_bin_prefix = pm.get_bin_prefix();
92+
let envs = build_passthrough_envs(&runtime_bin, Some(&pm_bin_prefix));
93+
let bin_name = pm.bin_name.to_string();
94+
Ok(run_command(&bin_name, full_args, &envs, cwd).await?)
95+
}
96+
Commands::PackageManager(pm_command) => {
97+
// Reuse the full PM dispatch with the detect-only pm. dispatch_with_pm
98+
// internally calls pm.run_*_command whose envs.PATH = pm.get_bin_prefix()
99+
// (detect_only set it: explicit version -> vp download dir bin; npm no
100+
// config -> node runtime bin). Prepend the node runtime bin to the
101+
// process PATH so JS-based PM shims (pnpm/yarn) can resolve `node`
102+
// via #!/usr/bin/env node.
103+
prepend_to_path_env(&runtime_bin, PrependOptions { dedupe_anywhere: true });
104+
Ok(vite_pm_cli::dispatch::dispatch_with_pm(cwd, pm_command.clone(), &pm).await?)
105+
}
106+
other => Err(Error::UserMessage(
107+
format!("Passthrough mode does not support this command: {other:?}").into(),
108+
)),
109+
}
110+
}
111+
112+
/// Map `vite_error::Error` from `detect_only` to a user-facing message when the
113+
/// project has no explicit, compatible package manager version.
114+
#[allow(dead_code)] // called by execute
115+
fn map_pm_error(e: vite_error::Error) -> Error {
116+
match e {
117+
vite_error::Error::UnrecognizedPackageManager => Error::UserMessage(
118+
"Passthrough mode could not resolve a compatible package manager version. \
119+
Please specify one in package.json (e.g. \"packageManager\": \"pnpm@9.15.0\") \
120+
or upgrade Node."
121+
.into(),
122+
),
123+
other => Error::Install(other),
124+
}
125+
}
126+
127+
/// Build the PATH env for passthrough: PM bin dir first (so the pm binary is
128+
/// found first), then the node runtime bin (for the pm shim's
129+
/// `#!/usr/bin/env node`), then the existing PATH.
130+
///
131+
/// Uses `env::split_paths` / `env::join_paths` for correct platform-aware
132+
/// separator handling and non-UTF-8 path safety.
133+
#[allow(dead_code)] // called by execute
134+
fn build_passthrough_envs(
135+
runtime_bin: &AbsolutePath,
136+
pm_bin_prefix: Option<&AbsolutePath>,
137+
) -> HashMap<String, String> {
138+
use std::env;
139+
140+
let current = env::var_os("PATH").unwrap_or_default();
141+
let mut paths: Vec<_> = env::split_paths(&current).collect();
142+
143+
// PM bin dir first (so the pm binary resolves before node).
144+
if let Some(pm_bin) = pm_bin_prefix {
145+
let pm = pm_bin.as_path().to_path_buf();
146+
if !paths.iter().any(|p| *p == pm) {
147+
paths.insert(0, pm);
148+
}
149+
}
150+
151+
// Node runtime bin next (for pm shim's #!/usr/bin/env node).
152+
let rt = runtime_bin.as_path().to_path_buf();
153+
if !paths.iter().any(|p| *p == rt) {
154+
paths.insert(0, rt);
155+
}
156+
157+
let path_string =
158+
env::join_paths(paths).map(|p| p.to_string_lossy().into_owned()).unwrap_or_default();
159+
160+
let mut envs = HashMap::new();
161+
envs.insert("PATH".to_string(), path_string);
162+
envs
163+
}
164+
165+
/// Resolve the project's Node version string (if any), without forcing a
166+
/// download. Used by the passthrough precheck to decide activation.
167+
///
168+
/// Returns `None` when the project has no Node version source (no .node-version,
169+
/// no devEngines.runtime, no engines.node) — in that case the original path
170+
/// runs (which falls back to CLI/LTS runtime, above the minimum).
171+
///
172+
/// Returns `None` on resolution errors (I/O, parse) — the caller falls through
173+
/// to the normal CLI path, which has its own error handling.
174+
pub(crate) async fn resolve_project_node_version(cwd: &vite_path::AbsolutePath) -> Option<String> {
175+
use vite_js_runtime::resolve_node_version;
176+
// walk_up=true to match ensure_project_runtime's resolution
177+
// (has_valid_version_source uses resolve_node_version(path, true) at
178+
// js_executor.rs:189); using false here could disagree with the runtime
179+
// version actually downloaded, causing passthrough to mis-fire.
180+
let resolution = resolve_node_version(cwd, true).await.ok()??;
181+
Some(resolution.version.to_string())
182+
}
183+
184+
#[cfg(test)]
185+
mod tests {
186+
use super::*;
187+
use vite_path::AbsolutePathBuf;
188+
189+
#[test]
190+
fn run_is_eligible() {
191+
assert!(is_eligible(&Commands::Run { args: vec![] }));
192+
}
193+
194+
#[test]
195+
fn install_is_eligible() {
196+
assert!(is_eligible(&Commands::PackageManager(
197+
vite_pm_cli::PackageManagerCommand::Install {
198+
prod: false,
199+
dev: false,
200+
no_optional: false,
201+
frozen_lockfile: false,
202+
no_frozen_lockfile: false,
203+
lockfile_only: false,
204+
prefer_offline: false,
205+
offline: false,
206+
force: false,
207+
ignore_scripts: false,
208+
no_lockfile: false,
209+
fix_lockfile: false,
210+
shamefully_hoist: false,
211+
resolution_only: false,
212+
silent: false,
213+
filter: None,
214+
workspace_root: false,
215+
save_exact: false,
216+
save_peer: false,
217+
save_optional: false,
218+
save_catalog: false,
219+
global: false,
220+
node: None,
221+
concurrency: None,
222+
packages: None,
223+
pass_through_args: None,
224+
}
225+
)));
226+
}
227+
228+
#[test]
229+
fn dev_build_test_not_eligible() {
230+
assert!(!is_eligible(&Commands::Dev { args: vec![] }));
231+
assert!(!is_eligible(&Commands::Build { args: vec![] }));
232+
assert!(!is_eligible(&Commands::Test { args: vec![] }));
233+
}
234+
235+
#[test]
236+
fn global_install_not_eligible() {
237+
// Global PM operations should use VP's managed install system, not passthrough.
238+
assert!(is_eligible(&Commands::PackageManager(
239+
vite_pm_cli::PackageManagerCommand::Install {
240+
prod: false,
241+
dev: false,
242+
no_optional: false,
243+
frozen_lockfile: false,
244+
no_frozen_lockfile: false,
245+
lockfile_only: false,
246+
prefer_offline: false,
247+
offline: false,
248+
force: false,
249+
ignore_scripts: false,
250+
no_lockfile: false,
251+
fix_lockfile: false,
252+
shamefully_hoist: false,
253+
resolution_only: false,
254+
silent: false,
255+
filter: None,
256+
workspace_root: false,
257+
save_exact: false,
258+
save_peer: false,
259+
save_optional: false,
260+
save_catalog: false,
261+
global: false,
262+
node: None,
263+
concurrency: None,
264+
packages: None,
265+
pass_through_args: None,
266+
}
267+
)));
268+
assert!(!is_eligible(&Commands::PackageManager(
269+
vite_pm_cli::PackageManagerCommand::Install {
270+
prod: false,
271+
dev: false,
272+
no_optional: false,
273+
frozen_lockfile: false,
274+
no_frozen_lockfile: false,
275+
lockfile_only: false,
276+
prefer_offline: false,
277+
offline: false,
278+
force: false,
279+
ignore_scripts: false,
280+
no_lockfile: false,
281+
fix_lockfile: false,
282+
shamefully_hoist: false,
283+
resolution_only: false,
284+
silent: false,
285+
filter: None,
286+
workspace_root: false,
287+
save_exact: false,
288+
save_peer: false,
289+
save_optional: false,
290+
save_catalog: false,
291+
global: true,
292+
node: None,
293+
concurrency: None,
294+
packages: Some(vec!["express".into()]),
295+
pass_through_args: None,
296+
}
297+
)));
298+
}
299+
300+
#[test]
301+
fn should_passthrough_combines_eligible_and_low_node() {
302+
assert!(should_passthrough(&Commands::Run { args: vec![] }, "14.15.0"));
303+
assert!(!should_passthrough(&Commands::Run { args: vec![] }, "22.18.0"));
304+
// high node but eligible command -> no passthrough
305+
assert!(!should_passthrough(&Commands::Dev { args: vec![] }, "14.15.0"));
306+
}
307+
308+
#[test]
309+
fn build_envs_prepends_runtime_bin_to_path() {
310+
let tmp = tempfile::tempdir().unwrap();
311+
let bin = AbsolutePathBuf::new(tmp.path().join("bin")).unwrap();
312+
let envs = build_passthrough_envs(&bin, None);
313+
let path = envs.get("PATH").expect("PATH must be present");
314+
assert!(path.starts_with(&bin.to_string()), "PATH must start with runtime bin");
315+
}
316+
317+
#[test]
318+
fn build_envs_prepends_both_runtime_and_pm_bin() {
319+
let tmp = tempfile::tempdir().unwrap();
320+
let runtime_bin = AbsolutePathBuf::new(tmp.path().join("node_bin")).unwrap();
321+
let pm_bin = AbsolutePathBuf::new(tmp.path().join("pm_bin")).unwrap();
322+
let envs = build_passthrough_envs(&runtime_bin, Some(&pm_bin));
323+
let path = envs.get("PATH").expect("PATH must be present");
324+
assert!(
325+
path.starts_with(&pm_bin.to_string()),
326+
"PATH must start with PM bin dir, got: {path}"
327+
);
328+
assert!(path.contains(&runtime_bin.to_string()), "PATH must contain runtime bin");
329+
}
330+
331+
#[test]
332+
fn map_pm_error_unrecognized_is_user_message() {
333+
let err = map_pm_error(vite_error::Error::UnrecognizedPackageManager);
334+
assert!(matches!(err, Error::UserMessage(_)));
335+
}
336+
}

0 commit comments

Comments
 (0)