From 813474dac4bd42023cc916b1599bde61968303e4 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 11 Nov 2025 10:28:10 +0800 Subject: [PATCH 1/2] feat: run command with fspy --- .github/workflows/ci.yml | 2 +- Cargo.lock | 16 + Cargo.toml | 2 + crates/vite_command/Cargo.toml | 24 ++ crates/vite_command/src/lib.rs | 338 ++++++++++++++++++ package.json | 2 +- packages/cli/binding/Cargo.toml | 2 + packages/cli/binding/index.d.ts | 80 +++++ packages/cli/binding/index.js | 3 +- packages/cli/binding/src/lib.rs | 2 + packages/cli/binding/src/utils.rs | 120 +++++++ packages/cli/build.ts | 2 +- packages/cli/package.json | 7 +- .../__snapshots__/runCommand.spec.ts.snap | 47 +++ packages/cli/src/__tests__/runCommand.spec.ts | 58 +++ packages/cli/src/binding.ts | 1 + vite.config.ts | 3 + vitest.config.ts | 12 - 18 files changed, 704 insertions(+), 17 deletions(-) create mode 100644 crates/vite_command/Cargo.toml create mode 100644 crates/vite_command/src/lib.rs create mode 100644 packages/cli/binding/src/utils.rs create mode 100644 packages/cli/src/__tests__/__snapshots__/runCommand.spec.ts.snap create mode 100644 packages/cli/src/__tests__/runCommand.spec.ts create mode 100644 packages/cli/src/binding.ts delete mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e731ad29c5..e4d30331f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -215,7 +215,7 @@ jobs: - name: Run CLI E2E tests if: ${{ matrix.os == 'windows-latest' }} run: | - RUST_BACKTRACE=1 pnpm -r snap-test + RUST_BACKTRACE=1 pnpm --filter=@voidzero-dev/vite-plus test && pnpm -r snap-test git diff --exit-code install-e2e-test: diff --git a/Cargo.lock b/Cargo.lock index e8d13c3564..b10e49d005 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4105,6 +4105,7 @@ version = "0.0.0" dependencies = [ "clap", "crossterm", + "fspy", "napi", "napi-build", "napi-derive", @@ -4116,6 +4117,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "vite_command", "vite_error", "vite_install", "vite_path", @@ -4123,6 +4125,20 @@ dependencies = [ "vite_task", ] +[[package]] +name = "vite_command" +version = "0.0.0" +dependencies = [ + "fspy", + "nix 0.30.1", + "tempfile", + "tokio", + "tracing", + "vite_error", + "vite_path", + "which", +] + [[package]] name = "vite_error" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index 6086f3369a..d2ed67148c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ criterion = { version = "0.7", features = ["html_reports"] } crossterm = { version = "0.29.0", features = ["event-stream"] } directories = "6.0.0" flate2 = "1.0.35" +fspy = { git = "https://github.com/voidzero-dev/vite-task", rev = "96bd2eba19cdbfd7612057b41debc0fbb692d1be" } futures-util = "0.3.31" hex = "0.4.3" httpmock = "0.7" @@ -64,6 +65,7 @@ thiserror = "2" tokio = "1.48.0" tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter", "serde"] } +vite_command = { path = "crates/vite_command" } vite_error = { path = "crates/vite_error" } vite_glob = { git = "https://github.com/voidzero-dev/vite-task", rev = "96bd2eba19cdbfd7612057b41debc0fbb692d1be" } vite_install = { path = "crates/vite_install" } diff --git a/crates/vite_command/Cargo.toml b/crates/vite_command/Cargo.toml new file mode 100644 index 0000000000..9ba2d3ae39 --- /dev/null +++ b/crates/vite_command/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "vite_command" +version = "0.0.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +fspy = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +vite_error = { workspace = true } +vite_path = { workspace = true } +which = { workspace = true, features = ["tracing"] } + +[target.'cfg(not(target_os = "windows"))'.dependencies] +nix = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/crates/vite_command/src/lib.rs b/crates/vite_command/src/lib.rs new file mode 100644 index 0000000000..fd2af0034a --- /dev/null +++ b/crates/vite_command/src/lib.rs @@ -0,0 +1,338 @@ +use std::{ + collections::HashMap, + ffi::OsStr, + process::{ExitStatus, Stdio}, +}; + +use fspy::AccessMode; +use tokio::process::Command; +use vite_error::Error; +use vite_path::{AbsolutePath, RelativePathBuf}; + +/// Result of running a command with fspy tracking. +#[derive(Debug)] +pub struct FspyCommandResult { + /// The termination status of the command. + pub status: ExitStatus, + /// The path accesses of the command. + pub path_accesses: HashMap, +} + +/// Run a command with the given bin name, arguments, environment variables, and current working directory. +/// +/// # Arguments +/// +/// * `bin_name`: The name of the binary to run. +/// * `args`: The arguments to pass to the binary. +/// * `envs`: The custom environment variables to set for the command, will be merged with the system environment variables. +/// * `cwd`: The current working directory for the command. +/// +/// # Returns +/// +/// Returns the exit status of the command. +pub async fn run_command( + bin_name: &str, + args: I, + envs: &HashMap, + cwd: impl AsRef, +) -> Result +where + I: IntoIterator, + S: AsRef, +{ + // Resolve the command path using which crate + // If PATH is provided in envs, use which_in to search in custom paths + // Otherwise, use which to search in system PATH + let paths = envs.get("PATH"); + let cwd = cwd.as_ref(); + let bin_path = which::which_in(bin_name, paths, cwd) + .map_err(|_| Error::CannotFindBinaryPath(bin_name.into()))?; + + let mut cmd = Command::new(bin_path); + cmd.args(args) + .envs(envs) + .current_dir(cwd) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + + // fix stdio streams on unix + #[cfg(unix)] + unsafe { + cmd.pre_exec(|| { + fix_stdio_streams(); + Ok(()) + }); + } + + let status = cmd.status().await?; + Ok(status) +} + +/// Run a command with fspy tracking. +/// +/// # Arguments +/// +/// * `bin_name`: The name of the binary to run. +/// * `args`: The arguments to pass to the binary. +/// * `envs`: The custom environment variables to set for the command. +/// * `cwd`: The current working directory for the command. +/// +/// # Returns +/// +/// Returns a FspyCommandResult containing the exit status and path accesses. +pub async fn run_command_with_fspy( + bin_name: &str, + args: I, + envs: &HashMap, + cwd: impl AsRef, +) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let cwd = cwd.as_ref(); + let mut cmd = fspy::Command::new(bin_name); + cmd.args(args) + // set system environment variables first + .envs(std::env::vars_os()) + // then set custom environment variables + .envs(envs) + .current_dir(cwd) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + + // fix stdio streams on unix + #[cfg(unix)] + unsafe { + cmd.pre_exec(|| { + fix_stdio_streams(); + Ok(()) + }); + } + + let child = cmd.spawn().await.map_err(|e| Error::Anyhow(e.into()))?; + let termination = child.wait_handle.await?; + + let mut path_accesses = HashMap::::new(); + for access in termination.path_accesses.iter() { + tracing::debug!("Path access: {:?}", access); + let relative_path = access + .path + .strip_path_prefix(cwd, |strip_result| { + let Ok(stripped_path) = strip_result else { + return None; + }; + if stripped_path.as_os_str().is_empty() { + return None; + } + tracing::debug!("stripped_path: {:?}", stripped_path); + Some(RelativePathBuf::new(stripped_path).map_err(|err| { + Error::InvalidRelativePath { path: stripped_path.into(), reason: err } + })) + }) + .transpose()?; + let Some(relative_path) = relative_path else { + continue; + }; + path_accesses + .entry(relative_path) + .and_modify(|mode| *mode |= access.mode) + .or_insert(access.mode); + } + + Ok(FspyCommandResult { status: termination.status, path_accesses }) +} + +#[cfg(unix)] +fn fix_stdio_streams() { + // libuv may mark stdin/stdout/stderr as close-on-exec, which interferes with Rust's subprocess spawning. + // As a workaround, we clear the FD_CLOEXEC flag on these file descriptors to prevent them from being closed when spawning child processes. + // + // For details see https://github.com/libuv/libuv/issues/2062 + // Fixed by reference from https://github.com/electron/electron/pull/15555 + + use std::os::fd::BorrowedFd; + + use nix::{ + fcntl::{FcntlArg, FdFlag, fcntl}, + libc::{STDERR_FILENO, STDIN_FILENO, STDOUT_FILENO}, + }; + + // Safe function to clear FD_CLOEXEC flag + fn clear_cloexec(fd: BorrowedFd<'_>) { + // Borrow RawFd as BorrowedFd to satisfy AsFd constraint + if let Ok(flags) = fcntl(fd, FcntlArg::F_GETFD) { + let mut fd_flags = FdFlag::from_bits_retain(flags); + if fd_flags.contains(FdFlag::FD_CLOEXEC) { + fd_flags.remove(FdFlag::FD_CLOEXEC); + // Ignore errors: some fd may be closed + let _ = fcntl(fd, FcntlArg::F_SETFD(fd_flags)); + } + } + } + + // Clear FD_CLOEXEC on stdin, stdout, stderr + clear_cloexec(unsafe { BorrowedFd::borrow_raw(STDIN_FILENO) }); + clear_cloexec(unsafe { BorrowedFd::borrow_raw(STDOUT_FILENO) }); + clear_cloexec(unsafe { BorrowedFd::borrow_raw(STDERR_FILENO) }); +} + +#[cfg(test)] +mod tests { + use tempfile::{TempDir, tempdir}; + use vite_path::AbsolutePathBuf; + + use super::*; + + fn create_temp_dir() -> TempDir { + tempdir().expect("Failed to create temp directory") + } + + mod run_command_tests { + + use super::*; + + #[tokio::test] + async fn test_run_command_and_find_binary_path() { + let temp_dir = create_temp_dir(); + let temp_dir_path = + AbsolutePathBuf::new(temp_dir.path().canonicalize().unwrap().to_path_buf()) + .unwrap(); + let envs = HashMap::from([( + "PATH".to_string(), + std::env::var_os("PATH").unwrap_or_default().into_string().unwrap(), + )]); + let result = run_command("npm", &["--version"], &envs, &temp_dir_path).await; + assert!(result.is_ok(), "Should run command successfully, but got error: {:?}", result); + } + + #[tokio::test] + async fn test_run_command_and_not_find_binary_path() { + let temp_dir = create_temp_dir(); + let temp_dir_path = + AbsolutePathBuf::new(temp_dir.path().canonicalize().unwrap().to_path_buf()) + .unwrap(); + let envs = HashMap::from([( + "PATH".to_string(), + std::env::var_os("PATH").unwrap_or_default().into_string().unwrap(), + )]); + let result = run_command("npm-not-exists", &["--version"], &envs, &temp_dir_path).await; + assert!(result.is_err(), "Should not find binary path, but got: {:?}", result); + assert_eq!( + result.unwrap_err().to_string(), + "Cannot find binary path for command 'npm-not-exists'" + ); + } + } + + mod run_command_with_fspy_tests { + use super::*; + + #[tokio::test] + async fn test_run_command_with_fspy() { + let temp_dir = create_temp_dir(); + let temp_dir_path = + AbsolutePathBuf::new(temp_dir.path().canonicalize().unwrap().to_path_buf()) + .unwrap(); + let envs = HashMap::from([( + "PATH".to_string(), + std::env::var_os("PATH").unwrap_or_default().into_string().unwrap(), + )]); + let result = + run_command_with_fspy("node", &["-p", "process.cwd()"], &envs, &temp_dir_path) + .await; + assert!(result.is_ok(), "Should run command successfully, but got error: {:?}", result); + let cmd_result = result.unwrap(); + assert!(cmd_result.status.success()); + } + + #[tokio::test] + async fn test_run_command_with_fspy_and_capture_path_accesses_write_file() { + let temp_dir = create_temp_dir(); + let temp_dir_path = + AbsolutePathBuf::new(temp_dir.path().canonicalize().unwrap().to_path_buf()) + .unwrap(); + let envs = HashMap::from([( + "PATH".to_string(), + std::env::var_os("PATH").unwrap_or_default().into_string().unwrap(), + )]); + // Test with a filter that only accepts paths containing "package.json" + let result = run_command_with_fspy( + "node", + &["-p", "fs.writeFileSync(path.join(process.cwd(), 'package.json'), '{}');'done'"], + &envs, + &temp_dir_path, + ) + .await; + assert!(result.is_ok(), "Should run command successfully, but got error: {:?}", result); + let cmd_result = result.unwrap(); + assert!(cmd_result.status.success()); + eprintln!("cmd_result: {:?}", cmd_result); + // Verify one path containing "package.json" is included + assert_eq!(cmd_result.path_accesses.len(), 1); + let path_access = cmd_result + .path_accesses + .get(&RelativePathBuf::new("package.json").unwrap()) + .unwrap(); + assert!(path_access.contains(AccessMode::WRITE)); + assert!(!path_access.contains(AccessMode::READ)); + } + + #[tokio::test] + async fn test_run_command_with_fspy_and_capture_path_accesses_write_and_read_file() { + let temp_dir = create_temp_dir(); + let temp_dir_path = + AbsolutePathBuf::new(temp_dir.path().canonicalize().unwrap().to_path_buf()) + .unwrap(); + let envs = HashMap::from([( + "PATH".to_string(), + std::env::var_os("PATH").unwrap_or_default().into_string().unwrap(), + )]); + // Test with a filter that only accepts paths containing "package.json" + let result = run_command_with_fspy( + "node", + &["-p", "fs.writeFileSync(path.join(process.cwd(), 'package.json'), '{}'); fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'); 'done'"], + &envs, + &temp_dir_path, + ) + .await; + assert!(result.is_ok(), "Should run command successfully, but got error: {:?}", result); + let cmd_result = result.unwrap(); + assert!(cmd_result.status.success()); + eprintln!("cmd_result: {:?}", cmd_result); + // Verify one path containing "package.json" is included + assert_eq!(cmd_result.path_accesses.len(), 1); + let path_access = cmd_result + .path_accesses + .get(&RelativePathBuf::new("package.json").unwrap()) + .unwrap(); + assert!(path_access.contains(AccessMode::WRITE)); + assert!(path_access.contains(AccessMode::READ)); + } + + #[tokio::test] + async fn test_run_command_with_fspy_and_not_find_binary_path() { + let temp_dir = create_temp_dir(); + let temp_dir_path = + AbsolutePathBuf::new(temp_dir.path().canonicalize().unwrap().to_path_buf()) + .unwrap(); + let envs = HashMap::from([( + "PATH".to_string(), + std::env::var_os("PATH").unwrap_or_default().into_string().unwrap(), + )]); + let result = + run_command_with_fspy("npm-not-exists", &["--version"], &envs, &temp_dir_path) + .await; + assert!(result.is_err(), "Should not find binary path, but got: {:?}", result); + assert!( + result + .err() + .unwrap() + .to_string() + .contains("could not resolve the full path of program '\"npm-not-exists\"'") + ); + } + } +} diff --git a/package.json b/package.json index bab1595fea..136cc698d6 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "install-global-cli": "npm install -g ./packages/global", "typecheck": "tsc -b tsconfig.json", "lint": "vite lint --type-aware --threads 4", - "test": "vite test run --config vite.config.ts && pnpm -r snap-test", + "test": "vite test run && pnpm --filter=@voidzero-dev/vite-plus test && pnpm -r snap-test", "prepare": "husky" }, "devDependencies": { diff --git a/packages/cli/binding/Cargo.toml b/packages/cli/binding/Cargo.toml index 6ea22e2dfa..8b97edbd98 100644 --- a/packages/cli/binding/Cargo.toml +++ b/packages/cli/binding/Cargo.toml @@ -10,6 +10,7 @@ path = "src/main.rs" [dependencies] clap = { workspace = true, features = ["derive"] } crossterm = { workspace = true } +fspy = { workspace = true } napi = { workspace = true } napi-derive = { workspace = true } petgraph = { workspace = true } @@ -18,6 +19,7 @@ serde_json = { workspace = true } tokio = { workspace = true, features = ["fs"] } tracing = { workspace = true } tracing-subscriber = { workspace = true } +vite_command = { workspace = true } vite_error = { workspace = true } vite_install = { workspace = true } vite_path = { workspace = true } diff --git a/packages/cli/binding/index.d.ts b/packages/cli/binding/index.d.ts index 9a94ee6b33..b853658c1c 100644 --- a/packages/cli/binding/index.d.ts +++ b/packages/cli/binding/index.d.ts @@ -42,6 +42,16 @@ export interface JsCommandResolvedResult { envs: Record } +/** Access modes for a path. */ +export interface PathAccess { + /** Whether the path was read */ + read: boolean + /** Whether the path was written */ + write: boolean + /** Whether the path was read as a directory */ + readDir: boolean +} + /** * Main entry point for the CLI, called from JavaScript. * @@ -64,3 +74,73 @@ export interface JsCommandResolvedResult { * (e.g., `LintFailed`, `ViteError`) to provide better error messages. */ export declare function run(options: CliOptions): Promise + +/** + * Run a command with fspy tracking, callable from JavaScript. + * + * This function wraps `vite_command::run_command_with_fspy` to provide + * a JavaScript-friendly interface for executing commands and tracking + * their file system accesses. + * + * ## Parameters + * + * - `options`: Configuration for the command to run, including: + * - `bin_name`: The name of the binary to execute + * - `args`: Command line arguments + * - `envs`: Environment variables + * - `cwd`: Working directory + * + * ## Returns + * + * Returns a `RunCommandResult` containing: + * - The exit code of the command + * - A map of file paths accessed and their access modes + * + * ## Example + * + * ```javascript + * const result = await runCommand({ + * binName: "node", + * args: ["-p", "console.log('hello')"], + * envs: { PATH: process.env.PATH }, + * cwd: "/tmp" + * }); + * console.log(`Exit code: ${result.exitCode}`); + * console.log(`Path accesses:`, result.pathAccesses); + * ``` + */ +export declare function runCommand(options: RunCommandOptions): Promise + +/** + * Input parameters for running a command with fspy tracking. + * + * This structure contains the information needed to execute a command: + * - `bin_name`: The name of the binary to execute + * - `args`: Command line arguments to pass to the binary + * - `envs`: Environment variables to set when executing the command + * - `cwd`: The current working directory for the command + */ +export interface RunCommandOptions { + /** The name of the binary to execute */ + binName: string + /** Command line arguments to pass to the binary */ + args: Array + /** Environment variables to set when executing the command */ + envs: Record + /** The current working directory for the command */ + cwd: string +} + +/** + * Result returned by the run_command function. + * + * This structure contains: + * - `exit_code`: The exit code of the command + * - `path_accesses`: A map of relative paths to their access modes + */ +export interface RunCommandResult { + /** The exit code of the command */ + exitCode: number + /** Map of relative paths to their access modes */ + pathAccesses: Record +} diff --git a/packages/cli/binding/index.js b/packages/cli/binding/index.js index b73c0c9a3a..e2b68ff7ea 100644 --- a/packages/cli/binding/index.js +++ b/packages/cli/binding/index.js @@ -575,5 +575,6 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { run } = nativeBinding +const { run, runCommand } = nativeBinding export { run } +export { runCommand } diff --git a/packages/cli/binding/src/lib.rs b/packages/cli/binding/src/lib.rs index b7a8b92d92..681e71a701 100644 --- a/packages/cli/binding/src/lib.rs +++ b/packages/cli/binding/src/lib.rs @@ -15,12 +15,14 @@ mod cli; mod commands; +mod utils; use std::{collections::HashMap, sync::Arc}; use clap::Parser as _; use napi::{anyhow, bindgen_prelude::*, threadsafe_function::ThreadsafeFunction}; use napi_derive::napi; +pub use utils::run_command; use vite_error::Error; use vite_path::current_dir; use vite_task::ResolveCommandResult; diff --git a/packages/cli/binding/src/utils.rs b/packages/cli/binding/src/utils.rs new file mode 100644 index 0000000000..7d3e1fc9cc --- /dev/null +++ b/packages/cli/binding/src/utils.rs @@ -0,0 +1,120 @@ +use std::{collections::HashMap, path::PathBuf}; + +use fspy::AccessMode; +use napi::{anyhow, bindgen_prelude::*}; +use napi_derive::napi; +use vite_command::run_command_with_fspy; +use vite_path::AbsolutePathBuf; + +/// Input parameters for running a command with fspy tracking. +/// +/// This structure contains the information needed to execute a command: +/// - `bin_name`: The name of the binary to execute +/// - `args`: Command line arguments to pass to the binary +/// - `envs`: Environment variables to set when executing the command +/// - `cwd`: The current working directory for the command +#[napi(object, object_to_js = false)] +#[derive(Debug)] +pub struct RunCommandOptions { + /// The name of the binary to execute + pub bin_name: String, + /// Command line arguments to pass to the binary + pub args: Vec, + /// Environment variables to set when executing the command + pub envs: HashMap, + /// The current working directory for the command + pub cwd: String, +} + +/// Access modes for a path. +#[napi(object)] +#[derive(Debug)] +pub struct PathAccess { + /// Whether the path was read + pub read: bool, + /// Whether the path was written + pub write: bool, + /// Whether the path was read as a directory + pub read_dir: bool, +} + +/// Result returned by the run_command function. +/// +/// This structure contains: +/// - `exit_code`: The exit code of the command +/// - `path_accesses`: A map of relative paths to their access modes +#[napi(object)] +#[derive(Debug)] +pub struct RunCommandResult { + /// The exit code of the command + pub exit_code: i32, + /// Map of relative paths to their access modes + pub path_accesses: HashMap, +} + +/// Run a command with fspy tracking, callable from JavaScript. +/// +/// This function wraps `vite_command::run_command_with_fspy` to provide +/// a JavaScript-friendly interface for executing commands and tracking +/// their file system accesses. +/// +/// ## Parameters +/// +/// - `options`: Configuration for the command to run, including: +/// - `bin_name`: The name of the binary to execute +/// - `args`: Command line arguments +/// - `envs`: Environment variables +/// - `cwd`: Working directory +/// +/// ## Returns +/// +/// Returns a `RunCommandResult` containing: +/// - The exit code of the command +/// - A map of file paths accessed and their access modes +/// +/// ## Example +/// +/// ```javascript +/// const result = await runCommand({ +/// binName: "node", +/// args: ["-p", "console.log('hello')"], +/// envs: { PATH: process.env.PATH }, +/// cwd: "/tmp" +/// }); +/// console.log(`Exit code: ${result.exitCode}`); +/// console.log(`Path accesses:`, result.pathAccesses); +/// ``` +#[napi] +pub async fn run_command(options: RunCommandOptions) -> Result { + tracing::debug!("Run command options: {:?}", options); + // Parse and validate the working directory + let cwd = AbsolutePathBuf::new(PathBuf::from(&options.cwd)).ok_or_else(|| { + anyhow::Error::msg(format!("Invalid working directory: {} (must be absolute)", options.cwd)) + })?; + + // Convert args from Vec to Vec<&str> + let args: Vec<&str> = options.args.iter().map(|s| s.as_str()).collect(); + + // Call the core run_command_with_fspy function + let result = run_command_with_fspy(&options.bin_name, &args, &options.envs, &cwd) + .await + .map_err(anyhow::Error::from)?; + + // Convert path accesses to JavaScript-friendly format + let mut path_accesses = HashMap::new(); + for (path, mode) in result.path_accesses { + path_accesses.insert( + path.as_str().to_string(), + PathAccess { + read: mode.contains(AccessMode::READ), + write: mode.contains(AccessMode::WRITE), + read_dir: mode.contains(AccessMode::READ_DIR), + }, + ); + } + + // Get the exit code + let exit_code = result.status.code().unwrap_or(1); + + Ok(RunCommandResult { exit_code, path_accesses }) +} diff --git a/packages/cli/build.ts b/packages/cli/build.ts index 399dfeef31..ac1b06a7ec 100644 --- a/packages/cli/build.ts +++ b/packages/cli/build.ts @@ -46,7 +46,7 @@ async function buildNapiBinding() { async function buildCli() { await build({ - input: ['./src/bin.ts', './src/index.ts', './src/config.ts'], + input: ['./src/bin.ts', './src/index.ts', './src/config.ts', './src/binding.ts'], external: [/^node:/, 'vitest-dev', './vitest/dist/config.js', './vitest/dist/index.js'], plugins: [ { diff --git a/packages/cli/package.json b/packages/cli/package.json index cf0ee69f51..5d2ea3ab70 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -11,6 +11,10 @@ "import": "./dist/index.js", "types": "./dist/index.d.ts" }, + "./binding": { + "import": "./dist/binding.js", + "types": "./dist/binding.d.ts" + }, "./bin": { "import": "./dist/bin.js" }, @@ -181,7 +185,8 @@ }, "scripts": { "build": "oxnode -C dev ./build.ts", - "snap-test": "tool snap-test" + "snap-test": "tool snap-test", + "test": "vitest run" }, "files": [ "bin", diff --git a/packages/cli/src/__tests__/__snapshots__/runCommand.spec.ts.snap b/packages/cli/src/__tests__/__snapshots__/runCommand.spec.ts.snap new file mode 100644 index 0000000000..0f98989e8c --- /dev/null +++ b/packages/cli/src/__tests__/__snapshots__/runCommand.spec.ts.snap @@ -0,0 +1,47 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`should read file on the temp directory 1`] = ` +{ + "exitCode": 0, + "pathAccesses": { + "test.txt": { + "read": true, + "readDir": false, + "write": false, + }, + }, +} +`; + +exports[`should run command successfully 1`] = ` +{ + "exitCode": 0, + "pathAccesses": {}, +} +`; + +exports[`should write and read file on the temp directory 1`] = ` +{ + "exitCode": 0, + "pathAccesses": { + "test.txt": { + "read": true, + "readDir": false, + "write": true, + }, + }, +} +`; + +exports[`should write file on the temp directory 1`] = ` +{ + "exitCode": 0, + "pathAccesses": { + "test.txt": { + "read": false, + "readDir": false, + "write": true, + }, + }, +} +`; diff --git a/packages/cli/src/__tests__/runCommand.spec.ts b/packages/cli/src/__tests__/runCommand.spec.ts new file mode 100644 index 0000000000..ec0821d4bf --- /dev/null +++ b/packages/cli/src/__tests__/runCommand.spec.ts @@ -0,0 +1,58 @@ +import fs from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { expect, test } from 'vitest'; + +import { runCommand } from '../binding.js'; + +test('should run command successfully', async () => { + const result = await runCommand({ + binName: 'node', + args: ['-e', 'console.log("Hello, world!")'], + envs: {}, + cwd: process.cwd(), + }); + expect(result).toMatchSnapshot(); +}); + +// write file on the temp directory +test('should write file on the temp directory', async () => { + const tempDir = await fs.realpath(await fs.mkdtemp(path.join(tmpdir(), 'vite-plus-test-'))); + const result = await runCommand({ + binName: 'node', + args: ['-e', `fs.writeFileSync("test.txt", "Hello, world!")`], + envs: {}, + cwd: tempDir, + }); + expect(result).toMatchSnapshot(); + expect(await fs.readFile(path.join(tempDir, 'test.txt'), 'utf-8')).toBe('Hello, world!'); + await fs.rm(tempDir, { recursive: true }); +}); + +// read file on the temp directory +test('should read file on the temp directory', async () => { + const tempDir = await fs.realpath(await fs.mkdtemp(path.join(tmpdir(), 'vite-plus-test-'))); + await fs.writeFile(path.join(tempDir, 'test.txt'), 'Hello, world!'); + const result = await runCommand({ + binName: 'node', + args: ['-e', `fs.readFileSync("test.txt", "utf-8")`], + envs: {}, + cwd: tempDir, + }); + expect(result).toMatchSnapshot(); + await fs.rm(tempDir, { recursive: true }); +}); + +// write and read file on the temp directory +test('should write and read file on the temp directory', async () => { + const tempDir = await fs.realpath(await fs.mkdtemp(path.join(tmpdir(), 'vite-plus-test-'))); + const result = await runCommand({ + binName: 'node', + args: ['-e', `fs.writeFileSync("test.txt", "Hello, world!"); fs.readFileSync("test.txt", "utf-8")`], + envs: {}, + cwd: tempDir, + }); + expect(result).toMatchSnapshot(); + await fs.rm(tempDir, { recursive: true }); +}); diff --git a/packages/cli/src/binding.ts b/packages/cli/src/binding.ts new file mode 100644 index 0000000000..64502f1b68 --- /dev/null +++ b/packages/cli/src/binding.ts @@ -0,0 +1 @@ +export * from '../binding/index.js'; diff --git a/vite.config.ts b/vite.config.ts index 97c96d03dd..11ddb2441b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,8 +9,11 @@ export default defineConfig({ test: { exclude: [ '**/node_modules/**', + '**/snap-tests/**', './rolldown/**', './rolldown-vite/**', + // FIXME: Error: failed to prepare the command for injection: Invalid argument (os error 22) + 'packages/cli/src/__tests__/', ], }, }); diff --git a/vitest.config.ts b/vitest.config.ts deleted file mode 100644 index 93d68f2969..0000000000 --- a/vitest.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { defineConfig } from '@voidzero-dev/vite-plus/config'; - -export default defineConfig({ - test: { - include: ['**/__tests__/**/*.spec.ts'], - exclude: [ - 'packages/global/templates', - // ignore __tests__ at node_modules, e.g.: packages/cli/node_modules/@napi-rs/cli/src/utils/__tests__/typegen.spec.ts - '**/node_modules', - ], - }, -}); From 4fd6e564d8f4ddab454f6f68e431b31054acb030 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 19 Nov 2025 17:32:29 +0800 Subject: [PATCH 2/2] adjust exports --- .../__tests__/__snapshots__/run-command.spec.ts.snap} | 0 .../__tests__/run-command.spec.ts} | 2 +- packages/cli/build.ts | 4 +++- packages/cli/package.json | 6 +++--- packages/cli/src/binding.ts | 1 - packages/cli/tsconfig.json | 3 ++- packages/tools/src/sync-remote-deps.ts | 1 + vite.config.ts | 2 +- 8 files changed, 11 insertions(+), 8 deletions(-) rename packages/cli/{src/__tests__/__snapshots__/runCommand.spec.ts.snap => binding/__tests__/__snapshots__/run-command.spec.ts.snap} (100%) rename packages/cli/{src/__tests__/runCommand.spec.ts => binding/__tests__/run-command.spec.ts} (97%) delete mode 100644 packages/cli/src/binding.ts diff --git a/packages/cli/src/__tests__/__snapshots__/runCommand.spec.ts.snap b/packages/cli/binding/__tests__/__snapshots__/run-command.spec.ts.snap similarity index 100% rename from packages/cli/src/__tests__/__snapshots__/runCommand.spec.ts.snap rename to packages/cli/binding/__tests__/__snapshots__/run-command.spec.ts.snap diff --git a/packages/cli/src/__tests__/runCommand.spec.ts b/packages/cli/binding/__tests__/run-command.spec.ts similarity index 97% rename from packages/cli/src/__tests__/runCommand.spec.ts rename to packages/cli/binding/__tests__/run-command.spec.ts index ec0821d4bf..eaa1bd80df 100644 --- a/packages/cli/src/__tests__/runCommand.spec.ts +++ b/packages/cli/binding/__tests__/run-command.spec.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { expect, test } from 'vitest'; -import { runCommand } from '../binding.js'; +import { runCommand } from '../index.js'; test('should run command successfully', async () => { const result = await runCommand({ diff --git a/packages/cli/build.ts b/packages/cli/build.ts index ac1b06a7ec..5dc95f920d 100644 --- a/packages/cli/build.ts +++ b/packages/cli/build.ts @@ -46,7 +46,7 @@ async function buildNapiBinding() { async function buildCli() { await build({ - input: ['./src/bin.ts', './src/index.ts', './src/config.ts', './src/binding.ts'], + input: ['./src/bin.ts', './src/index.ts', './src/config.ts'], external: [/^node:/, 'vitest-dev', './vitest/dist/config.js', './vitest/dist/index.js'], plugins: [ { @@ -89,6 +89,8 @@ async function buildCli() { }); await cp(join(rolldownViteSourceDir, 'client.d.ts'), join(projectDir, 'dist', 'vite', 'client.d.ts')); + await cp(join(projectDir, 'binding', 'index.d.ts'), join(projectDir, 'dist', 'binding.d.ts')); + await cp(join(projectDir, 'binding', 'index.js'), join(projectDir, 'dist', 'binding.js')); } async function buildVite() { diff --git a/packages/cli/package.json b/packages/cli/package.json index 5d2ea3ab70..525dd755bc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -11,13 +11,13 @@ "import": "./dist/index.js", "types": "./dist/index.d.ts" }, + "./bin": { + "import": "./dist/bin.js" + }, "./binding": { "import": "./dist/binding.js", "types": "./dist/binding.d.ts" }, - "./bin": { - "import": "./dist/bin.js" - }, "./browser": { "types": "./dist/vitest/browser/context.d.ts", "default": "./dist/vitest/browser/context.js" diff --git a/packages/cli/src/binding.ts b/packages/cli/src/binding.ts deleted file mode 100644 index 64502f1b68..0000000000 --- a/packages/cli/src/binding.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../binding/index.js'; diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 51392b2db4..6836f0857b 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -10,7 +10,8 @@ "skipLibCheck": true, "rootDir": "./src", "experimentalDecorators": true, - "customConditions": ["dev"] + "customConditions": ["dev"], + "preserveSymlinks": true }, "include": ["src"], "exclude": ["node_modules", "dist/**/*"] diff --git a/packages/tools/src/sync-remote-deps.ts b/packages/tools/src/sync-remote-deps.ts index 20f3a9453d..e140ade267 100755 --- a/packages/tools/src/sync-remote-deps.ts +++ b/packages/tools/src/sync-remote-deps.ts @@ -432,6 +432,7 @@ function mergePackageExports( const cliOwnExports = new Set([ '.', './bin', + './binding', './test', './client', './vite', diff --git a/vite.config.ts b/vite.config.ts index 11ddb2441b..36d02ef8bd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig({ './rolldown/**', './rolldown-vite/**', // FIXME: Error: failed to prepare the command for injection: Invalid argument (os error 22) - 'packages/cli/src/__tests__/', + 'packages/cli/binding/__tests__/', ], }, });