-
Notifications
You must be signed in to change notification settings - Fork 171
Expand file tree
/
Copy pathjs_executor.rs
More file actions
564 lines (494 loc) · 21.8 KB
/
js_executor.rs
File metadata and controls
564 lines (494 loc) · 21.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
//! JavaScript execution via managed Node.js runtime.
//!
//! This module handles downloading and caching Node.js via `vite_js_runtime`,
//! and executing JavaScript scripts using the managed runtime.
use std::process::ExitStatus;
use tokio::process::Command;
use vite_install::package_manager::{
PackageManagerType, download_package_manager, get_package_manager_type_and_version,
};
use vite_js_runtime::{
JsRuntime, JsRuntimeType, download_runtime, download_runtime_for_project, is_valid_version,
read_package_json, resolve_node_version,
};
use vite_path::{AbsolutePath, AbsolutePathBuf};
use vite_shared::{PrependOptions, PrependResult, env_vars, format_path_with_prepend};
use vite_workspace::find_workspace_root;
use crate::{commands::env::config, error::Error};
const DELEGATE_BOOTSTRAP_FILE: &str = "delegate-bootstrap.js";
#[derive(Clone, Debug)]
struct YarnPnpProject {
version: String,
hash: Option<String>,
}
/// JavaScript executor using managed Node.js runtime.
///
/// Handles two runtime resolution strategies:
/// - CLI runtime: For package manager commands and bundled JS scripts (Categories A & B)
/// - Project runtime: For delegating to local vite-plus CLI (Category C)
pub struct JsExecutor {
/// Cached runtime for CLI commands (Categories A & B)
cli_runtime: Option<JsRuntime>,
/// Cached runtime for project delegation (Category C)
project_runtime: Option<JsRuntime>,
/// Directory containing JS scripts (from `VITE_GLOBAL_CLI_JS_SCRIPTS_DIR`)
scripts_dir: Option<AbsolutePathBuf>,
}
impl JsExecutor {
/// Create a new JS executor.
///
/// # Arguments
/// * `scripts_dir` - Optional path to the JS scripts directory.
/// If not provided, will be auto-detected from the binary location.
#[must_use]
pub const fn new(scripts_dir: Option<AbsolutePathBuf>) -> Self {
Self { cli_runtime: None, project_runtime: None, scripts_dir }
}
/// Get the JS scripts directory.
///
/// Resolution order:
/// 1. Explicitly provided `scripts_dir`
/// 2. `VITE_GLOBAL_CLI_JS_SCRIPTS_DIR` environment variable
/// 3. Auto-detect from binary location (../dist relative to binary)
pub fn get_scripts_dir(&self) -> Result<AbsolutePathBuf, Error> {
// 1. Use explicitly provided scripts_dir
if let Some(dir) = &self.scripts_dir {
return Ok(dir.clone());
}
// 2. Check environment variable
if let Ok(dir) = std::env::var(env_vars::VITE_GLOBAL_CLI_JS_SCRIPTS_DIR) {
return AbsolutePathBuf::new(dir.into()).ok_or(Error::JsScriptsDirNotFound);
}
// 3. Auto-detect from binary location
// JS scripts are at ../node_modules/vite-plus/dist relative to the binary directory
// e.g., ~/.vite-plus/<version>/bin/vp -> ~/.vite-plus/<version>/node_modules/vite-plus/dist/
let exe_path = std::env::current_exe().map_err(|_| Error::JsScriptsDirNotFound)?;
// Resolve symlinks to get the real binary path (Unix only)
// Skip on Windows to avoid path resolution issues
#[cfg(unix)]
let exe_path = std::fs::canonicalize(&exe_path).map_err(|_| Error::JsScriptsDirNotFound)?;
let bin_dir = exe_path.parent().ok_or(Error::JsScriptsDirNotFound)?;
let version_dir = bin_dir.parent().ok_or(Error::JsScriptsDirNotFound)?;
let scripts_dir = version_dir.join("node_modules").join("vite-plus").join("dist");
AbsolutePathBuf::new(scripts_dir).ok_or(Error::JsScriptsDirNotFound)
}
/// Get the path to the current Rust binary (vp).
///
/// This is passed to JS scripts via `VITE_PLUS_CLI_BIN` environment variable
/// so they can invoke vp commands when needed.
fn get_bin_path() -> Result<AbsolutePathBuf, Error> {
let exe_path = std::env::current_exe().map_err(|_| Error::CliBinaryNotFound)?;
AbsolutePathBuf::new(exe_path).ok_or(Error::CliBinaryNotFound)
}
/// Create a JS runtime command with common environment variables set.
///
/// Sets up:
/// - `VITE_PLUS_CLI_BIN`: So JS scripts can invoke vp commands
/// - `PATH`: Prepends the runtime bin directory so child processes can find the JS runtime
fn create_js_command(
runtime_binary: &AbsolutePath,
runtime_bin_prefix: &AbsolutePath,
) -> Command {
let mut cmd = Command::new(runtime_binary.as_path());
if let Ok(bin_path) = Self::get_bin_path() {
tracing::debug!("Set VITE_PLUS_CLI_BIN to {:?}", bin_path);
cmd.env(env_vars::VITE_PLUS_CLI_BIN, bin_path.as_path());
}
// Prepend runtime bin to PATH so child processes can find the JS runtime
let options = PrependOptions { dedupe_anywhere: true };
if let PrependResult::Prepended(new_path) =
format_path_with_prepend(runtime_bin_prefix.as_path(), options)
{
tracing::debug!("Set PATH to {:?}", new_path);
cmd.env("PATH", new_path);
}
cmd
}
/// Create a `yarn node` command so Yarn can inject its PnP hooks.
fn create_yarn_node_command(
yarn_binary: &AbsolutePath,
runtime_bin_prefix: &AbsolutePath,
) -> Command {
let mut cmd = Command::new(yarn_binary.as_path());
if let Ok(bin_path) = Self::get_bin_path() {
tracing::debug!("Set VITE_PLUS_CLI_BIN to {:?}", bin_path);
cmd.env(env_vars::VITE_PLUS_CLI_BIN, bin_path.as_path());
}
let options = PrependOptions { dedupe_anywhere: true };
if let PrependResult::Prepended(new_path) =
format_path_with_prepend(runtime_bin_prefix.as_path(), options)
{
tracing::debug!("Set PATH to {:?}", new_path);
cmd.env("PATH", new_path);
}
cmd.arg("node");
cmd
}
/// Get the CLI's package.json directory (parent of `scripts_dir`).
///
/// This is used for resolving the CLI's default Node.js version
/// from `devEngines.runtime` in the CLI's package.json.
fn get_cli_package_dir(&self) -> Result<AbsolutePathBuf, Error> {
let scripts_dir = self.get_scripts_dir()?;
// scripts_dir is typically packages/cli/dist, so parent is packages/cli
scripts_dir
.parent()
.map(vite_path::AbsolutePath::to_absolute_path_buf)
.ok_or(Error::JsScriptsDirNotFound)
}
/// Ensure the CLI runtime is downloaded and cached.
///
/// Uses the CLI's package.json `devEngines.runtime` configuration
/// to determine which Node.js version to use.
pub async fn ensure_cli_runtime(&mut self) -> Result<&JsRuntime, Error> {
if self.cli_runtime.is_none() {
let cli_dir = self.get_cli_package_dir()?;
tracing::debug!("Resolving CLI runtime from {:?}", cli_dir);
let runtime = download_runtime_for_project(&cli_dir).await?;
self.cli_runtime = Some(runtime);
}
Ok(self.cli_runtime.as_ref().unwrap())
}
/// Ensure the project runtime is downloaded and cached.
///
/// Resolution order:
/// 1. Session override (env var from `vp env use`)
/// 2. Session override (file from `vp env use`)
/// 3. Project sources (.node-version, engines.node, devEngines.runtime) —
/// delegates to `download_runtime_for_project()` for cache-aware resolution
/// 4. User default from config.json
/// 5. Latest LTS
pub async fn ensure_project_runtime(
&mut self,
project_path: &AbsolutePath,
) -> Result<&JsRuntime, Error> {
if self.project_runtime.is_none() {
tracing::debug!("Resolving project runtime from {:?}", project_path);
// 1–2. Session overrides: env var (from `vp env use`), then file
let session_version = vite_shared::EnvConfig::get()
.node_version
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
let session_version = if session_version.is_some() {
session_version
} else {
config::read_session_version().await
};
if let Some(version) = session_version {
let runtime = download_runtime(JsRuntimeType::Node, &version).await?;
return Ok(self.project_runtime.insert(runtime));
}
// 3. Check if project has any *valid* version source.
// resolve_node_version returns Some for any non-empty value,
// even invalid ones. We must validate before routing to
// download_runtime_for_project, which falls to LTS on all-invalid
// and would skip the user's configured default.
let has_valid_project_source = has_valid_version_source(project_path).await?;
let runtime = if has_valid_project_source {
// At least one valid project source exists — delegate to
// download_runtime_for_project for cache-aware range resolution
// and intra-project fallback chain
download_runtime_for_project(project_path).await?
} else {
// No valid project source — check user default from config, then LTS
let resolution = config::resolve_version(project_path).await?;
download_runtime(JsRuntimeType::Node, &resolution.version).await?
};
self.project_runtime = Some(runtime);
}
Ok(self.project_runtime.as_ref().unwrap())
}
/// Download a specific Node.js version.
///
/// This is used when we need a specific version regardless of
/// package.json configuration.
#[allow(dead_code)] // Will be used in future phases
pub async fn download_node(&self, version: &str) -> Result<JsRuntime, Error> {
Ok(download_runtime(JsRuntimeType::Node, version).await?)
}
/// Delegate to local or global vite-plus CLI.
///
/// Executes a small bootstrap entrypoint from the global installation.
/// The bootstrap resolves the project's local `vite-plus` from the project
/// context and falls back to the global `dist/bin.js` when needed.
///
/// Uses the project's runtime resolved via `config::resolve_version()`.
/// For side-effect-free commands like `--version`, use [`delegate_with_cli_runtime`] instead.
///
/// # Arguments
/// * `project_path` - Path to the project directory
/// * `args` - Arguments to pass to the local CLI
pub async fn delegate_to_local_cli(
&mut self,
project_path: &AbsolutePath,
args: &[String],
) -> Result<ExitStatus, Error> {
// Use project's runtime based on its devEngines.runtime configuration
let runtime = self.ensure_project_runtime(project_path).await?;
let node_binary = runtime.get_binary_path();
let bin_prefix = runtime.get_bin_prefix();
self.run_local_js_entry(project_path, &node_binary, &bin_prefix, args).await
}
/// Delegate to the global vite-plus CLI entrypoint directly.
///
/// Unlike [`delegate_to_local_cli`], this bypasses project-local resolution and always runs
/// the global installation's `dist/bin.js`.
pub async fn delegate_to_global_cli(
&mut self,
project_path: &AbsolutePath,
args: &[String],
) -> Result<ExitStatus, Error> {
let runtime = self.ensure_cli_runtime().await?;
let node_binary = runtime.get_binary_path();
let bin_prefix = runtime.get_bin_prefix();
self.run_global_js_entry(project_path, &node_binary, &bin_prefix, args).await
}
/// Delegate to local or global vite-plus CLI using the CLI's own runtime.
///
/// Like [`delegate_to_local_cli`], but uses the CLI's bundled runtime
/// (from its own `devEngines.runtime` in `package.json`) instead of the
/// project's runtime. This avoids side effects like writing `.node-version`
/// when no version source exists in the project directory.
///
/// Use this for read-only / side-effect-free commands like `--version`.
#[allow(dead_code)] // kept for future read-only delegations
pub async fn delegate_with_cli_runtime(
&mut self,
project_path: &AbsolutePath,
args: &[String],
) -> Result<ExitStatus, Error> {
let runtime = self.ensure_cli_runtime().await?;
let node_binary = runtime.get_binary_path();
let bin_prefix = runtime.get_bin_prefix();
self.run_local_js_entry(project_path, &node_binary, &bin_prefix, args).await
}
async fn run_local_js_entry(
&self,
project_path: &AbsolutePath,
node_binary: &AbsolutePath,
bin_prefix: &AbsolutePath,
args: &[String],
) -> Result<ExitStatus, Error> {
let scripts_dir = self.get_scripts_dir()?;
let bootstrap_entry = scripts_dir.join(DELEGATE_BOOTSTRAP_FILE);
let global_entry = scripts_dir.join("bin.js");
tracing::debug!("Delegating to CLI via bootstrap {:?} {:?}", bootstrap_entry, args);
let mut cmd = match self.resolve_yarn_pnp_bin(project_path).await? {
Some(yarn_binary) => {
tracing::debug!("Using yarn node launcher for PnP project");
Self::create_yarn_node_command(&yarn_binary, bin_prefix)
}
None => Self::create_js_command(node_binary, bin_prefix),
};
cmd.arg(bootstrap_entry.as_path())
.arg(global_entry.as_path())
.args(args)
.current_dir(project_path.as_path());
Ok(cmd.status().await?)
}
async fn run_global_js_entry(
&self,
project_path: &AbsolutePath,
node_binary: &AbsolutePath,
bin_prefix: &AbsolutePath,
args: &[String],
) -> Result<ExitStatus, Error> {
let scripts_dir = self.get_scripts_dir()?;
let entry_point = scripts_dir.join("bin.js");
tracing::debug!(
"Delegating to global CLI via JS entry point: {:?} {:?}",
entry_point,
args
);
let mut cmd = match self.resolve_yarn_pnp_bin(project_path).await? {
Some(yarn_binary) => {
tracing::debug!("Using yarn node launcher for PnP project");
Self::create_yarn_node_command(&yarn_binary, bin_prefix)
}
None => Self::create_js_command(node_binary, bin_prefix),
};
cmd.arg(entry_point.as_path()).args(args).current_dir(project_path.as_path());
Ok(cmd.status().await?)
}
async fn resolve_yarn_pnp_bin(
&self,
project_path: &AbsolutePath,
) -> Result<Option<AbsolutePathBuf>, Error> {
let Some(project) = detect_yarn_pnp_project(project_path)? else {
return Ok(None);
};
let (install_dir, _, _) = download_package_manager(
PackageManagerType::Yarn,
&project.version,
project.hash.as_deref(),
)
.await?;
let yarn_bin = if cfg!(windows) {
install_dir.join("bin").join("yarn.cmd")
} else {
install_dir.join("bin").join("yarn")
};
Ok(Some(yarn_bin))
}
}
fn detect_yarn_pnp_project(project_path: &AbsolutePath) -> Result<Option<YarnPnpProject>, Error> {
let (workspace_root, _) = match find_workspace_root(project_path) {
Ok(result) => result,
Err(vite_workspace::Error::PackageJsonNotFound(_)) => return Ok(None),
Err(err) => return Err(err.into()),
};
let (package_manager_type, version, hash) =
get_package_manager_type_and_version(&workspace_root, None)?;
if package_manager_type != PackageManagerType::Yarn {
return Ok(None);
}
if !workspace_root.path.join(".pnp.cjs").as_path().exists() {
return Ok(None);
}
Ok(Some(YarnPnpProject {
version: version.to_string(),
hash: hash.map(|value| value.to_string()),
}))
}
/// Check whether a project directory has at least one valid version source.
///
/// Uses `is_valid_version` (no warning side effects) to avoid duplicate
/// warnings when `download_runtime_for_project` or `config::resolve_version`
/// later call `normalize_version` on the same values.
///
/// Returns `false` when all sources are missing or invalid, so the caller
/// can fall through to the user's configured default instead of LTS.
async fn has_valid_version_source(
project_path: &AbsolutePath,
) -> Result<bool, vite_js_runtime::Error> {
let resolution = resolve_node_version(project_path, true).await?;
let Some(ref r) = resolution else {
return Ok(false);
};
// Primary source is a valid version?
if is_valid_version(&r.version) {
return Ok(true);
}
// Primary source invalid — check package.json for valid fallbacks
let pkg_path = project_path.join("package.json");
let Ok(Some(pkg)) = read_package_json(&pkg_path).await else {
return Ok(false);
};
let engines_valid =
pkg.engines.as_ref().and_then(|e| e.node.as_ref()).is_some_and(|v| is_valid_version(v));
let dev_engines_valid = !engines_valid
&& pkg
.dev_engines
.as_ref()
.and_then(|de| de.runtime.as_ref())
.and_then(|rt| rt.find_by_name("node"))
.filter(|r| !r.version.is_empty())
.is_some_and(|r| is_valid_version(&r.version));
Ok(engines_valid || dev_engines_valid)
}
#[cfg(test)]
mod tests {
use std::fs;
use serial_test::serial;
use tempfile::TempDir;
use super::*;
#[test]
fn test_js_executor_new() {
let executor = JsExecutor::new(None);
assert!(executor.cli_runtime.is_none());
assert!(executor.project_runtime.is_none());
assert!(executor.scripts_dir.is_none());
}
#[test]
fn test_js_executor_with_scripts_dir() {
let scripts_dir = if cfg!(windows) {
AbsolutePathBuf::new("C:\\test\\scripts".into()).unwrap()
} else {
AbsolutePathBuf::new("/test/scripts".into()).unwrap()
};
let executor = JsExecutor::new(Some(scripts_dir.clone()));
assert_eq!(executor.get_scripts_dir().unwrap(), scripts_dir);
}
#[test]
fn test_create_js_command_uses_direct_binary() {
use std::ffi::OsStr;
let (runtime_binary, runtime_bin_prefix, expected_program) = if cfg!(windows) {
(
AbsolutePathBuf::new("C:\\node\\node.exe".into()).unwrap(),
AbsolutePathBuf::new("C:\\node".into()).unwrap(),
"C:\\node\\node.exe",
)
} else {
(
AbsolutePathBuf::new("/usr/local/bin/node".into()).unwrap(),
AbsolutePathBuf::new("/usr/local/bin".into()).unwrap(),
"/usr/local/bin/node",
)
};
let cmd = JsExecutor::create_js_command(&runtime_binary, &runtime_bin_prefix);
// The command should use the node binary directly
assert_eq!(cmd.as_std().get_program(), OsStr::new(expected_program));
}
#[tokio::test]
#[serial]
async fn test_delegate_to_local_cli_prints_node_version() {
use std::io::Write;
// Create a temporary directory for the scripts (used as fallback global dir)
let temp_dir = TempDir::new().unwrap();
let scripts_dir = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
// Create a bin.js that prints process.version
let script_path = temp_dir.path().join("bin.js");
let mut file = std::fs::File::create(&script_path).unwrap();
writeln!(file, "console.log(process.version);").unwrap();
let bootstrap_path = temp_dir.path().join(DELEGATE_BOOTSTRAP_FILE);
let mut bootstrap_file = std::fs::File::create(&bootstrap_path).unwrap();
writeln!(
bootstrap_file,
"import {{ pathToFileURL }} from 'node:url'; await import(pathToFileURL(process.argv[2]).href);"
)
.unwrap();
// Create executor with the temp scripts directory as global fallback
let mut executor = JsExecutor::new(Some(scripts_dir.clone()));
// Delegate — no local vite-plus will be found, so it falls back to global bin.js
let status = executor.delegate_to_local_cli(&scripts_dir, &[]).await.unwrap();
assert!(status.success(), "Script should execute successfully");
}
#[test]
fn test_detect_yarn_pnp_project_when_pnp_file_exists() {
let temp_dir = TempDir::new().unwrap();
let project_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
fs::write(
project_path.join("package.json"),
r#"{"name":"test-project","packageManager":"yarn@4.13.0"}"#,
)
.unwrap();
fs::write(project_path.join(".pnp.cjs"), "").unwrap();
let project = detect_yarn_pnp_project(&project_path).unwrap();
assert!(project.is_some(), "Expected Yarn PnP project to be detected");
}
#[test]
fn test_detect_yarn_pnp_project_ignores_yarn_without_pnp_file() {
let temp_dir = TempDir::new().unwrap();
let project_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
fs::write(
project_path.join("package.json"),
r#"{"name":"test-project","packageManager":"yarn@4.13.0"}"#,
)
.unwrap();
let project = detect_yarn_pnp_project(&project_path).unwrap();
assert!(project.is_none(), "Expected Yarn project without .pnp.cjs to be ignored");
}
#[test]
fn test_detect_yarn_pnp_project_ignores_non_yarn_projects() {
let temp_dir = TempDir::new().unwrap();
let project_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
fs::write(
project_path.join("package.json"),
r#"{"name":"test-project","packageManager":"pnpm@10.19.0"}"#,
)
.unwrap();
fs::write(project_path.join(".pnp.cjs"), "").unwrap();
let project = detect_yarn_pnp_project(&project_path).unwrap();
assert!(project.is_none(), "Expected non-Yarn project to be ignored");
}
}