From e378addc196011c80d5bc098b0a682fe245b2229 Mon Sep 17 00:00:00 2001 From: Akokko Date: Fri, 28 Aug 2026 21:33:10 +0800 Subject: [PATCH 01/14] fix(global-cli): bound local vite-plus resolution to the workspace root Local CLI resolution (oxc_resolver in the JS executor) and the `vp --version` "Local vite-plus" probe both walk every ancestor directory's node_modules, Node-style. When the project's own install is missing or broken (e.g. after a corrupted install), resolution escapes the project and silently picks up an unrelated ancestor project's copy: delegation then runs another project's vite-plus, and `vp --version` reports that copy's version and bundled tool versions as "Local". Bound the walk at the project's workspace root via `vt_workspace::find_workspace_root`: - within the workspace, nearest wins - a workspace member still resolves the workspace root's install; - beyond it, resolution fails, so delegation falls back to the global installation and the existing missing-local-cli warning (#2361) explains the state instead of masking it; - when there is no workspace or package root at all, the walk stays unbounded (unchanged behavior for markerless directories). `find_local_vite_plus` in version.rs now derives from the same bounded walk, so what --version displays is what delegation would execute. Tested: unit tests cover the escape (red without the gate), the workspace-member case, and the markerless case; verified end-to-end with a nested-project fixture where 0.3.0 reports the outer project's copy and the patched build reports "Not found". Co-Authored-By: Claude Fable 5 --- crates/vp_global_cli/src/commands/version.rs | 39 +++---- crates/vp_global_cli/src/js_executor.rs | 110 +++++++++++++++++++ 2 files changed, 130 insertions(+), 19 deletions(-) diff --git a/crates/vp_global_cli/src/commands/version.rs b/crates/vp_global_cli/src/commands/version.rs index 26cff89b7e..9864a6fa29 100644 --- a/crates/vp_global_cli/src/commands/version.rs +++ b/crates/vp_global_cli/src/commands/version.rs @@ -9,10 +9,10 @@ use std::{ use serde::Deserialize; use vp_pm_cli::get_package_manager_type_and_version; -use vt_path::AbsolutePathBuf; +use vt_path::{AbsolutePath, AbsolutePathBuf}; use vt_workspace::find_workspace_root; -use crate::{commands::env::config::resolve_version, error::Error, help}; +use crate::{commands::env::config::resolve_version, error::Error, help, js_executor::JsExecutor}; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -64,20 +64,16 @@ fn read_package_json(package_json_path: &Path) -> Option { serde_json::from_str(&content).ok() } -fn find_local_vite_plus(start: &Path) -> Option { - let mut current = Some(start); - while let Some(dir) = current { - let package_json_path = dir.join("node_modules").join("vite-plus").join("package.json"); - if let Some(pkg) = read_package_json(&package_json_path) { - let package_dir = package_json_path.parent()?.to_path_buf(); - // Follow symlinks (pnpm links node_modules/vite-plus -> node_modules/.pnpm/.../vite-plus) - // so parent traversal can discover colocated dependency links. - let package_dir = fs::canonicalize(&package_dir).unwrap_or(package_dir); - return Some(LocalVitePlus { version: pkg.version, package_dir }); - } - current = dir.parent(); - } - None +fn find_local_vite_plus(cwd: &AbsolutePath) -> Option { + // The workspace-bounded walk keeps this display consistent with what + // delegation would actually execute (see `local_vite_plus_install_host`). + let host = JsExecutor::local_vite_plus_install_host(cwd)?; + let package_dir = host.as_path().join("node_modules").join("vite-plus"); + let pkg = read_package_json(&package_dir.join("package.json"))?; + // Follow symlinks (pnpm links node_modules/vite-plus -> node_modules/.pnpm/.../vite-plus) + // so parent traversal can discover colocated dependency links. + let package_dir = fs::canonicalize(&package_dir).unwrap_or(package_dir); + Some(LocalVitePlus { version: pkg.version, package_dir }) } fn read_toolchain_manifest(local: &LocalVitePlus) -> Option { @@ -173,7 +169,7 @@ pub async fn execute(cwd: AbsolutePathBuf) -> Result { println!(); // Local vite-plus and tools - let local = find_local_vite_plus(cwd.as_path()); + let local = find_local_vite_plus(&cwd); print_rows( "Local vite-plus", &[("vite-plus", format_version(local.as_ref().map(|pkg| pkg.version.clone())))], @@ -228,6 +224,9 @@ mod tests { #[cfg(unix)] use std::{fs, path::Path}; + #[cfg(unix)] + use vt_path::AbsolutePath; + #[cfg(unix)] use super::{TOOL_SPECS, find_local_vite_plus, read_toolchain_manifest, resolve_tool_version}; use super::{detect_system_node_version, format_version}; @@ -307,7 +306,8 @@ mod tests { &node_modules_dir.join("vite-plus"), ); - let local = find_local_vite_plus(project).expect("expected local vite-plus to resolve"); + let local = find_local_vite_plus(AbsolutePath::new(project).unwrap()) + .expect("expected local vite-plus to resolve"); let manifest = read_toolchain_manifest(&local).expect("expected manifest to resolve"); assert_eq!( resolve_tool_version(Some(&local), Some(&manifest), TOOL_SPECS[0]).as_deref(), @@ -342,7 +342,8 @@ mod tests { &node_modules_dir.join("vite-plus"), ); - let local = find_local_vite_plus(project).expect("expected local vite-plus to resolve"); + let local = find_local_vite_plus(AbsolutePath::new(project).unwrap()) + .expect("expected local vite-plus to resolve"); assert_eq!( resolve_tool_version(Some(&local), None, TOOL_SPECS[0]).as_deref(), Some("8.0.0"), diff --git a/crates/vp_global_cli/src/js_executor.rs b/crates/vp_global_cli/src/js_executor.rs index 245dcbd900..c9166d7b57 100644 --- a/crates/vp_global_cli/src/js_executor.rs +++ b/crates/vp_global_cli/src/js_executor.rs @@ -407,12 +407,50 @@ impl JsExecutor { Ok(output) } + /// Find the directory whose `node_modules/vite-plus` an upward walk from + /// `project_path` is allowed to use. + /// + /// Node-style resolution walks every ancestor's `node_modules`, which can + /// escape the project and silently pick up an unrelated ancestor project's + /// copy (e.g. a repo checked out inside another project's tree, whose own + /// install is missing or broken). Bound the walk at the project's workspace + /// root: within it, nearest wins (a workspace member still resolves the + /// workspace root's install); beyond it, resolution fails so callers fall + /// back to the global installation. When there is no workspace or package + /// root at all (`find_workspace_root` errors), there is no project boundary + /// to protect and the walk stays unbounded. + pub(crate) fn local_vite_plus_install_host( + project_path: &AbsolutePath, + ) -> Option { + let boundary = + vt_workspace::find_workspace_root(project_path).ok().map(|(root, _)| root.path); + + let mut current = project_path; + loop { + if current.join("node_modules/vite-plus/package.json").as_path().exists() { + return Some(current.to_absolute_path_buf()); + } + if boundary.as_deref().is_some_and(|boundary| current == boundary) { + return None; + } + match current.parent() { + Some(parent) if parent != current => current = parent, + _ => return None, + } + } + } + /// Resolve the local vite-plus package root from the project directory. pub(crate) fn resolve_local_vite_plus_package_dir( project_path: &AbsolutePath, ) -> Option { use oxc_resolver::{ResolveOptions, Resolver}; + // Only trust an install that lives within the project's workspace; the + // Node-semantics resolution below would otherwise walk past it (see + // `local_vite_plus_install_host`). + Self::local_vite_plus_install_host(project_path)?; + let resolver = Resolver::new(ResolveOptions { condition_names: vec!["import".into(), "node".into()], ..ResolveOptions::default() @@ -528,6 +566,78 @@ mod tests { dir } + /// An independent project (with its own workspace marker) checked out + /// inside another project's tree must not resolve the outer project's + /// vite-plus when its own install is missing — Node-style upward + /// resolution would otherwise silently delegate to an unrelated copy. + #[test] + fn local_resolution_stays_within_the_workspace() { + let temp = tempfile::tempdir().unwrap(); + let outer = temp.path(); + std::fs::create_dir_all(outer.join("node_modules/vite-plus/dist")).unwrap(); + std::fs::write(outer.join("node_modules/vite-plus/package.json"), r#"{"version":"0.2.1"}"#) + .unwrap(); + std::fs::write(outer.join("node_modules/vite-plus/dist/bin.js"), "").unwrap(); + std::fs::write(outer.join("pnpm-workspace.yaml"), "packages: []\n").unwrap(); + std::fs::write(outer.join("package.json"), r#"{"name":"outer"}"#).unwrap(); + + let inner = outer.join("external/inner"); + std::fs::create_dir_all(&inner).unwrap(); + std::fs::write( + inner.join("package.json"), + r#"{"name":"inner","devDependencies":{"vite-plus":"0.3.0"}}"#, + ) + .unwrap(); + std::fs::write(inner.join("pnpm-workspace.yaml"), "packages: []\n").unwrap(); + + let inner = AbsolutePath::new(inner.as_path()).unwrap(); + assert_eq!(JsExecutor::local_vite_plus_install_host(inner), None); + assert_eq!(JsExecutor::resolve_local_vite_plus_package_dir(inner), None); + assert_eq!(JsExecutor::resolve_local_vite_plus(inner), None); + } + + /// A workspace member still resolves the workspace root's install: the + /// boundary is the workspace root, not the member directory. + #[test] + fn workspace_member_resolves_the_workspace_root_install() { + let temp = tempfile::tempdir().unwrap(); + let ws = temp.path(); + std::fs::write(ws.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap(); + std::fs::write(ws.join("package.json"), r#"{"name":"ws"}"#).unwrap(); + std::fs::create_dir_all(ws.join("node_modules/vite-plus")).unwrap(); + std::fs::write(ws.join("node_modules/vite-plus/package.json"), r#"{"version":"0.3.0"}"#) + .unwrap(); + let member = ws.join("packages/app"); + std::fs::create_dir_all(&member).unwrap(); + std::fs::write(member.join("package.json"), r#"{"name":"app"}"#).unwrap(); + + let member = AbsolutePath::new(member.as_path()).unwrap(); + let host = JsExecutor::local_vite_plus_install_host(member) + .expect("workspace root install must stay resolvable"); + assert_eq!(host.as_path(), ws); + let pkg_dir = JsExecutor::resolve_local_vite_plus_package_dir(member) + .expect("workspace root install must stay resolvable"); + assert!(pkg_dir.as_path().ends_with("node_modules/vite-plus")); + } + + /// Without any project marker around (`find_workspace_root` errors) there + /// is no boundary to protect; the walk stays unbounded as before. + #[test] + fn unbounded_walk_without_project_markers() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + std::fs::create_dir_all(root.join("node_modules/vite-plus")).unwrap(); + std::fs::write(root.join("node_modules/vite-plus/package.json"), r#"{"version":"1.0.0"}"#) + .unwrap(); + let nested = root.join("a/b"); + std::fs::create_dir_all(&nested).unwrap(); + + let nested = AbsolutePath::new(nested.as_path()).unwrap(); + let host = JsExecutor::local_vite_plus_install_host(nested) + .expect("markerless directories keep the unbounded walk"); + assert_eq!(host.as_path(), root); + } + #[test] fn test_local_vite_plus_is_older() { // Older local should escalate. From 8c21eccd392d2200c4e63566e156c0e952152aca Mon Sep 17 00:00:00 2001 From: Akokko Date: Fri, 28 Aug 2026 21:51:18 +0800 Subject: [PATCH 02/14] test(global-cli): gate the markerless-walk test to unix The test's premise is that no ancestor of the tempdir carries a package.json. That holds for /tmp and /var/folders, but Windows' %TEMP% lives under the user profile, where a stray package.json would create a workspace boundary and fail the test for environmental reasons. Co-Authored-By: Claude Fable 5 --- crates/vp_global_cli/src/js_executor.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/vp_global_cli/src/js_executor.rs b/crates/vp_global_cli/src/js_executor.rs index c9166d7b57..955f104991 100644 --- a/crates/vp_global_cli/src/js_executor.rs +++ b/crates/vp_global_cli/src/js_executor.rs @@ -622,6 +622,12 @@ mod tests { /// Without any project marker around (`find_workspace_root` errors) there /// is no boundary to protect; the walk stays unbounded as before. + /// + /// Unix-only: the premise is that no ancestor of the tempdir carries a + /// package.json, which holds for `/tmp` / `/var/folders` but not for + /// Windows, where `%TEMP%` lives under the user profile and a stray + /// `package.json` there would create a boundary and fail the test. + #[cfg(unix)] #[test] fn unbounded_walk_without_project_markers() { let temp = tempfile::tempdir().unwrap(); From 38507059f1c40b99ec66c7a76725da2d52fab5a2 Mon Sep 17 00:00:00 2001 From: Akokko Date: Sun, 30 Aug 2026 13:09:54 +0800 Subject: [PATCH 03/14] fix(global-cli): only bound the walk for projects that declare vite-plus the previous commit bounded local resolution at the workspace root for every project. that breaks a layout the repo itself relies on: the snapshot harness stages workspaces with no node_modules of their own and resolves the run-root install through Node's unbounded upward walk, so all three CLI snapshot jobs went red with the same signature - the global CLI stopped seeing the project-local install (75 cases, every diff a "does not use vite-plus" warning) walking past the package root is ordinary Node resolution semantics and hoisted installs depend on it, so the default stays unbounded. the boundary now applies only when the project declares a vite-plus dependency - directly or at its workspace root, the same test warn_missing_local_cli_if_project uses - because that is exactly the case where "run vp install" is the right answer rather than silently borrowing an unrelated ancestor's copy - new test pins the harness-shaped layout: an undeclared staged workspace keeps resolving the run-root install (mutation-verified: removing the declaration filter reds it) - the workspace-member test's root now declares the dependency so the bounded walk is actually engaged rather than passing via the unbounded default - snapshot fixtures do not declare vite-plus, so they take the unbounded path; the declared-but-missing fixture resolves its own install at the first hop either way Co-Authored-By: Claude Fable 5 --- crates/vp_global_cli/src/js_executor.rs | 84 +++++++++++++++++++------ 1 file changed, 64 insertions(+), 20 deletions(-) diff --git a/crates/vp_global_cli/src/js_executor.rs b/crates/vp_global_cli/src/js_executor.rs index 955f104991..b3739c4ccd 100644 --- a/crates/vp_global_cli/src/js_executor.rs +++ b/crates/vp_global_cli/src/js_executor.rs @@ -410,20 +410,29 @@ impl JsExecutor { /// Find the directory whose `node_modules/vite-plus` an upward walk from /// `project_path` is allowed to use. /// - /// Node-style resolution walks every ancestor's `node_modules`, which can - /// escape the project and silently pick up an unrelated ancestor project's - /// copy (e.g. a repo checked out inside another project's tree, whose own - /// install is missing or broken). Bound the walk at the project's workspace - /// root: within it, nearest wins (a workspace member still resolves the - /// workspace root's install); beyond it, resolution fails so callers fall - /// back to the global installation. When there is no workspace or package - /// root at all (`find_workspace_root` errors), there is no project boundary - /// to protect and the walk stays unbounded. + /// Walking every ancestor's `node_modules` is ordinary Node resolution + /// semantics, and layouts legitimately rely on it (hoisted installs; the + /// snapshot harness stages workspaces with no `node_modules` of their own + /// that resolve a run-root install). So the walk stays unbounded by + /// default. The exception is a project that *declares* a `vite-plus` + /// dependency (directly, or at its workspace root — the same test + /// `warn_missing_local_cli_if_project` applies): for it, escaping the + /// workspace root would silently borrow an unrelated ancestor's copy in + /// exactly the situation where "run `vp install`" is the right answer. + /// Only then is the walk bounded at the workspace root: within it, + /// nearest wins (a workspace member still resolves the workspace root's + /// install); beyond it, resolution fails so callers fall back to the + /// global installation and the install hint. pub(crate) fn local_vite_plus_install_host( project_path: &AbsolutePath, ) -> Option { - let boundary = - vt_workspace::find_workspace_root(project_path).ok().map(|(root, _)| root.path); + let boundary = vt_workspace::find_workspace_root(project_path) + .ok() + .filter(|(root, _)| { + crate::commands::has_vite_plus_dependency(project_path) + || crate::commands::has_vite_plus_dependency(root.path.as_ref()) + }) + .map(|(root, _)| root.path); let mut current = project_path; loop { @@ -446,9 +455,9 @@ impl JsExecutor { ) -> Option { use oxc_resolver::{ResolveOptions, Resolver}; - // Only trust an install that lives within the project's workspace; the - // Node-semantics resolution below would otherwise walk past it (see - // `local_vite_plus_install_host`). + // For projects that declare a vite-plus dependency, only trust an + // install within their workspace; the Node-semantics resolution below + // would otherwise walk past it (see `local_vite_plus_install_host`). Self::local_vite_plus_install_host(project_path)?; let resolver = Resolver::new(ResolveOptions { @@ -566,10 +575,11 @@ mod tests { dir } - /// An independent project (with its own workspace marker) checked out - /// inside another project's tree must not resolve the outer project's - /// vite-plus when its own install is missing — Node-style upward - /// resolution would otherwise silently delegate to an unrelated copy. + /// An independent project that *declares* a vite-plus dependency (with + /// its own workspace marker) checked out inside another project's tree + /// must not resolve the outer project's vite-plus when its own install is + /// missing — the declaration makes "run `vp install`" the right answer, + /// not silently delegating to an unrelated copy. #[test] fn local_resolution_stays_within_the_workspace() { let temp = tempfile::tempdir().unwrap(); @@ -597,13 +607,20 @@ mod tests { } /// A workspace member still resolves the workspace root's install: the - /// boundary is the workspace root, not the member directory. + /// boundary is the workspace root, not the member directory. The root + /// declares the dependency so the bounded walk is actually engaged — + /// without a declaration this case would pass trivially via the + /// unbounded default. #[test] fn workspace_member_resolves_the_workspace_root_install() { let temp = tempfile::tempdir().unwrap(); let ws = temp.path(); std::fs::write(ws.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap(); - std::fs::write(ws.join("package.json"), r#"{"name":"ws"}"#).unwrap(); + std::fs::write( + ws.join("package.json"), + r#"{"name":"ws","devDependencies":{"vite-plus":"0.3.0"}}"#, + ) + .unwrap(); std::fs::create_dir_all(ws.join("node_modules/vite-plus")).unwrap(); std::fs::write(ws.join("node_modules/vite-plus/package.json"), r#"{"version":"0.3.0"}"#) .unwrap(); @@ -620,6 +637,33 @@ mod tests { assert!(pkg_dir.as_path().ends_with("node_modules/vite-plus")); } + /// A project that does *not* declare a vite-plus dependency keeps Node's + /// unbounded upward resolution even across its own workspace marker — + /// this is the layout the snapshot harness depends on (staged workspaces + /// with no `node_modules` of their own, resolving a run-root install). + #[test] + fn undeclared_project_keeps_the_unbounded_walk() { + let temp = tempfile::tempdir().unwrap(); + let outer = temp.path(); + std::fs::create_dir_all(outer.join("node_modules/vite-plus/dist")).unwrap(); + std::fs::write(outer.join("node_modules/vite-plus/package.json"), r#"{"version":"0.2.1"}"#) + .unwrap(); + std::fs::write(outer.join("node_modules/vite-plus/dist/bin.js"), "").unwrap(); + + let inner = outer.join("cases/one/workspace"); + std::fs::create_dir_all(&inner).unwrap(); + std::fs::write(inner.join("package.json"), r#"{"name":"inner"}"#).unwrap(); + std::fs::write(inner.join("pnpm-workspace.yaml"), "packages: []\n").unwrap(); + + let inner = AbsolutePath::new(inner.as_path()).unwrap(); + let host = JsExecutor::local_vite_plus_install_host(inner) + .expect("undeclared projects keep the unbounded walk"); + assert_eq!(host.as_path(), outer); + let pkg_dir = JsExecutor::resolve_local_vite_plus_package_dir(inner) + .expect("undeclared projects keep the unbounded walk"); + assert!(pkg_dir.as_path().ends_with("node_modules/vite-plus")); + } + /// Without any project marker around (`find_workspace_root` errors) there /// is no boundary to protect; the walk stays unbounded as before. /// From 8d54c83a343ace4c432c3b034746b53d9d4076f8 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 12 Sep 2026 00:11:57 +0800 Subject: [PATCH 04/14] test: link local CLI within migration fixture workspaces --- .../tests/cli_snapshots/README.md | 8 ++++++++ .../snapshots.toml | 1 + .../migration_pack_tsdown_023/snapshots.toml | 3 +++ .../tests/cli_snapshots/main.rs | 18 ++++++++++++++++++ 4 files changed, 30 insertions(+) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/README.md b/crates/vp_cli_snapshots/tests/cli_snapshots/README.md index 9129433673..a1e8399557 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/README.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/README.md @@ -94,6 +94,8 @@ seed-runtime = true # false: start from an empty VP_HOME link-node-modules = false # true: expose the run-root node_modules as # the workspace's parent-dir node_modules, # for `../node_modules/vite-plus/...` paths +link-local-vite-plus = false # true: link the checkout CLI into the workspace's + # node_modules/vite-plus for local delegation env = { MY_VAR = "1" } # case-wide env additions unset-env = ["SOME_VAR"] # remove baseline env entries steps = [ ... ] @@ -214,6 +216,12 @@ into the run root's `node_modules`, where Node's upward walk finds them from any staged workspace. Anything else a fixture imports must be vendored inside the fixture itself. +Cases that declare a `vite-plus` dependency and run built-in commands without +installing dependencies can set `link-local-vite-plus = true`. This links the +checkout CLI at `node_modules/vite-plus` inside the staged workspace, where +workspace-bounded CLI resolution can find it. The path must not already exist +in the fixture. Leave this disabled for tests of missing local installations. + Snapshots are plain-text screen grids: styling is flattened, and redaction masks paths, durations, versions, UUIDs, thread counts, byte-size numbers (units kept: ` kB`), and content-hash asset suffixes (see diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_eslint_svelte_runes/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_eslint_svelte_runes/snapshots.toml index aac256e57d..9534ee8933 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_eslint_svelte_runes/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_eslint_svelte_runes/snapshots.toml @@ -1,6 +1,7 @@ [[case]] name = "migration_eslint_svelte_runes" vp = "global" +link-local-vite-plus = true steps = [ { argv = ["vp", "migrate", "--no-interactive"], comment = "migration should add Svelte rune globals to the lint override", continue-on-failure = true }, { argv = ["vpt", "print-file", "vite.config.ts"], comment = "Svelte override includes every built-in rune as a readonly global", continue-on-failure = true }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots.toml index b4e78906e1..0ee4f3a0fa 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots.toml @@ -13,6 +13,7 @@ steps = [ [[case]] name = "migration_pack_tsdown_023_build" vp = "global" +link-local-vite-plus = true comment = "Build a migrated library and check that static assets and declarations are emitted." steps = [ { argv = ["vpt", "write-file", "vite.config.ts", "export default { pack: { entry: 'src/index.ts', bundle: false, publicDir: 'public', removeNodeProtocol: true, dts: { oxc: true, cjsReexport: false } } };"], snapshot = false }, @@ -26,6 +27,7 @@ steps = [ [[case]] name = "migration_pack_tsdown_023_external" vp = "global" +link-local-vite-plus = true comment = "Preserve external matchers when either skipNodeModulesBundle form becomes deps.neverBundle." steps = [ { argv = ["vpt", "cp", "external.config.txt", "vite.config.ts"], snapshot = false }, @@ -43,6 +45,7 @@ steps = [ [[case]] name = "migration_pack_tsdown_023_concise_methods" vp = "global" +link-local-vite-plus = true comment = "Migrate a standalone concise arrow and method options, then check unbundled files and copied assets." steps = [ { argv = ["vpt", "cp", "concise.config.txt", "tsdown.config.ts"], snapshot = false }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs b/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs index 4c6a920385..844da6c126 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs @@ -391,6 +391,10 @@ struct Case { /// through Node's upward walk. #[serde(default, rename = "link-node-modules")] link_node_modules: bool, + /// Link the checkout CLI into the workspace's node_modules so commands + /// can resolve a project-local installation within the workspace boundary. + #[serde(default, rename = "link-local-vite-plus")] + link_local_vite_plus: bool, /// Case-wide environment additions on top of the runner baseline. #[serde(default)] env: BTreeMap, @@ -1206,6 +1210,20 @@ fn run_case( .copy_tree(fixture_path, &stage) .unwrap(); + if case.link_local_vite_plus { + let node_modules = stage.join("node_modules"); + std::fs::create_dir_all(&node_modules) + .map_err(|e| format!("failed to create workspace node_modules: {e}"))?; + let local_vite_plus = node_modules.join("vite-plus"); + if std::fs::symlink_metadata(&local_vite_plus).is_ok() { + return Err("link-local-vite-plus requires no fixture node_modules/vite-plus".into()); + } + flavor::link_dir(&runtime.cli_package_dir, &local_vite_plus); + if !local_vite_plus.is_dir() { + return Err("failed to link workspace node_modules/vite-plus".into()); + } + } + let case_home = CaseHome::provision(&case_root, case.seed_runtime); let case_install = case_home.provision_vite_plus(flavor, runtime)?; From 1fcb9d4da991c1cd4810ffae11ee15d46fd7103f Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 12 Sep 2026 00:58:51 +0800 Subject: [PATCH 05/14] test: stabilize setup and migration fixtures --- .../snapshots.toml | 2 ++ crates/vp_setup/src/install.rs | 16 +++++++--------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_oxc_configs/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_oxc_configs/snapshots.toml index eb4e7224f9..1962760688 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_oxc_configs/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_oxc_configs/snapshots.toml @@ -1,6 +1,7 @@ [[case]] name = "migration_existing_oxc_configs" vp = "global" +link-local-vite-plus = true steps = [ { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "finish a leftover Oxfmt config even when Vite+ is already installed" }, ["vpt", "print-file", "vite.config.ts"], @@ -17,6 +18,7 @@ steps = [ [[case]] name = "migration_existing_oxc_configs_inline_fmt" vp = "global" +link-local-vite-plus = true steps = [ { argv = ["vpt", "write-file", "vite.config.ts", "export default { fmt: { singleQuote: false, semi: false } };\n"], snapshot = false }, { argv = ["vp", "fmt", "src/index.ts"], comment = "the existing inline fmt config takes precedence over the standalone config" }, diff --git a/crates/vp_setup/src/install.rs b/crates/vp_setup/src/install.rs index e4970941db..de599fdf7f 100644 --- a/crates/vp_setup/src/install.rs +++ b/crates/vp_setup/src/install.rs @@ -830,25 +830,23 @@ mod tests { async fn fake_pnpm_runtime( version_dir: &AbsolutePath, ) -> (AbsolutePathBuf, AbsolutePathBuf, vp_js_runtime::JsRuntime, AbsolutePathBuf) { - use std::os::unix::fs::PermissionsExt; - let node_bin = version_dir.join("node").join("bin"); let pnpm_bin = version_dir.join("pnpm").join("bin"); tokio::fs::create_dir_all(&node_bin).await.unwrap(); tokio::fs::create_dir_all(&pnpm_bin).await.unwrap(); let node_binary = node_bin.join("node"); + // Execute an existing shell, with the generated script as input. + // Parallel process creation can briefly inherit a newly written + // executable's open descriptor and cause ETXTBSY on Linux. + std::os::unix::fs::symlink("/bin/sh", &node_binary).unwrap(); + let pnpm_entry = pnpm_bin.join("pnpm.cjs"); tokio::fs::write( - &node_binary, - "#!/bin/sh\nprintf '%s\\n' \"$@\" > invocation.txt\nprintf '%s' \"$PATH\" > path.txt\nprintf '%s' \"$npm_config_registry\" > registry.txt\n", + &pnpm_entry, + "printf '%s\\n' \"$0\" \"$@\" > invocation.txt\nprintf '%s' \"$PATH\" > path.txt\nprintf '%s' \"$npm_config_registry\" > registry.txt\n", ) .await .unwrap(); - tokio::fs::set_permissions(&node_binary, std::fs::Permissions::from_mode(0o755)) - .await - .unwrap(); - let pnpm_entry = pnpm_bin.join("pnpm.cjs"); - tokio::fs::write(&pnpm_entry, "").await.unwrap(); let node_runtime = vp_js_runtime::JsRuntime::from_system(JsRuntimeType::Node, node_binary); (node_bin, pnpm_bin, node_runtime, pnpm_entry) From 07ab2b45da41af0d4e7f6afb68941c290d6c6c62 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 12 Sep 2026 01:00:33 +0800 Subject: [PATCH 06/14] fix(global-cli): validate local CLI workspace boundaries --- Cargo.lock | 2 + Cargo.toml | 1 + .../assert-boundary.mjs | 28 ++++ .../outer-cli/dist/bin.js | 1 + .../outer-cli/package.json | 4 + .../outer/external/inner/package.json | 6 + .../outer/package.json | 10 ++ .../outer/packages/member/package.json | 3 + .../local_cli_workspace_boundary/package.json | 4 + .../snapshots.toml | 42 ++++++ .../snapshots/bom_manifest.md | 22 +++ .../snapshots/excluded_project.md | 15 +++ .../snapshots/malformed_ancestor.md | 18 +++ .../snapshots/workspace_member.md | 15 +++ crates/vp_global_cli/Cargo.toml | 2 + .../src/commands/local_install.rs | 112 ++++++++++++++++ crates/vp_global_cli/src/commands/mod.rs | 43 ++++-- crates/vp_global_cli/src/js_executor.rs | 126 +++++++++++++++--- 18 files changed, 425 insertions(+), 29 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/assert-boundary.mjs create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/outer-cli/dist/bin.js create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/outer-cli/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/outer/external/inner/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/outer/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/outer/packages/member/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/bom_manifest.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/excluded_project.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/malformed_ancestor.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/workspace_member.md create mode 100644 crates/vp_global_cli/src/commands/local_install.rs diff --git a/Cargo.lock b/Cargo.lock index 2234e59fa3..1846b921fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8757,6 +8757,7 @@ dependencies = [ "same-file", "serde", "serde_json", + "serde_yaml", "serial_test", "tar", "temp-env", @@ -8773,6 +8774,7 @@ dependencies = [ "vp_setup", "vp_shared", "vp_toolchain", + "vt_glob", "vt_path", "vt_str", "vt_workspace", diff --git a/Cargo.toml b/Cargo.toml index f0324ac465..7d54d359fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -306,6 +306,7 @@ vp_shared = { path = "crates/vp_shared" } vp_static_config = { path = "crates/vp_static_config" } vp_toolchain = { path = "crates/vp_toolchain" } vt = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" } +vt_glob = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" } vt_path = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" } vt_powershell = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" } vt_select = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "d05b1dcdbaabaa69643ee0b89cebe3cd390957e9" } diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/assert-boundary.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/assert-boundary.mjs new file mode 100644 index 0000000000..f6690d039b --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/assert-boundary.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { resolve } from 'node:path' + +const [directory, expected] = process.argv.slice(2) +assert.ok(expected === 'global' || expected === 'local') +const cwd = resolve(directory) +function run(args) { + const result = spawnSync('vp', args, { cwd, encoding: 'utf8' }) + if (result.error) throw result.error + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`) + return `${result.stdout}${result.stderr}` +} + +const version = run(['--version']) +const delegation = run(['lint', '--help']) +if (expected === 'global') { + assert.match(version, /Local vite-plus:\s*\n\s*vite-plus\s+Not found/) + assert.match(delegation, /No project-local vite-plus installation was found/) + assert.match(delegation, /Usage: vp lint/) + assert.doesNotMatch(delegation, /Ancestor workspace CLI executed/) + console.log('Version reports no local CLI; delegation uses the global CLI with an install warning.') +} else { + assert.match(version, /Local vite-plus:\s*\n\s*vite-plus\s+v9\.8\.7/) + assert.match(delegation, /Ancestor workspace CLI executed/) + assert.doesNotMatch(delegation, /No project-local vite-plus installation was found/) + console.log('Version and delegation use the workspace root CLI without an install warning.') +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/outer-cli/dist/bin.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/outer-cli/dist/bin.js new file mode 100644 index 0000000000..834e7cbca3 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/outer-cli/dist/bin.js @@ -0,0 +1 @@ +console.log('Ancestor workspace CLI executed.') diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/outer-cli/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/outer-cli/package.json new file mode 100644 index 0000000000..211f1e058c --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/outer-cli/package.json @@ -0,0 +1,4 @@ +{ + "name": "vite-plus", + "version": "9.8.7" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/outer/external/inner/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/outer/external/inner/package.json new file mode 100644 index 0000000000..89bb0c3b2e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/outer/external/inner/package.json @@ -0,0 +1,6 @@ +{ + "name": "inner", + "devDependencies": { + "vite-plus": "0.3.1" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/outer/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/outer/package.json new file mode 100644 index 0000000000..16ae1b8985 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/outer/package.json @@ -0,0 +1,10 @@ +{ + "name": "outer", + "private": true, + "workspaces": [ + "packages/*" + ], + "devDependencies": { + "vite-plus": "0.3.1" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/outer/packages/member/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/outer/packages/member/package.json new file mode 100644 index 0000000000..e9587fd8ea --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/outer/packages/member/package.json @@ -0,0 +1,3 @@ +{ + "name": "member" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/package.json new file mode 100644 index 0000000000..45934df23f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/package.json @@ -0,0 +1,4 @@ +{ + "name": "workspace-boundary-fixture", + "private": true +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots.toml new file mode 100644 index 0000000000..b091e257b1 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots.toml @@ -0,0 +1,42 @@ +[[case]] +name = "excluded_project" +vp = "global" +comment = "An independent project outside the workspace patterns cannot borrow its ancestor's CLI." +steps = [ + { argv = ["vpt", "mkdir", "-p", "outer/node_modules"], snapshot = false }, + { argv = ["vpt", "cp", "-r", "outer-cli", "outer/node_modules/vite-plus"], snapshot = false }, + ["node", "assert-boundary.mjs", "outer/external/inner", "global"], +] + +[[case]] +name = "workspace_member" +vp = "global" +comment = "A member can use the workspace root's declared CLI dependency." +steps = [ + { argv = ["vpt", "mkdir", "-p", "outer/node_modules"], snapshot = false }, + { argv = ["vpt", "cp", "-r", "outer-cli", "outer/node_modules/vite-plus"], snapshot = false }, + ["node", "assert-boundary.mjs", "outer/packages/member", "local"], +] + +[[case]] +name = "malformed_ancestor" +vp = "global" +comment = "A malformed ancestor manifest cannot remove the independent project's boundary." +steps = [ + { argv = ["vpt", "mkdir", "-p", "outer/node_modules"], snapshot = false }, + { argv = ["vpt", "cp", "-r", "outer-cli", "outer/node_modules/vite-plus"], snapshot = false }, + { argv = ["vpt", "write-file", "outer/package.json", "{"], snapshot = false }, + ["node", "assert-boundary.mjs", "outer/external/inner", "global"], +] + +[[case]] +name = "bom_manifest" +vp = "global" +comment = "A UTF-8 BOM does not hide the project's vite-plus dependency." +steps = [ + { argv = ["vpt", "mkdir", "-p", "outer/node_modules"], snapshot = false }, + { argv = ["vpt", "cp", "-r", "outer-cli", "outer/node_modules/vite-plus"], snapshot = false }, + { argv = ["vpt", "write-file", "outer/external/inner/package.json", "\uFEFF{\"name\":\"inner\",\"devDependencies\":{\"vite-plus\":\"0.3.1\"}}"], snapshot = false }, + { argv = ["vpt", "write-file", "outer/external/inner/pnpm-workspace.yaml", "packages: []\n"], snapshot = false }, + ["node", "assert-boundary.mjs", "outer/external/inner", "global"], +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/bom_manifest.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/bom_manifest.md new file mode 100644 index 0000000000..0bab18017c --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/bom_manifest.md @@ -0,0 +1,22 @@ +# bom_manifest + +A UTF-8 BOM does not hide the project's vite-plus dependency. + +## `vpt mkdir -p outer/node_modules` + + +## `vpt cp -r outer-cli outer/node_modules/vite-plus` + + +## `vpt write-file outer/external/inner/package.json '{"name":"inner","devDependencies":{"vite-plus":"0.3.1"}}'` + + +## `vpt write-file outer/external/inner/pnpm-workspace.yaml 'packages: [] +'` + + +## `node assert-boundary.mjs outer/external/inner global` + +``` +Version reports no local CLI; delegation uses the global CLI with an install warning. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/excluded_project.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/excluded_project.md new file mode 100644 index 0000000000..6e3918ab57 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/excluded_project.md @@ -0,0 +1,15 @@ +# excluded_project + +An independent project outside the workspace patterns cannot borrow its ancestor's CLI. + +## `vpt mkdir -p outer/node_modules` + + +## `vpt cp -r outer-cli outer/node_modules/vite-plus` + + +## `node assert-boundary.mjs outer/external/inner global` + +``` +Version reports no local CLI; delegation uses the global CLI with an install warning. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/malformed_ancestor.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/malformed_ancestor.md new file mode 100644 index 0000000000..40dbbb2883 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/malformed_ancestor.md @@ -0,0 +1,18 @@ +# malformed_ancestor + +A malformed ancestor manifest cannot remove the independent project's boundary. + +## `vpt mkdir -p outer/node_modules` + + +## `vpt cp -r outer-cli outer/node_modules/vite-plus` + + +## `vpt write-file outer/package.json {` + + +## `node assert-boundary.mjs outer/external/inner global` + +``` +Version reports no local CLI; delegation uses the global CLI with an install warning. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/workspace_member.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/workspace_member.md new file mode 100644 index 0000000000..5e15ddd8c2 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/workspace_member.md @@ -0,0 +1,15 @@ +# workspace_member + +A member can use the workspace root's declared CLI dependency. + +## `vpt mkdir -p outer/node_modules` + + +## `vpt cp -r outer-cli outer/node_modules/vite-plus` + + +## `node assert-boundary.mjs outer/packages/member local` + +``` +Version and delegation use the workspace root CLI without an install warning. +``` diff --git a/crates/vp_global_cli/Cargo.toml b/crates/vp_global_cli/Cargo.toml index dfc21d99cb..4e5eb2dc2b 100644 --- a/crates/vp_global_cli/Cargo.toml +++ b/crates/vp_global_cli/Cargo.toml @@ -23,6 +23,7 @@ futures = { workspace = true } flate2 = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +serde_yaml = { workspace = true } node-semver = { workspace = true } thiserror = { workspace = true } tar = { workspace = true } @@ -41,6 +42,7 @@ vp_error = { workspace = true } vp_js_runtime = { workspace = true } vp_pm_cli = { workspace = true } vt_path = { workspace = true } +vt_glob = { workspace = true } vp_command = { workspace = true } vp_cli_help = { workspace = true } vp_setup = { workspace = true } diff --git a/crates/vp_global_cli/src/commands/local_install.rs b/crates/vp_global_cli/src/commands/local_install.rs new file mode 100644 index 0000000000..d566c46cba --- /dev/null +++ b/crates/vp_global_cli/src/commands/local_install.rs @@ -0,0 +1,112 @@ +use serde::Deserialize; +use vt_glob::path::PathGlobSet; +use vt_path::{AbsolutePath, AbsolutePathBuf}; +use vt_str::Str; +use vt_workspace::{WorkspaceFile, WorkspaceRoot, find_workspace_root}; + +use super::{find_nearest_package_json, read_dependency_manifest, strip_bom}; + +/// Bound declared Vite+ projects at their package root, extending to a +/// workspace root only for actual members. Unknown manifests keep the +/// nearest known package boundary instead of permitting an ancestor install. +pub(crate) fn local_vite_plus_boundary(cwd: &AbsolutePath) -> Option { + let package_json = find_nearest_package_json(cwd)?; + let package_root = package_json.parent()?; + let Some(package) = read_dependency_manifest(&package_json) else { + return Some(package_root.to_absolute_path_buf()); + }; + let Ok((workspace, _)) = find_workspace_root(cwd) else { + return Some(package_root.to_absolute_path_buf()); + }; + let boundary = match workspace_contains_package(&workspace, package_root) { + Some(true) => workspace.path.to_absolute_path_buf(), + Some(false) => package_root.to_absolute_path_buf(), + None => return Some(package_root.to_absolute_path_buf()), + }; + + if boundary == package_root { + return package.has_vite_plus().then_some(boundary); + } + + let workspace_package_json = boundary.join("package.json"); + let workspace_declares = match read_dependency_manifest(&workspace_package_json) { + Some(workspace_package) => workspace_package.has_vite_plus(), + None if workspace_package_json.as_path().exists() => { + return Some(package_root.to_absolute_path_buf()); + } + None => false, + }; + (package.has_vite_plus() || workspace_declares).then_some(boundary) +} + +#[derive(Deserialize)] +struct PnpmWorkspace { + #[serde(default)] + packages: Vec, +} + +#[derive(Deserialize)] +struct NpmWorkspace { + workspaces: NpmWorkspaces, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum NpmWorkspaces { + Array(Vec), + Object { packages: Vec }, +} + +/// Match the nearest package, not the cwd or every manifest in the workspace. +/// `None` means workspace configuration could not be read or compiled. +fn workspace_contains_package(workspace: &WorkspaceRoot, package: &AbsolutePath) -> Option { + if package == workspace.path.as_ref() { + return Some(true); + } + let manifest = package.join("package.json"); + let relative = manifest.strip_prefix(&workspace.path).ok()??; + if relative.as_path().components().any(|component| component.as_os_str() == "node_modules") { + return Some(false); + } + let patterns = match &workspace.workspace_file { + WorkspaceFile::PnpmWorkspaceYaml(file) => { + serde_yaml::from_slice::(strip_bom(file.content())).ok()?.packages + } + WorkspaceFile::NpmWorkspaceJson(file) => { + match serde_json::from_slice::(strip_bom(file.content())).ok()?.workspaces + { + NpmWorkspaces::Array(patterns) | NpmWorkspaces::Object { packages: patterns } => { + patterns + } + } + } + WorkspaceFile::NonWorkspacePackage(_) => return Some(false), + }; + + // Match vt_workspace's WorkspaceMemberGlobs normalization and ordered + // exclusions, without walking the filesystem or loading a package graph. + let patterns: Vec = patterns + .iter() + .map(|pattern| { + let exclusions = pattern.bytes().take_while(|byte| *byte == b'!').count(); + let path = &pattern[exclusions..]; + let without_dot = path.strip_prefix('.').unwrap_or(path); + let path = if without_dot.starts_with('/') { + without_dot.trim_start_matches('/') + } else { + path + }; + let mut normalized = Str::with_capacity(pattern.len() + "/package.json".len()); + if exclusions % 2 == 1 { + normalized.push('!'); + } + normalized.push_str(path); + if !path.is_empty() && !path.ends_with('/') { + normalized.push('/'); + } + normalized.push_str("package.json"); + normalized + }) + .collect(); + Some(PathGlobSet::new(&patterns).ok()?.is_match(relative.as_path())) +} diff --git a/crates/vp_global_cli/src/commands/mod.rs b/crates/vp_global_cli/src/commands/mod.rs index ddcf4655fc..7b19dd778f 100644 --- a/crates/vp_global_cli/src/commands/mod.rs +++ b/crates/vp_global_cli/src/commands/mod.rs @@ -16,13 +16,16 @@ //! Category C - Local CLI Delegation: //! - `delegate`: Local CLI delegation -use std::{collections::HashMap, io::BufReader}; +use std::collections::HashMap; use vp_shared::{PrependOptions, output, prepend_tools_to_path_env}; use vt_path::{AbsolutePath, AbsolutePathBuf}; use crate::{error::Error, js_executor::JsExecutor}; +mod local_install; +pub(crate) use local_install::local_vite_plus_boundary; + #[derive(serde::Deserialize, Default)] #[serde(rename_all = "camelCase")] struct DepCheckPackageJson { @@ -34,6 +37,14 @@ struct DepCheckPackageJson { optional_dependencies: HashMap, } +impl DepCheckPackageJson { + fn has_vite_plus(&self) -> bool { + self.dependencies.contains_key("vite-plus") + || self.dev_dependencies.contains_key("vite-plus") + || self.optional_dependencies.contains_key("vite-plus") + } +} + fn find_nearest_package_json(cwd: &AbsolutePath) -> Option { let mut current = cwd; loop { @@ -49,14 +60,16 @@ fn find_nearest_package_json(cwd: &AbsolutePath) -> Option { } fn package_json_has_vite_plus_dependency(package_json_path: &AbsolutePath) -> bool { - if let Ok(file) = std::fs::File::open(package_json_path) - && let Ok(pkg) = serde_json::from_reader::<_, DepCheckPackageJson>(BufReader::new(file)) - { - return pkg.dependencies.contains_key("vite-plus") - || pkg.dev_dependencies.contains_key("vite-plus") - || pkg.optional_dependencies.contains_key("vite-plus"); - } - false + read_dependency_manifest(package_json_path).is_some_and(|pkg| pkg.has_vite_plus()) +} + +fn read_dependency_manifest(package_json_path: &AbsolutePath) -> Option { + let content = std::fs::read(package_json_path).ok()?; + serde_json::from_slice(strip_bom(&content)).ok() +} + +fn strip_bom(content: &[u8]) -> &[u8] { + content.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(content) } fn find_vite_plus_dependency(cwd: &AbsolutePath) -> Option { @@ -215,6 +228,18 @@ mod tests { assert!(has_vite_plus_dependency(&temp_path)); } + #[test] + fn test_has_vite_plus_in_bom_prefixed_manifest() { + let temp_dir = tempfile::tempdir().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + std::fs::write( + temp_path.join("package.json"), + "\u{feff}{\"devDependencies\":{\"vite-plus\":\"^1.0.0\"}}", + ) + .unwrap(); + assert!(has_vite_plus_dependency(&temp_path)); + } + #[test] fn test_has_vite_plus_in_dependencies() { let temp_dir = tempfile::tempdir().unwrap(); diff --git a/crates/vp_global_cli/src/js_executor.rs b/crates/vp_global_cli/src/js_executor.rs index b3739c4ccd..0e8a3e1dd9 100644 --- a/crates/vp_global_cli/src/js_executor.rs +++ b/crates/vp_global_cli/src/js_executor.rs @@ -410,29 +410,14 @@ impl JsExecutor { /// Find the directory whose `node_modules/vite-plus` an upward walk from /// `project_path` is allowed to use. /// - /// Walking every ancestor's `node_modules` is ordinary Node resolution - /// semantics, and layouts legitimately rely on it (hoisted installs; the - /// snapshot harness stages workspaces with no `node_modules` of their own - /// that resolve a run-root install). So the walk stays unbounded by - /// default. The exception is a project that *declares* a `vite-plus` - /// dependency (directly, or at its workspace root — the same test - /// `warn_missing_local_cli_if_project` applies): for it, escaping the - /// workspace root would silently borrow an unrelated ancestor's copy in - /// exactly the situation where "run `vp install`" is the right answer. - /// Only then is the walk bounded at the workspace root: within it, - /// nearest wins (a workspace member still resolves the workspace root's - /// install); beyond it, resolution fails so callers fall back to the - /// global installation and the install hint. + /// Declared Vite+ projects stay within their package or workspace boundary; + /// an ancestor workspace must include the package to supply its install. + /// Unreadable manifests preserve the nearest known package boundary. + /// Undeclared projects and markerless directories keep Node's upward walk. pub(crate) fn local_vite_plus_install_host( project_path: &AbsolutePath, ) -> Option { - let boundary = vt_workspace::find_workspace_root(project_path) - .ok() - .filter(|(root, _)| { - crate::commands::has_vite_plus_dependency(project_path) - || crate::commands::has_vite_plus_dependency(root.path.as_ref()) - }) - .map(|(root, _)| root.path); + let boundary = commands::local_vite_plus_boundary(project_path); let mut current = project_path; loop { @@ -637,6 +622,107 @@ mod tests { assert!(pkg_dir.as_path().ends_with("node_modules/vite-plus")); } + #[test] + fn local_resolution_checks_workspace_membership() { + for (workspace_file, content, package, is_member) in [ + ("package.json", r#"{"workspaces":["packages/*"]}"#, "external/inner", false), + ("package.json", r#"{"workspaces":["packages/*"]}"#, "packages/app", true), + ("package.json", r#"{"workspaces":["**"]}"#, "node_modules/inner", false), + ( + "package.json", + r#"{"workspaces":{"packages":["./packages/*","!./packages/excluded"]}}"#, + "packages/excluded", + false, + ), + ( + "package.json", + r#"{"workspaces":{"packages":["./packages/*","!./packages/excluded"]}}"#, + "packages/app", + true, + ), + ("pnpm-workspace.yaml", "packages:\n - packages/*\n", "external/inner", false), + ( + "pnpm-workspace.yaml", + "packages:\n - packages/*\n - '!packages/excluded'\n", + "packages/excluded", + false, + ), + ( + "pnpm-workspace.yaml", + "packages:\n - './packages/{app,other}/'\n", + "packages/app", + true, + ), + ] { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + std::fs::write(root.join("package.json"), r#"{"name":"outer"}"#).unwrap(); + std::fs::write(root.join(workspace_file), content).unwrap(); + std::fs::create_dir_all(root.join("node_modules/vite-plus/dist")).unwrap(); + std::fs::write( + root.join("node_modules/vite-plus/package.json"), + r#"{"version":"0.3.0"}"#, + ) + .unwrap(); + std::fs::write(root.join("node_modules/vite-plus/dist/bin.js"), "").unwrap(); + // Membership must not require reading unrelated members' manifests. + std::fs::create_dir_all(root.join("packages/broken")).unwrap(); + std::fs::write(root.join("packages/broken/package.json"), "{").unwrap(); + + let project = root.join(package); + std::fs::create_dir_all(project.join("src")).unwrap(); + std::fs::write( + project.join("package.json"), + r#"{"name":"inner","devDependencies":{"vite-plus":"0.3.0"}}"#, + ) + .unwrap(); + let cwd = project.join("src"); + let cwd = AbsolutePath::new(&cwd).unwrap(); + assert_eq!( + JsExecutor::resolve_local_vite_plus(cwd).is_some(), + is_member, + "{workspace_file}: {content}, package: {package}", + ); + } + } + + #[test] + fn local_resolution_preserves_boundary_on_manifest_errors() { + for (ancestor, project, workspace) in [ + ("{", r#"{"devDependencies":{"vite-plus":"0.3.0"}}"#, None), + (r#"{"workspaces":["["]}"#, r#"{"devDependencies":{"vite-plus":"0.3.0"}}"#, None), + ("{}", r#"{"devDependencies":{"vite-plus":"0.3.0"}}"#, Some("packages: [")), + ("{}", "{", None), + ("{}", "{", Some("packages:\n - inner\n")), + ("{", "{}", Some("packages:\n - inner\n")), + ("{}", "\u{feff}{\"devDependencies\":{\"vite-plus\":\"0.3.0\"}}", None), + ] { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + std::fs::write(root.join("package.json"), ancestor).unwrap(); + if let Some(workspace) = workspace { + std::fs::write(root.join("pnpm-workspace.yaml"), workspace).unwrap(); + } + std::fs::create_dir_all(root.join("node_modules/vite-plus/dist")).unwrap(); + std::fs::write( + root.join("node_modules/vite-plus/package.json"), + r#"{"version":"0.2.1"}"#, + ) + .unwrap(); + std::fs::write(root.join("node_modules/vite-plus/dist/bin.js"), "").unwrap(); + let inner = root.join("inner"); + std::fs::create_dir_all(&inner).unwrap(); + std::fs::write(inner.join("package.json"), project).unwrap(); + let inner = AbsolutePath::new(&inner).unwrap(); + assert_eq!( + JsExecutor::local_vite_plus_install_host(inner), + None, + "{ancestor}: {project}" + ); + assert_eq!(JsExecutor::resolve_local_vite_plus(inner), None); + } + } + /// A project that does *not* declare a vite-plus dependency keeps Node's /// unbounded upward resolution even across its own workspace marker — /// this is the layout the snapshot harness depends on (staged workspaces From 8e00c3aeeb5828d6c54db523d600ba7dbd70f375 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 12 Sep 2026 01:11:21 +0800 Subject: [PATCH 07/14] test: snapshot local CLI boundary output directly --- .../assert-boundary.mjs | 28 --------------- .../snapshots.toml | 12 ++++--- .../snapshots/bom_manifest.md | 31 ++++++++++++++-- .../snapshots/excluded_project.md | 31 ++++++++++++++-- .../snapshots/malformed_ancestor.md | 36 +++++++++++++++++-- .../snapshots/workspace_member.md | 30 ++++++++++++++-- 6 files changed, 128 insertions(+), 40 deletions(-) delete mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/assert-boundary.mjs diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/assert-boundary.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/assert-boundary.mjs deleted file mode 100644 index f6690d039b..0000000000 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/assert-boundary.mjs +++ /dev/null @@ -1,28 +0,0 @@ -import assert from 'node:assert/strict' -import { spawnSync } from 'node:child_process' -import { resolve } from 'node:path' - -const [directory, expected] = process.argv.slice(2) -assert.ok(expected === 'global' || expected === 'local') -const cwd = resolve(directory) -function run(args) { - const result = spawnSync('vp', args, { cwd, encoding: 'utf8' }) - if (result.error) throw result.error - assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`) - return `${result.stdout}${result.stderr}` -} - -const version = run(['--version']) -const delegation = run(['lint', '--help']) -if (expected === 'global') { - assert.match(version, /Local vite-plus:\s*\n\s*vite-plus\s+Not found/) - assert.match(delegation, /No project-local vite-plus installation was found/) - assert.match(delegation, /Usage: vp lint/) - assert.doesNotMatch(delegation, /Ancestor workspace CLI executed/) - console.log('Version reports no local CLI; delegation uses the global CLI with an install warning.') -} else { - assert.match(version, /Local vite-plus:\s*\n\s*vite-plus\s+v9\.8\.7/) - assert.match(delegation, /Ancestor workspace CLI executed/) - assert.doesNotMatch(delegation, /No project-local vite-plus installation was found/) - console.log('Version and delegation use the workspace root CLI without an install warning.') -} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots.toml index b091e257b1..a6dfa51e2b 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots.toml @@ -5,7 +5,8 @@ comment = "An independent project outside the workspace patterns cannot borrow i steps = [ { argv = ["vpt", "mkdir", "-p", "outer/node_modules"], snapshot = false }, { argv = ["vpt", "cp", "-r", "outer-cli", "outer/node_modules/vite-plus"], snapshot = false }, - ["node", "assert-boundary.mjs", "outer/external/inner", "global"], + { argv = ["vp", "--version"], cwd = "outer/external/inner" }, + { argv = ["vp", "lint", "--version"], cwd = "outer/external/inner" }, ] [[case]] @@ -15,7 +16,8 @@ comment = "A member can use the workspace root's declared CLI dependency." steps = [ { argv = ["vpt", "mkdir", "-p", "outer/node_modules"], snapshot = false }, { argv = ["vpt", "cp", "-r", "outer-cli", "outer/node_modules/vite-plus"], snapshot = false }, - ["node", "assert-boundary.mjs", "outer/packages/member", "local"], + { argv = ["vp", "--version"], cwd = "outer/packages/member" }, + { argv = ["vp", "lint", "--version"], cwd = "outer/packages/member" }, ] [[case]] @@ -26,7 +28,8 @@ steps = [ { argv = ["vpt", "mkdir", "-p", "outer/node_modules"], snapshot = false }, { argv = ["vpt", "cp", "-r", "outer-cli", "outer/node_modules/vite-plus"], snapshot = false }, { argv = ["vpt", "write-file", "outer/package.json", "{"], snapshot = false }, - ["node", "assert-boundary.mjs", "outer/external/inner", "global"], + { argv = ["vp", "--version"], cwd = "outer/external/inner" }, + { argv = ["vp", "lint", "--version"], cwd = "outer/external/inner", comment = "the global CLI reports the malformed manifest instead of running the ancestor CLI" }, ] [[case]] @@ -38,5 +41,6 @@ steps = [ { argv = ["vpt", "cp", "-r", "outer-cli", "outer/node_modules/vite-plus"], snapshot = false }, { argv = ["vpt", "write-file", "outer/external/inner/package.json", "\uFEFF{\"name\":\"inner\",\"devDependencies\":{\"vite-plus\":\"0.3.1\"}}"], snapshot = false }, { argv = ["vpt", "write-file", "outer/external/inner/pnpm-workspace.yaml", "packages: []\n"], snapshot = false }, - ["node", "assert-boundary.mjs", "outer/external/inner", "global"], + { argv = ["vp", "--version"], cwd = "outer/external/inner" }, + { argv = ["vp", "lint", "--version"], cwd = "outer/external/inner" }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/bom_manifest.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/bom_manifest.md index 0bab18017c..bfde511edd 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/bom_manifest.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/bom_manifest.md @@ -15,8 +15,35 @@ A UTF-8 BOM does not hide the project's vite-plus dependency. '` -## `node assert-boundary.mjs outer/external/inner global` +## `cd outer/external/inner && vp --version` ``` -Version reports no local CLI; delegation uses the global CLI with an install warning. +VITE+ - The Unified Toolchain for the Web + +vp + +Local vite-plus: + vite-plus Not found + +Tools: + vite Not found + rolldown Not found + vitest Not found + oxfmt Not found + oxlint Not found + oxlint-tsgolint Not found + tsdown Not found + +Environment: + Package manager Not found + Node.js +``` + +## `cd outer/external/inner && vp lint --version` + +``` +VITE+ - The Unified Toolchain for the Web + +warn: No project-local vite-plus installation was found. Run `vp install` in `/outer/external/inner` to install dependencies. +Version: 1.81.0 ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/excluded_project.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/excluded_project.md index 6e3918ab57..f5a133c9d2 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/excluded_project.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/excluded_project.md @@ -8,8 +8,35 @@ An independent project outside the workspace patterns cannot borrow its ancestor ## `vpt cp -r outer-cli outer/node_modules/vite-plus` -## `node assert-boundary.mjs outer/external/inner global` +## `cd outer/external/inner && vp --version` ``` -Version reports no local CLI; delegation uses the global CLI with an install warning. +VITE+ - The Unified Toolchain for the Web + +vp + +Local vite-plus: + vite-plus Not found + +Tools: + vite Not found + rolldown Not found + vitest Not found + oxfmt Not found + oxlint Not found + oxlint-tsgolint Not found + tsdown Not found + +Environment: + Package manager Not found + Node.js +``` + +## `cd outer/external/inner && vp lint --version` + +``` +VITE+ - The Unified Toolchain for the Web + +warn: No project-local vite-plus installation was found. Run `vp install` in `/outer/external/inner` to install dependencies. +Version: 1.81.0 ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/malformed_ancestor.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/malformed_ancestor.md index 40dbbb2883..e819bf2be1 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/malformed_ancestor.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/malformed_ancestor.md @@ -11,8 +11,40 @@ A malformed ancestor manifest cannot remove the independent project's boundary. ## `vpt write-file outer/package.json {` -## `node assert-boundary.mjs outer/external/inner global` +## `cd outer/external/inner && vp --version` ``` -Version reports no local CLI; delegation uses the global CLI with an install warning. +VITE+ - The Unified Toolchain for the Web + +vp + +Local vite-plus: + vite-plus Not found + +Tools: + vite Not found + rolldown Not found + vitest Not found + oxfmt Not found + oxlint Not found + oxlint-tsgolint Not found + tsdown Not found + +Environment: + Package manager Not found + Node.js +``` + +## `cd outer/external/inner && vp lint --version` + +the global CLI reports the malformed manifest instead of running the ancestor CLI + +**Exit code:** 1 + +``` +VITE+ - The Unified Toolchain for the Web + +warn: No project-local vite-plus installation was found. Run `vp install` in `/outer/external/inner` to install dependencies. +error: Failed to parse JSON file at /outer/package.json +* EOF while parsing an object at line 1 column 1 ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/workspace_member.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/workspace_member.md index 5e15ddd8c2..658ff68559 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/workspace_member.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/workspace_member.md @@ -8,8 +8,34 @@ A member can use the workspace root's declared CLI dependency. ## `vpt cp -r outer-cli outer/node_modules/vite-plus` -## `node assert-boundary.mjs outer/packages/member local` +## `cd outer/packages/member && vp --version` ``` -Version and delegation use the workspace root CLI without an install warning. +VITE+ - The Unified Toolchain for the Web + +vp + +Local vite-plus: + vite-plus + +Tools: + vite + rolldown + vitest Not found + oxfmt Not found + oxlint Not found + oxlint-tsgolint Not found + tsdown + +Environment: + Package manager Not found + Node.js +``` + +## `cd outer/packages/member && vp lint --version` + +``` +VITE+ - The Unified Toolchain for the Web + +Ancestor workspace CLI executed. ``` From b03fddbf9e1abd67dc5bb402ea062ec7e06ac5f5 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 12 Sep 2026 01:30:08 +0800 Subject: [PATCH 08/14] test: preserve shell name for BusyBox runtime stub --- crates/vp_setup/src/install.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/vp_setup/src/install.rs b/crates/vp_setup/src/install.rs index de599fdf7f..22adea7b13 100644 --- a/crates/vp_setup/src/install.rs +++ b/crates/vp_setup/src/install.rs @@ -835,11 +835,12 @@ mod tests { tokio::fs::create_dir_all(&node_bin).await.unwrap(); tokio::fs::create_dir_all(&pnpm_bin).await.unwrap(); - let node_binary = node_bin.join("node"); // Execute an existing shell, with the generated script as input. // Parallel process creation can briefly inherit a newly written // executable's open descriptor and cause ETXTBSY on Linux. - std::os::unix::fs::symlink("/bin/sh", &node_binary).unwrap(); + // Keep the sh basename: BusyBox selects its applet from argv[0]. + let runtime_binary = node_bin.join("sh"); + std::os::unix::fs::symlink("/bin/sh", &runtime_binary).unwrap(); let pnpm_entry = pnpm_bin.join("pnpm.cjs"); tokio::fs::write( &pnpm_entry, @@ -847,7 +848,8 @@ mod tests { ) .await .unwrap(); - let node_runtime = vp_js_runtime::JsRuntime::from_system(JsRuntimeType::Node, node_binary); + let node_runtime = + vp_js_runtime::JsRuntime::from_system(JsRuntimeType::Node, runtime_binary); (node_bin, pnpm_bin, node_runtime, pnpm_entry) } From 6bf0c0be52e42470aabef1ff6e67790dda4f181d Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 12 Sep 2026 01:31:22 +0800 Subject: [PATCH 09/14] fix(global-cli): retain workspaces without root manifests --- .../snapshots.toml | 20 +++ .../workspace_without_root_manifest.md | 119 ++++++++++++++++++ .../src/commands/local_install.rs | 16 ++- crates/vp_global_cli/src/js_executor.rs | 44 +++++++ 4 files changed, 195 insertions(+), 4 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/workspace_without_root_manifest.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots.toml index a6dfa51e2b..2c262648f8 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots.toml @@ -20,6 +20,26 @@ steps = [ { argv = ["vp", "lint", "--version"], cwd = "outer/packages/member" }, ] +[[case]] +name = "workspace_without_root_manifest" +vp = "global" +comment = "A pnpm workspace without a root package.json cannot borrow an outer project's CLI, but can use its own install." +steps = [ + { argv = ["vpt", "mkdir", "-p", "outer/node_modules", "outer/external/inner/apps/app"], snapshot = false }, + { argv = ["vpt", "cp", "-r", "outer-cli", "outer/node_modules/vite-plus"], snapshot = false }, + { argv = ["vpt", "rm", "outer/external/inner/package.json"], snapshot = false }, + { argv = ["vpt", "write-file", "outer/external/inner/pnpm-workspace.yaml", "packages:\n - apps/*\n"], snapshot = false }, + { argv = ["vpt", "write-file", "outer/external/inner/apps/app/package.json", '{"name":"app","devDependencies":{"vite-plus":"0.3.1"}}'], snapshot = false }, + { argv = ["vp", "--version"], cwd = "outer/external/inner" }, + { argv = ["vp", "lint", "--version"], cwd = "outer/external/inner" }, + { argv = ["vp", "--version"], cwd = "outer/external/inner/apps/app" }, + { argv = ["vpt", "mkdir", "-p", "outer/external/inner/node_modules"], snapshot = false }, + { argv = ["vpt", "cp", "-r", "outer-cli", "outer/external/inner/node_modules/vite-plus"], snapshot = false }, + { argv = ["vpt", "write-file", "outer/external/inner/node_modules/vite-plus/dist/bin.js", "console.log('Workspace-local CLI executed.')\n"], snapshot = false }, + { argv = ["vp", "--version"], cwd = "outer/external/inner" }, + { argv = ["vp", "lint", "--version"], cwd = "outer/external/inner" }, +] + [[case]] name = "malformed_ancestor" vp = "global" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/workspace_without_root_manifest.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/workspace_without_root_manifest.md new file mode 100644 index 0000000000..dad3a2035d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/workspace_without_root_manifest.md @@ -0,0 +1,119 @@ +# workspace_without_root_manifest + +A pnpm workspace without a root package.json cannot borrow an outer project's CLI, but can use its own install. + +## `vpt mkdir -p outer/node_modules outer/external/inner/apps/app` + + +## `vpt cp -r outer-cli outer/node_modules/vite-plus` + + +## `vpt rm outer/external/inner/package.json` + + +## `vpt write-file outer/external/inner/pnpm-workspace.yaml 'packages: + - apps/* +'` + + +## `vpt write-file outer/external/inner/apps/app/package.json '{"name":"app","devDependencies":{"vite-plus":"0.3.1"}}'` + + +## `cd outer/external/inner && vp --version` + +``` +VITE+ - The Unified Toolchain for the Web + +vp + +Local vite-plus: + vite-plus Not found + +Tools: + vite Not found + rolldown Not found + vitest Not found + oxfmt Not found + oxlint Not found + oxlint-tsgolint Not found + tsdown Not found + +Environment: + Package manager pnpm latest + Node.js +``` + +## `cd outer/external/inner && vp lint --version` + +``` +VITE+ - The Unified Toolchain for the Web + +warn: No project-local vite-plus installation was found. Run `vp install` in `/outer/external/inner` to install dependencies. +Version: 1.81.0 +``` + +## `cd outer/external/inner/apps/app && vp --version` + +``` +VITE+ - The Unified Toolchain for the Web + +vp + +Local vite-plus: + vite-plus Not found + +Tools: + vite Not found + rolldown Not found + vitest Not found + oxfmt Not found + oxlint Not found + oxlint-tsgolint Not found + tsdown Not found + +Environment: + Package manager pnpm latest + Node.js +``` + +## `vpt mkdir -p outer/external/inner/node_modules` + + +## `vpt cp -r outer-cli outer/external/inner/node_modules/vite-plus` + + +## `vpt write-file outer/external/inner/node_modules/vite-plus/dist/bin.js 'console.log('\''Workspace-local CLI executed.'\'') +'` + + +## `cd outer/external/inner && vp --version` + +``` +VITE+ - The Unified Toolchain for the Web + +vp + +Local vite-plus: + vite-plus + +Tools: + vite + rolldown + vitest Not found + oxfmt Not found + oxlint Not found + oxlint-tsgolint Not found + tsdown + +Environment: + Package manager pnpm latest + Node.js +``` + +## `cd outer/external/inner && vp lint --version` + +``` +VITE+ - The Unified Toolchain for the Web + +Workspace-local CLI executed. +``` diff --git a/crates/vp_global_cli/src/commands/local_install.rs b/crates/vp_global_cli/src/commands/local_install.rs index d566c46cba..e643e19942 100644 --- a/crates/vp_global_cli/src/commands/local_install.rs +++ b/crates/vp_global_cli/src/commands/local_install.rs @@ -10,14 +10,22 @@ use super::{find_nearest_package_json, read_dependency_manifest, strip_bom}; /// workspace root only for actual members. Unknown manifests keep the /// nearest known package boundary instead of permitting an ancestor install. pub(crate) fn local_vite_plus_boundary(cwd: &AbsolutePath) -> Option { - let package_json = find_nearest_package_json(cwd)?; + let package_json = find_nearest_package_json(cwd); + let Ok((workspace, _)) = find_workspace_root(cwd) else { + return Some(package_json?.parent()?.to_absolute_path_buf()); + }; + let Some(package_json) = package_json else { + return Some(workspace.path.to_absolute_path_buf()); + }; let package_root = package_json.parent()?; + // A pnpm workspace can have no root package.json. Its nearest manifest + // may belong to an outer project, which must not supply its boundary. + if !package_root.as_path().starts_with(workspace.path.as_path()) { + return Some(workspace.path.to_absolute_path_buf()); + } let Some(package) = read_dependency_manifest(&package_json) else { return Some(package_root.to_absolute_path_buf()); }; - let Ok((workspace, _)) = find_workspace_root(cwd) else { - return Some(package_root.to_absolute_path_buf()); - }; let boundary = match workspace_contains_package(&workspace, package_root) { Some(true) => workspace.path.to_absolute_path_buf(), Some(false) => package_root.to_absolute_path_buf(), diff --git a/crates/vp_global_cli/src/js_executor.rs b/crates/vp_global_cli/src/js_executor.rs index 0e8a3e1dd9..4e5445834c 100644 --- a/crates/vp_global_cli/src/js_executor.rs +++ b/crates/vp_global_cli/src/js_executor.rs @@ -622,6 +622,50 @@ mod tests { assert!(pkg_dir.as_path().ends_with("node_modules/vite-plus")); } + #[test] + fn workspace_without_root_manifest_keeps_its_boundary() { + for ancestor in + [None, Some("{}"), Some(r#"{"devDependencies":{"vite-plus":"0.3.0"}}"#), Some("{")] + { + let temp = tempfile::tempdir().unwrap(); + let outer = temp.path(); + if let Some(ancestor) = ancestor { + std::fs::write(outer.join("package.json"), ancestor).unwrap(); + } + std::fs::create_dir_all(outer.join("node_modules/vite-plus/dist")).unwrap(); + std::fs::write( + outer.join("node_modules/vite-plus/package.json"), + r#"{"version":"0.2.1"}"#, + ) + .unwrap(); + std::fs::write(outer.join("node_modules/vite-plus/dist/bin.js"), "").unwrap(); + + let workspace = outer.join("inner"); + std::fs::create_dir_all(workspace.join("src")).unwrap(); + std::fs::write(workspace.join("pnpm-workspace.yaml"), "packages: []\n").unwrap(); + for cwd in [&workspace, &workspace.join("src")] { + let cwd = AbsolutePath::new(cwd).unwrap(); + assert_eq!( + JsExecutor::local_vite_plus_install_host(cwd), + None, + "ancestor: {ancestor:?}" + ); + assert_eq!(JsExecutor::resolve_local_vite_plus(cwd), None); + } + + // A missing root manifest must not prevent a workspace-local install. + std::fs::create_dir_all(workspace.join("node_modules/vite-plus/dist")).unwrap(); + std::fs::write( + workspace.join("node_modules/vite-plus/package.json"), + r#"{"version":"0.3.0"}"#, + ) + .unwrap(); + std::fs::write(workspace.join("node_modules/vite-plus/dist/bin.js"), "").unwrap(); + let cwd = AbsolutePath::new(&workspace).unwrap(); + assert_eq!(JsExecutor::local_vite_plus_install_host(cwd).as_deref(), Some(cwd)); + } + } + #[test] fn local_resolution_checks_workspace_membership() { for (workspace_file, content, package, is_member) in [ From 603037432530a3d54341e3a81560efcf2fdacb2b Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 12 Sep 2026 09:54:30 +0800 Subject: [PATCH 10/14] refactor: simplify local CLI workspace resolution --- .../src/commands/local_install.rs | 52 +++---- crates/vp_global_cli/src/js_executor.rs | 138 ++++++------------ crates/vp_setup/src/install.rs | 11 +- 3 files changed, 77 insertions(+), 124 deletions(-) diff --git a/crates/vp_global_cli/src/commands/local_install.rs b/crates/vp_global_cli/src/commands/local_install.rs index e643e19942..9f1dc3f879 100644 --- a/crates/vp_global_cli/src/commands/local_install.rs +++ b/crates/vp_global_cli/src/commands/local_install.rs @@ -27,13 +27,13 @@ pub(crate) fn local_vite_plus_boundary(cwd: &AbsolutePath) -> Option workspace.path.to_absolute_path_buf(), - Some(false) => package_root.to_absolute_path_buf(), + Some(true) => workspace.path.as_ref(), + Some(false) => package_root, None => return Some(package_root.to_absolute_path_buf()), }; if boundary == package_root { - return package.has_vite_plus().then_some(boundary); + return package.has_vite_plus().then(|| boundary.to_absolute_path_buf()); } let workspace_package_json = boundary.join("package.json"); @@ -44,7 +44,7 @@ pub(crate) fn local_vite_plus_boundary(cwd: &AbsolutePath) -> Option false, }; - (package.has_vite_plus() || workspace_declares).then_some(boundary) + (package.has_vite_plus() || workspace_declares).then(|| boundary.to_absolute_path_buf()) } #[derive(Deserialize)] @@ -91,30 +91,24 @@ fn workspace_contains_package(workspace: &WorkspaceRoot, package: &AbsolutePath) WorkspaceFile::NonWorkspacePackage(_) => return Some(false), }; - // Match vt_workspace's WorkspaceMemberGlobs normalization and ordered - // exclusions, without walking the filesystem or loading a package graph. - let patterns: Vec = patterns - .iter() - .map(|pattern| { - let exclusions = pattern.bytes().take_while(|byte| *byte == b'!').count(); - let path = &pattern[exclusions..]; - let without_dot = path.strip_prefix('.').unwrap_or(path); - let path = if without_dot.starts_with('/') { - without_dot.trim_start_matches('/') - } else { - path - }; - let mut normalized = Str::with_capacity(pattern.len() + "/package.json".len()); - if exclusions % 2 == 1 { - normalized.push('!'); - } - normalized.push_str(path); - if !path.is_empty() && !path.ends_with('/') { - normalized.push('/'); - } - normalized.push_str("package.json"); - normalized - }) - .collect(); + let patterns: Vec = patterns.into_iter().map(workspace_package_json_pattern).collect(); Some(PathGlobSet::new(&patterns).ok()?.is_match(relative.as_path())) } + +/// Match vt_workspace's WorkspaceMemberGlobs normalization, including negation. +fn workspace_package_json_pattern(pattern: Str) -> Str { + let exclusions = pattern.bytes().take_while(|byte| *byte == b'!').count(); + let path = &pattern[exclusions..]; + let path = path.strip_prefix("./").unwrap_or(path).trim_start_matches('/'); + + let mut normalized = Str::with_capacity(pattern.len() + "/package.json".len()); + if exclusions % 2 == 1 { + normalized.push('!'); + } + normalized.push_str(path); + if !path.is_empty() && !path.ends_with('/') { + normalized.push('/'); + } + normalized.push_str("package.json"); + normalized +} diff --git a/crates/vp_global_cli/src/js_executor.rs b/crates/vp_global_cli/src/js_executor.rs index 4e5445834c..377ad2b2aa 100644 --- a/crates/vp_global_cli/src/js_executor.rs +++ b/crates/vp_global_cli/src/js_executor.rs @@ -440,9 +440,7 @@ impl JsExecutor { ) -> Option { use oxc_resolver::{ResolveOptions, Resolver}; - // For projects that declare a vite-plus dependency, only trust an - // install within their workspace; the Node-semantics resolution below - // would otherwise walk past it (see `local_vite_plus_install_host`). + // Enforce the workspace boundary before using Node's unbounded resolver. Self::local_vite_plus_install_host(project_path)?; let resolver = Resolver::new(ResolveOptions { @@ -560,19 +558,23 @@ mod tests { dir } - /// An independent project that *declares* a vite-plus dependency (with - /// its own workspace marker) checked out inside another project's tree - /// must not resolve the outer project's vite-plus when its own install is - /// missing — the declaration makes "run `vp install`" the right answer, - /// not silently delegating to an unrelated copy. + fn write_local_cli(root: &AbsolutePath, version: &str) { + let package_dir = root.join("node_modules/vite-plus"); + std::fs::create_dir_all(package_dir.join("dist")).unwrap(); + std::fs::write( + package_dir.join("package.json"), + vt_str::format!(r#"{{"version":"{version}"}}"#).as_bytes(), + ) + .unwrap(); + std::fs::write(package_dir.join("dist/bin.js"), "").unwrap(); + } + + /// A declared dependency must not resolve outside an independent workspace. #[test] fn local_resolution_stays_within_the_workspace() { let temp = tempfile::tempdir().unwrap(); - let outer = temp.path(); - std::fs::create_dir_all(outer.join("node_modules/vite-plus/dist")).unwrap(); - std::fs::write(outer.join("node_modules/vite-plus/package.json"), r#"{"version":"0.2.1"}"#) - .unwrap(); - std::fs::write(outer.join("node_modules/vite-plus/dist/bin.js"), "").unwrap(); + let outer = AbsolutePath::new(temp.path()).unwrap(); + write_local_cli(outer, "0.2.1"); std::fs::write(outer.join("pnpm-workspace.yaml"), "packages: []\n").unwrap(); std::fs::write(outer.join("package.json"), r#"{"name":"outer"}"#).unwrap(); @@ -585,21 +587,16 @@ mod tests { .unwrap(); std::fs::write(inner.join("pnpm-workspace.yaml"), "packages: []\n").unwrap(); - let inner = AbsolutePath::new(inner.as_path()).unwrap(); - assert_eq!(JsExecutor::local_vite_plus_install_host(inner), None); - assert_eq!(JsExecutor::resolve_local_vite_plus_package_dir(inner), None); - assert_eq!(JsExecutor::resolve_local_vite_plus(inner), None); + assert_eq!(JsExecutor::local_vite_plus_install_host(&inner), None); + assert_eq!(JsExecutor::resolve_local_vite_plus_package_dir(&inner), None); + assert_eq!(JsExecutor::resolve_local_vite_plus(&inner), None); } - /// A workspace member still resolves the workspace root's install: the - /// boundary is the workspace root, not the member directory. The root - /// declares the dependency so the bounded walk is actually engaged — - /// without a declaration this case would pass trivially via the - /// unbounded default. + /// The root declares vite-plus so this exercises bounded workspace lookup. #[test] fn workspace_member_resolves_the_workspace_root_install() { let temp = tempfile::tempdir().unwrap(); - let ws = temp.path(); + let ws = AbsolutePath::new(temp.path()).unwrap(); std::fs::write(ws.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap(); std::fs::write( ws.join("package.json"), @@ -613,11 +610,10 @@ mod tests { std::fs::create_dir_all(&member).unwrap(); std::fs::write(member.join("package.json"), r#"{"name":"app"}"#).unwrap(); - let member = AbsolutePath::new(member.as_path()).unwrap(); - let host = JsExecutor::local_vite_plus_install_host(member) + let host = JsExecutor::local_vite_plus_install_host(&member) .expect("workspace root install must stay resolvable"); - assert_eq!(host.as_path(), ws); - let pkg_dir = JsExecutor::resolve_local_vite_plus_package_dir(member) + assert_eq!(&host, ws); + let pkg_dir = JsExecutor::resolve_local_vite_plus_package_dir(&member) .expect("workspace root install must stay resolvable"); assert!(pkg_dir.as_path().ends_with("node_modules/vite-plus")); } @@ -628,23 +624,16 @@ mod tests { [None, Some("{}"), Some(r#"{"devDependencies":{"vite-plus":"0.3.0"}}"#), Some("{")] { let temp = tempfile::tempdir().unwrap(); - let outer = temp.path(); + let outer = AbsolutePath::new(temp.path()).unwrap(); if let Some(ancestor) = ancestor { std::fs::write(outer.join("package.json"), ancestor).unwrap(); } - std::fs::create_dir_all(outer.join("node_modules/vite-plus/dist")).unwrap(); - std::fs::write( - outer.join("node_modules/vite-plus/package.json"), - r#"{"version":"0.2.1"}"#, - ) - .unwrap(); - std::fs::write(outer.join("node_modules/vite-plus/dist/bin.js"), "").unwrap(); + write_local_cli(outer, "0.2.1"); let workspace = outer.join("inner"); std::fs::create_dir_all(workspace.join("src")).unwrap(); std::fs::write(workspace.join("pnpm-workspace.yaml"), "packages: []\n").unwrap(); for cwd in [&workspace, &workspace.join("src")] { - let cwd = AbsolutePath::new(cwd).unwrap(); assert_eq!( JsExecutor::local_vite_plus_install_host(cwd), None, @@ -654,15 +643,11 @@ mod tests { } // A missing root manifest must not prevent a workspace-local install. - std::fs::create_dir_all(workspace.join("node_modules/vite-plus/dist")).unwrap(); - std::fs::write( - workspace.join("node_modules/vite-plus/package.json"), - r#"{"version":"0.3.0"}"#, - ) - .unwrap(); - std::fs::write(workspace.join("node_modules/vite-plus/dist/bin.js"), "").unwrap(); - let cwd = AbsolutePath::new(&workspace).unwrap(); - assert_eq!(JsExecutor::local_vite_plus_install_host(cwd).as_deref(), Some(cwd)); + write_local_cli(&workspace, "0.3.0"); + assert_eq!( + JsExecutor::local_vite_plus_install_host(&workspace).as_deref(), + Some(workspace.as_ref()) + ); } } @@ -699,16 +684,10 @@ mod tests { ), ] { let temp = tempfile::tempdir().unwrap(); - let root = temp.path(); + let root = AbsolutePath::new(temp.path()).unwrap(); std::fs::write(root.join("package.json"), r#"{"name":"outer"}"#).unwrap(); std::fs::write(root.join(workspace_file), content).unwrap(); - std::fs::create_dir_all(root.join("node_modules/vite-plus/dist")).unwrap(); - std::fs::write( - root.join("node_modules/vite-plus/package.json"), - r#"{"version":"0.3.0"}"#, - ) - .unwrap(); - std::fs::write(root.join("node_modules/vite-plus/dist/bin.js"), "").unwrap(); + write_local_cli(root, "0.3.0"); // Membership must not require reading unrelated members' manifests. std::fs::create_dir_all(root.join("packages/broken")).unwrap(); std::fs::write(root.join("packages/broken/package.json"), "{").unwrap(); @@ -721,9 +700,8 @@ mod tests { ) .unwrap(); let cwd = project.join("src"); - let cwd = AbsolutePath::new(&cwd).unwrap(); assert_eq!( - JsExecutor::resolve_local_vite_plus(cwd).is_some(), + JsExecutor::resolve_local_vite_plus(&cwd).is_some(), is_member, "{workspace_file}: {content}, package: {package}", ); @@ -742,80 +720,60 @@ mod tests { ("{}", "\u{feff}{\"devDependencies\":{\"vite-plus\":\"0.3.0\"}}", None), ] { let temp = tempfile::tempdir().unwrap(); - let root = temp.path(); + let root = AbsolutePath::new(temp.path()).unwrap(); std::fs::write(root.join("package.json"), ancestor).unwrap(); if let Some(workspace) = workspace { std::fs::write(root.join("pnpm-workspace.yaml"), workspace).unwrap(); } - std::fs::create_dir_all(root.join("node_modules/vite-plus/dist")).unwrap(); - std::fs::write( - root.join("node_modules/vite-plus/package.json"), - r#"{"version":"0.2.1"}"#, - ) - .unwrap(); - std::fs::write(root.join("node_modules/vite-plus/dist/bin.js"), "").unwrap(); + write_local_cli(root, "0.2.1"); let inner = root.join("inner"); std::fs::create_dir_all(&inner).unwrap(); std::fs::write(inner.join("package.json"), project).unwrap(); - let inner = AbsolutePath::new(&inner).unwrap(); assert_eq!( - JsExecutor::local_vite_plus_install_host(inner), + JsExecutor::local_vite_plus_install_host(&inner), None, "{ancestor}: {project}" ); - assert_eq!(JsExecutor::resolve_local_vite_plus(inner), None); + assert_eq!(JsExecutor::resolve_local_vite_plus(&inner), None); } } - /// A project that does *not* declare a vite-plus dependency keeps Node's - /// unbounded upward resolution even across its own workspace marker — - /// this is the layout the snapshot harness depends on (staged workspaces - /// with no `node_modules` of their own, resolving a run-root install). + /// Undeclared projects retain Node's upward lookup, as used by snapshot fixtures. #[test] fn undeclared_project_keeps_the_unbounded_walk() { let temp = tempfile::tempdir().unwrap(); - let outer = temp.path(); - std::fs::create_dir_all(outer.join("node_modules/vite-plus/dist")).unwrap(); - std::fs::write(outer.join("node_modules/vite-plus/package.json"), r#"{"version":"0.2.1"}"#) - .unwrap(); - std::fs::write(outer.join("node_modules/vite-plus/dist/bin.js"), "").unwrap(); + let outer = AbsolutePath::new(temp.path()).unwrap(); + write_local_cli(outer, "0.2.1"); let inner = outer.join("cases/one/workspace"); std::fs::create_dir_all(&inner).unwrap(); std::fs::write(inner.join("package.json"), r#"{"name":"inner"}"#).unwrap(); std::fs::write(inner.join("pnpm-workspace.yaml"), "packages: []\n").unwrap(); - let inner = AbsolutePath::new(inner.as_path()).unwrap(); - let host = JsExecutor::local_vite_plus_install_host(inner) + let host = JsExecutor::local_vite_plus_install_host(&inner) .expect("undeclared projects keep the unbounded walk"); - assert_eq!(host.as_path(), outer); - let pkg_dir = JsExecutor::resolve_local_vite_plus_package_dir(inner) + assert_eq!(&host, outer); + let pkg_dir = JsExecutor::resolve_local_vite_plus_package_dir(&inner) .expect("undeclared projects keep the unbounded walk"); assert!(pkg_dir.as_path().ends_with("node_modules/vite-plus")); } - /// Without any project marker around (`find_workspace_root` errors) there - /// is no boundary to protect; the walk stays unbounded as before. - /// - /// Unix-only: the premise is that no ancestor of the tempdir carries a - /// package.json, which holds for `/tmp` / `/var/folders` but not for - /// Windows, where `%TEMP%` lives under the user profile and a stray - /// `package.json` there would create a boundary and fail the test. + /// Unix-only: Windows tempdirs can have a package.json in an ancestor profile + /// directory, which would invalidate this test's markerless layout. #[cfg(unix)] #[test] fn unbounded_walk_without_project_markers() { let temp = tempfile::tempdir().unwrap(); - let root = temp.path(); + let root = AbsolutePath::new(temp.path()).unwrap(); std::fs::create_dir_all(root.join("node_modules/vite-plus")).unwrap(); std::fs::write(root.join("node_modules/vite-plus/package.json"), r#"{"version":"1.0.0"}"#) .unwrap(); let nested = root.join("a/b"); std::fs::create_dir_all(&nested).unwrap(); - let nested = AbsolutePath::new(nested.as_path()).unwrap(); - let host = JsExecutor::local_vite_plus_install_host(nested) + let host = JsExecutor::local_vite_plus_install_host(&nested) .expect("markerless directories keep the unbounded walk"); - assert_eq!(host.as_path(), root); + assert_eq!(&host, root); } #[test] diff --git a/crates/vp_setup/src/install.rs b/crates/vp_setup/src/install.rs index 22adea7b13..d6975015d3 100644 --- a/crates/vp_setup/src/install.rs +++ b/crates/vp_setup/src/install.rs @@ -835,16 +835,17 @@ mod tests { tokio::fs::create_dir_all(&node_bin).await.unwrap(); tokio::fs::create_dir_all(&pnpm_bin).await.unwrap(); - // Execute an existing shell, with the generated script as input. - // Parallel process creation can briefly inherit a newly written - // executable's open descriptor and cause ETXTBSY on Linux. - // Keep the sh basename: BusyBox selects its applet from argv[0]. + // Use an existing executable to avoid ETXTBSY from inherited writable + // descriptors. Keep the sh basename so BusyBox selects the shell applet. let runtime_binary = node_bin.join("sh"); std::os::unix::fs::symlink("/bin/sh", &runtime_binary).unwrap(); let pnpm_entry = pnpm_bin.join("pnpm.cjs"); tokio::fs::write( &pnpm_entry, - "printf '%s\\n' \"$0\" \"$@\" > invocation.txt\nprintf '%s' \"$PATH\" > path.txt\nprintf '%s' \"$npm_config_registry\" > registry.txt\n", + r#"printf '%s\n' "$0" "$@" > invocation.txt +printf '%s' "$PATH" > path.txt +printf '%s' "$npm_config_registry" > registry.txt +"#, ) .await .unwrap(); From 9b738b28ca815d35ffdb05811fa1acf2cf243738 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 12 Sep 2026 16:21:24 +0800 Subject: [PATCH 11/14] refactor(global-cli): enforce local CLI boundaries with oxc_resolver --- .../snapshots.toml | 26 +++ .../snapshots/package_json_not_exported.md | 45 ++++++ .../snapshots/rootless_malformed_ancestor.md | 52 ++++++ crates/vp_global_cli/src/commands/version.rs | 14 +- crates/vp_global_cli/src/js_executor.rs | 151 ++++++++++++------ 5 files changed, 226 insertions(+), 62 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/package_json_not_exported.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/rootless_malformed_ancestor.md diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots.toml index 2c262648f8..a84b5b14c0 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots.toml @@ -64,3 +64,29 @@ steps = [ { argv = ["vp", "--version"], cwd = "outer/external/inner" }, { argv = ["vp", "lint", "--version"], cwd = "outer/external/inner" }, ] + +[[case]] +name = "package_json_not_exported" +vp = "global" +comment = "Version reporting and delegation both require the local package.json export." +steps = [ + { argv = ["vpt", "mkdir", "-p", "outer/external/inner/node_modules"], snapshot = false }, + { argv = ["vpt", "cp", "-r", "outer-cli", "outer/external/inner/node_modules/vite-plus"], snapshot = false }, + { argv = ["vpt", "write-file", "outer/external/inner/node_modules/vite-plus/package.json", '{"name":"vite-plus","version":"9.8.7","exports":{".":"./dist/bin.js"}}'], snapshot = false }, + { argv = ["vp", "--version"], cwd = "outer/external/inner" }, + { argv = ["vp", "lint", "--version"], cwd = "outer/external/inner" }, +] + +[[case]] +name = "rootless_malformed_ancestor" +vp = "global" +comment = "Oxc rejects a malformed ancestor manifest even when a rootless workspace has a local installation." +steps = [ + { argv = ["vpt", "mkdir", "-p", "outer/external/inner/node_modules"], snapshot = false }, + { argv = ["vpt", "cp", "-r", "outer-cli", "outer/external/inner/node_modules/vite-plus"], snapshot = false }, + { argv = ["vpt", "rm", "outer/external/inner/package.json"], snapshot = false }, + { argv = ["vpt", "write-file", "outer/external/inner/pnpm-workspace.yaml", "packages: []\n"], snapshot = false }, + { argv = ["vpt", "write-file", "outer/package.json", "{"], snapshot = false }, + { argv = ["vp", "--version"], cwd = "outer/external/inner" }, + { argv = ["vp", "lint", "--version"], cwd = "outer/external/inner" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/package_json_not_exported.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/package_json_not_exported.md new file mode 100644 index 0000000000..adf7fd0479 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/package_json_not_exported.md @@ -0,0 +1,45 @@ +# package_json_not_exported + +Version reporting and delegation both require the local package.json export. + +## `vpt mkdir -p outer/external/inner/node_modules` + + +## `vpt cp -r outer-cli outer/external/inner/node_modules/vite-plus` + + +## `vpt write-file outer/external/inner/node_modules/vite-plus/package.json '{"name":"vite-plus","version":"9.8.7","exports":{".":"./dist/bin.js"}}'` + + +## `cd outer/external/inner && vp --version` + +``` +VITE+ - The Unified Toolchain for the Web + +vp + +Local vite-plus: + vite-plus Not found + +Tools: + vite Not found + rolldown Not found + vitest Not found + oxfmt Not found + oxlint Not found + oxlint-tsgolint Not found + tsdown Not found + +Environment: + Package manager Not found + Node.js +``` + +## `cd outer/external/inner && vp lint --version` + +``` +VITE+ - The Unified Toolchain for the Web + +warn: No project-local vite-plus installation was found. Run `vp install` in `/outer/external/inner` to install dependencies. +Version: 1.81.0 +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/rootless_malformed_ancestor.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/rootless_malformed_ancestor.md new file mode 100644 index 0000000000..3c353196a2 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/rootless_malformed_ancestor.md @@ -0,0 +1,52 @@ +# rootless_malformed_ancestor + +Oxc rejects a malformed ancestor manifest even when a rootless workspace has a local installation. + +## `vpt mkdir -p outer/external/inner/node_modules` + + +## `vpt cp -r outer-cli outer/external/inner/node_modules/vite-plus` + + +## `vpt rm outer/external/inner/package.json` + + +## `vpt write-file outer/external/inner/pnpm-workspace.yaml 'packages: [] +'` + + +## `vpt write-file outer/package.json {` + + +## `cd outer/external/inner && vp --version` + +``` +VITE+ - The Unified Toolchain for the Web + +vp + +Local vite-plus: + vite-plus Not found + +Tools: + vite Not found + rolldown Not found + vitest Not found + oxfmt Not found + oxlint Not found + oxlint-tsgolint Not found + tsdown Not found + +Environment: + Package manager pnpm latest + Node.js +``` + +## `cd outer/external/inner && vp lint --version` + +``` +VITE+ - The Unified Toolchain for the Web + +warn: This project does not use vite-plus. Learn how to migrate: https://viteplus.dev/guide/migrate +Version: 1.81.0 +``` diff --git a/crates/vp_global_cli/src/commands/version.rs b/crates/vp_global_cli/src/commands/version.rs index 9864a6fa29..1f9b83fc55 100644 --- a/crates/vp_global_cli/src/commands/version.rs +++ b/crates/vp_global_cli/src/commands/version.rs @@ -65,15 +65,11 @@ fn read_package_json(package_json_path: &Path) -> Option { } fn find_local_vite_plus(cwd: &AbsolutePath) -> Option { - // The workspace-bounded walk keeps this display consistent with what - // delegation would actually execute (see `local_vite_plus_install_host`). - let host = JsExecutor::local_vite_plus_install_host(cwd)?; - let package_dir = host.as_path().join("node_modules").join("vite-plus"); - let pkg = read_package_json(&package_dir.join("package.json"))?; - // Follow symlinks (pnpm links node_modules/vite-plus -> node_modules/.pnpm/.../vite-plus) - // so parent traversal can discover colocated dependency links. - let package_dir = fs::canonicalize(&package_dir).unwrap_or(package_dir); - Some(LocalVitePlus { version: pkg.version, package_dir }) + let resolved = JsExecutor::resolve_local_vite_plus_package(cwd)?; + Some(LocalVitePlus { + version: resolved.package_json()?.version()?.to_owned(), + package_dir: resolved.path().parent()?.to_path_buf(), + }) } fn read_toolchain_manifest(local: &LocalVitePlus) -> Option { diff --git a/crates/vp_global_cli/src/js_executor.rs b/crates/vp_global_cli/src/js_executor.rs index 377ad2b2aa..1438088925 100644 --- a/crates/vp_global_cli/src/js_executor.rs +++ b/crates/vp_global_cli/src/js_executor.rs @@ -407,49 +407,31 @@ impl JsExecutor { Ok(output) } - /// Find the directory whose `node_modules/vite-plus` an upward walk from - /// `project_path` is allowed to use. - /// - /// Declared Vite+ projects stay within their package or workspace boundary; - /// an ancestor workspace must include the package to supply its install. - /// Unreadable manifests preserve the nearest known package boundary. - /// Undeclared projects and markerless directories keep Node's upward walk. - pub(crate) fn local_vite_plus_install_host( + /// Resolve the local package while restricting lookup to the project boundary. + pub(crate) fn resolve_local_vite_plus_package( project_path: &AbsolutePath, - ) -> Option { - let boundary = commands::local_vite_plus_boundary(project_path); + ) -> Option { + use oxc_resolver::{ResolveOptions, Resolver, Restriction}; - let mut current = project_path; - loop { - if current.join("node_modules/vite-plus/package.json").as_path().exists() { - return Some(current.to_absolute_path_buf()); - } - if boundary.as_deref().is_some_and(|boundary| current == boundary) { - return None; - } - match current.parent() { - Some(parent) if parent != current => current = parent, - _ => return None, - } + let mut options = ResolveOptions { + condition_names: vec!["import".into(), "node".into()], + ..ResolveOptions::default() + }; + if let Some(boundary) = commands::local_vite_plus_boundary(project_path) { + // Restrictions inspect the lookup path before symlinks are resolved. + // A project-local link may point to a package stored outside the project. + options.restrictions.push(Restriction::Fn(std::sync::Arc::new(move |path| { + path.starts_with(boundary.as_path()) + }))); } + Resolver::new(options).resolve(project_path, "vite-plus/package.json").ok() } /// Resolve the local vite-plus package root from the project directory. pub(crate) fn resolve_local_vite_plus_package_dir( project_path: &AbsolutePath, ) -> Option { - use oxc_resolver::{ResolveOptions, Resolver}; - - // Enforce the workspace boundary before using Node's unbounded resolver. - Self::local_vite_plus_install_host(project_path)?; - - let resolver = Resolver::new(ResolveOptions { - condition_names: vec!["import".into(), "node".into()], - ..ResolveOptions::default() - }); - - // Resolve vite-plus/package.json from the project directory to find the package root - let resolved = resolver.resolve(project_path, "vite-plus/package.json").ok()?; + let resolved = Self::resolve_local_vite_plus_package(project_path)?; let pkg_dir = resolved.path().parent()?; AbsolutePathBuf::new(pkg_dir.to_path_buf()) } @@ -563,7 +545,10 @@ mod tests { std::fs::create_dir_all(package_dir.join("dist")).unwrap(); std::fs::write( package_dir.join("package.json"), - vt_str::format!(r#"{{"version":"{version}"}}"#).as_bytes(), + vt_str::format!( + r#"{{"name":"vite-plus","version":"{version}","exports":{{"./package.json":"./package.json"}}}}"# + ) + .as_bytes(), ) .unwrap(); std::fs::write(package_dir.join("dist/bin.js"), "").unwrap(); @@ -587,7 +572,6 @@ mod tests { .unwrap(); std::fs::write(inner.join("pnpm-workspace.yaml"), "packages: []\n").unwrap(); - assert_eq!(JsExecutor::local_vite_plus_install_host(&inner), None); assert_eq!(JsExecutor::resolve_local_vite_plus_package_dir(&inner), None); assert_eq!(JsExecutor::resolve_local_vite_plus(&inner), None); } @@ -610,12 +594,12 @@ mod tests { std::fs::create_dir_all(&member).unwrap(); std::fs::write(member.join("package.json"), r#"{"name":"app"}"#).unwrap(); - let host = JsExecutor::local_vite_plus_install_host(&member) - .expect("workspace root install must stay resolvable"); - assert_eq!(&host, ws); let pkg_dir = JsExecutor::resolve_local_vite_plus_package_dir(&member) .expect("workspace root install must stay resolvable"); - assert!(pkg_dir.as_path().ends_with("node_modules/vite-plus")); + assert_eq!( + pkg_dir.as_path(), + std::fs::canonicalize(ws.join("node_modules/vite-plus")).unwrap() + ); } #[test] @@ -635,19 +619,25 @@ mod tests { std::fs::write(workspace.join("pnpm-workspace.yaml"), "packages: []\n").unwrap(); for cwd in [&workspace, &workspace.join("src")] { assert_eq!( - JsExecutor::local_vite_plus_install_host(cwd), + JsExecutor::resolve_local_vite_plus_package_dir(cwd), None, "ancestor: {ancestor:?}" ); assert_eq!(JsExecutor::resolve_local_vite_plus(cwd), None); } - // A missing root manifest must not prevent a workspace-local install. write_local_cli(&workspace, "0.3.0"); - assert_eq!( - JsExecutor::local_vite_plus_install_host(&workspace).as_deref(), - Some(workspace.as_ref()) - ); + if ancestor == Some("{") { + // Oxc still reads package scope above a rootless workspace. + assert!(JsExecutor::resolve_local_vite_plus_package(&workspace).is_none()); + } else { + let package = JsExecutor::resolve_local_vite_plus_package_dir(&workspace) + .expect("a missing root manifest must not prevent a local installation"); + assert_eq!( + package.as_path(), + std::fs::canonicalize(workspace.join("node_modules/vite-plus")).unwrap() + ); + } } } @@ -730,7 +720,7 @@ mod tests { std::fs::create_dir_all(&inner).unwrap(); std::fs::write(inner.join("package.json"), project).unwrap(); assert_eq!( - JsExecutor::local_vite_plus_install_host(&inner), + JsExecutor::resolve_local_vite_plus_package_dir(&inner), None, "{ancestor}: {project}" ); @@ -750,12 +740,12 @@ mod tests { std::fs::write(inner.join("package.json"), r#"{"name":"inner"}"#).unwrap(); std::fs::write(inner.join("pnpm-workspace.yaml"), "packages: []\n").unwrap(); - let host = JsExecutor::local_vite_plus_install_host(&inner) - .expect("undeclared projects keep the unbounded walk"); - assert_eq!(&host, outer); let pkg_dir = JsExecutor::resolve_local_vite_plus_package_dir(&inner) .expect("undeclared projects keep the unbounded walk"); - assert!(pkg_dir.as_path().ends_with("node_modules/vite-plus")); + assert_eq!( + pkg_dir.as_path(), + std::fs::canonicalize(outer.join("node_modules/vite-plus")).unwrap() + ); } /// Unix-only: Windows tempdirs can have a package.json in an ancestor profile @@ -771,9 +761,64 @@ mod tests { let nested = root.join("a/b"); std::fs::create_dir_all(&nested).unwrap(); - let host = JsExecutor::local_vite_plus_install_host(&nested) + let package = JsExecutor::resolve_local_vite_plus_package_dir(&nested) .expect("markerless directories keep the unbounded walk"); - assert_eq!(&host, root); + assert_eq!( + package.as_path(), + std::fs::canonicalize(root.join("node_modules/vite-plus")).unwrap() + ); + } + + #[cfg(unix)] + #[test] + fn local_resolution_checks_symlink_location_before_its_target() { + for local_link in [false, true] { + for local_target in [false, true] { + let temp = tempfile::tempdir().unwrap(); + let outer = AbsolutePath::new(temp.path()).unwrap(); + let project = outer.join("inner"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write(outer.join("package.json"), "{}").unwrap(); + std::fs::write( + project.join("package.json"), + r#"{"devDependencies":{"vite-plus":"0.3.0"}}"#, + ) + .unwrap(); + + let store = if local_target { project.join("store") } else { outer.join("store") }; + write_local_cli(&store, "0.3.0"); + let target = store.join("node_modules/vite-plus"); + let host = if local_link { project.as_ref() } else { outer }; + std::fs::create_dir_all(host.join("node_modules")).unwrap(); + std::os::unix::fs::symlink(&target, host.join("node_modules/vite-plus")).unwrap(); + + let resolved = JsExecutor::resolve_local_vite_plus_package_dir(&project); + let expected = local_link.then(|| { + AbsolutePathBuf::new(std::fs::canonicalize(&target).unwrap()).unwrap() + }); + assert_eq!( + resolved, expected, + "local link: {local_link}, local target: {local_target}" + ); + } + } + } + + #[test] + fn local_resolution_supports_package_self_reference() { + let temp = tempfile::tempdir().unwrap(); + let project = AbsolutePath::new(temp.path()).unwrap(); + std::fs::write( + project.join("package.json"), + r#"{"name":"vite-plus","version":"0.3.0","exports":{"./package.json":"./package.json"}}"#, + ) + .unwrap(); + std::fs::create_dir_all(project.join("dist")).unwrap(); + std::fs::write(project.join("dist/bin.js"), "").unwrap(); + + let resolved = JsExecutor::resolve_local_vite_plus(project) + .expect("a package can resolve its own exported CLI without node_modules"); + assert_eq!(resolved.as_path(), std::fs::canonicalize(project.join("dist/bin.js")).unwrap()); } #[test] From bf8ad420ecb8655cce43d9bc7bbd00490588f5f0 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 12 Sep 2026 21:53:32 +0800 Subject: [PATCH 12/14] test: normalize local CLI boundary snapshots and paths --- .../snapshots/bom_manifest.md | 2 +- .../snapshots/excluded_project.md | 2 +- .../snapshots/package_json_not_exported.md | 2 +- .../snapshots/rootless_malformed_ancestor.md | 2 +- .../workspace_without_root_manifest.md | 2 +- .../tests/cli_snapshots/main.rs | 15 ++++++------ .../tests/cli_snapshots/redact.rs | 24 +++++++------------ crates/vp_cli_snapshots/tests/redact_unit.rs | 12 ++++++++++ crates/vp_global_cli/src/js_executor.rs | 13 ++++++---- 9 files changed, 41 insertions(+), 33 deletions(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/bom_manifest.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/bom_manifest.md index bfde511edd..65573a1606 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/bom_manifest.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/bom_manifest.md @@ -45,5 +45,5 @@ Environment: VITE+ - The Unified Toolchain for the Web warn: No project-local vite-plus installation was found. Run `vp install` in `/outer/external/inner` to install dependencies. -Version: 1.81.0 +Version: ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/excluded_project.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/excluded_project.md index f5a133c9d2..d6964ece00 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/excluded_project.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/excluded_project.md @@ -38,5 +38,5 @@ Environment: VITE+ - The Unified Toolchain for the Web warn: No project-local vite-plus installation was found. Run `vp install` in `/outer/external/inner` to install dependencies. -Version: 1.81.0 +Version: ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/package_json_not_exported.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/package_json_not_exported.md index adf7fd0479..61dbf57d44 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/package_json_not_exported.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/package_json_not_exported.md @@ -41,5 +41,5 @@ Environment: VITE+ - The Unified Toolchain for the Web warn: No project-local vite-plus installation was found. Run `vp install` in `/outer/external/inner` to install dependencies. -Version: 1.81.0 +Version: ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/rootless_malformed_ancestor.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/rootless_malformed_ancestor.md index 3c353196a2..ef53df17aa 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/rootless_malformed_ancestor.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/rootless_malformed_ancestor.md @@ -48,5 +48,5 @@ Environment: VITE+ - The Unified Toolchain for the Web warn: This project does not use vite-plus. Learn how to migrate: https://viteplus.dev/guide/migrate -Version: 1.81.0 +Version: ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/workspace_without_root_manifest.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/workspace_without_root_manifest.md index dad3a2035d..9d2c36dff7 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/workspace_without_root_manifest.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/workspace_without_root_manifest.md @@ -49,7 +49,7 @@ Environment: VITE+ - The Unified Toolchain for the Web warn: No project-local vite-plus installation was found. Run `vp install` in `/outer/external/inner` to install dependencies. -Version: 1.81.0 +Version: ``` ## `cd outer/external/inner/apps/app && vp --version` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs b/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs index 844da6c126..b4263f09b7 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs @@ -1478,13 +1478,14 @@ fn run_case( let succeeded = matches!(termination_state, TerminationState::Exited(0)); if step.snapshot || !succeeded { let mut redacted = redact_output(raw_output, &redactions, !step.formatted_snapshot); - // A version-probe step's output is a bare semver that varies by - // environment (the managed Node's bundled npm or a package - // manager pin); mask it. Scoped by argv so - // fixture-controlled bare versions elsewhere (a printed - // `.node-version` file) stay assertable. - let version_probe = matches!(argv.first().map(String::as_str), Some("npm" | "npx")) - && argv[1..] == ["--version"]; + // Version probes report tool versions that vary by environment. + // Scope redaction by argv so fixture-controlled versions in other + // steps (such as a printed `.node-version` file) stay assertable. + let version_probe = match argv.first().map(String::as_str) { + Some("npm" | "npx") => argv[1..] == ["--version"], + Some("vp") => argv[1..] == ["lint", "--version"], + _ => false, + }; if version_probe { redacted = redact::redact_version_probe_output(redacted); } diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs b/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs index 1301e2dc33..8ddcc34a4e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs @@ -285,16 +285,12 @@ static NODE_TRACE_WARNING_RE: LazyLock = LazyLock::new(|| { ) .unwrap() }); -// A version-probe step (`npm --version` / `npx --version`) prints a lone bare -// semver in its fenced code block (no `v` prefix, so the generic VERSION_RE -// misses it). The value tracks the managed Node's bundled npm or a -// packageManager pin, both of which vary by environment, so -// mask it. Applied via `redact_version_probe_output` ONLY to steps the runner -// identifies as version probes: other steps' bare versions in a block (a -// printed `.node-version` file) are fixture-controlled assertions that must -// stay verbatim. e.g. "```\n10.9.4\n```" -> "```\n\n```". -static BARE_VERSION_BLOCK_RE: LazyLock = LazyLock::new(|| { - regex::Regex::new(r"(```\n)\d+\.\d+\.\d+(?:-[0-9A-Za-z.+-]+)?(\n```)").unwrap() +// npm and npx print a bare version; `vp lint --version` prints `Version: X.Y.Z`. +// Apply this only to version probes so fixture-controlled versions in other +// steps stay verbatim. +static VERSION_PROBE_RE: LazyLock = LazyLock::new(|| { + regex::Regex::new(r"(?m)^(Version: )?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$") + .unwrap() }); // npm prints an "update available" notice on a throttled, per-environment // schedule, so whether it appears at all is non-deterministic. Strip the notice @@ -621,14 +617,10 @@ pub fn redact_output( output } -/// Masks the bare semver a version-probe step (`npm --version` / -/// `npx --version`) prints as the sole content of its fenced code block (see -/// BARE_VERSION_BLOCK_RE). The runner applies this on top of `redact_output` -/// only for steps it identifies as version probes, so fixture-controlled bare -/// versions elsewhere (a printed `.node-version` file) stay assertable. +/// Masks bare or labeled tool versions only in version-probe steps. #[expect(clippy::disallowed_types, reason = "String required by regex replace_all API")] pub fn redact_version_probe_output(output: String) -> String { - BARE_VERSION_BLOCK_RE.replace_all(&output, "${1}${2}").into_owned() + VERSION_PROBE_RE.replace_all(&output, "${1}").into_owned() } #[expect( diff --git a/crates/vp_cli_snapshots/tests/redact_unit.rs b/crates/vp_cli_snapshots/tests/redact_unit.rs index 5c264e6756..dbd00245de 100644 --- a/crates/vp_cli_snapshots/tests/redact_unit.rs +++ b/crates/vp_cli_snapshots/tests/redact_unit.rs @@ -23,6 +23,18 @@ fn masks_bare_version_block_only_for_version_probe_steps() { assert_eq!(redact_output(node_version_file.clone(), &[], true), node_version_file); } +#[test] +fn masks_lint_version_only_for_version_probe_steps() { + for version in ["1.81.0", "1.82.0", "1.83.0-beta.1+build.2"] { + let output = format!("```\nwarn: No project-local installation\nVersion: {version}\n```\n"); + assert_eq!(redact_output(output.clone(), &[], true), output); + assert_eq!( + redact_version_probe_output(output), + "```\nwarn: No project-local installation\nVersion: \n```\n" + ); + } +} + #[test] fn trims_trailing_row_padding_on_every_platform() { // ConPTY repaints rows padded to the grid width with explicit spaces. diff --git a/crates/vp_global_cli/src/js_executor.rs b/crates/vp_global_cli/src/js_executor.rs index 1438088925..6372856dba 100644 --- a/crates/vp_global_cli/src/js_executor.rs +++ b/crates/vp_global_cli/src/js_executor.rs @@ -597,7 +597,7 @@ mod tests { let pkg_dir = JsExecutor::resolve_local_vite_plus_package_dir(&member) .expect("workspace root install must stay resolvable"); assert_eq!( - pkg_dir.as_path(), + std::fs::canonicalize(&pkg_dir).unwrap(), std::fs::canonicalize(ws.join("node_modules/vite-plus")).unwrap() ); } @@ -634,7 +634,7 @@ mod tests { let package = JsExecutor::resolve_local_vite_plus_package_dir(&workspace) .expect("a missing root manifest must not prevent a local installation"); assert_eq!( - package.as_path(), + std::fs::canonicalize(&package).unwrap(), std::fs::canonicalize(workspace.join("node_modules/vite-plus")).unwrap() ); } @@ -743,7 +743,7 @@ mod tests { let pkg_dir = JsExecutor::resolve_local_vite_plus_package_dir(&inner) .expect("undeclared projects keep the unbounded walk"); assert_eq!( - pkg_dir.as_path(), + std::fs::canonicalize(&pkg_dir).unwrap(), std::fs::canonicalize(outer.join("node_modules/vite-plus")).unwrap() ); } @@ -764,7 +764,7 @@ mod tests { let package = JsExecutor::resolve_local_vite_plus_package_dir(&nested) .expect("markerless directories keep the unbounded walk"); assert_eq!( - package.as_path(), + std::fs::canonicalize(&package).unwrap(), std::fs::canonicalize(root.join("node_modules/vite-plus")).unwrap() ); } @@ -818,7 +818,10 @@ mod tests { let resolved = JsExecutor::resolve_local_vite_plus(project) .expect("a package can resolve its own exported CLI without node_modules"); - assert_eq!(resolved.as_path(), std::fs::canonicalize(project.join("dist/bin.js")).unwrap()); + assert_eq!( + std::fs::canonicalize(&resolved).unwrap(), + std::fs::canonicalize(project.join("dist/bin.js")).unwrap() + ); } #[test] From 3ea6433b0f241111e266cf72ffc1886e9bb1d428 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 13 Sep 2026 01:11:45 +0800 Subject: [PATCH 13/14] refactor(cli): share local vite-plus resolution --- Cargo.lock | 19 ++- Cargo.toml | 1 + .../snapshots/cli_helper_message_local.md | 11 +- .../snapshots.toml | 32 +++++ .../version_manifest_resolution.global.md | 121 ++++++++++++++++++ .../version_manifest_resolution.local.md | 87 +++++++++++++ .../version_project_boundary.global.md | 87 +++++++++++++ .../version_project_boundary.local.md | 66 ++++++++++ crates/vp_global_cli/Cargo.toml | 4 +- crates/vp_global_cli/src/commands/mod.rs | 52 +------- crates/vp_global_cli/src/commands/version.rs | 4 +- crates/vp_global_cli/src/js_executor.rs | 49 +++---- crates/vp_local_cli/Cargo.toml | 21 +++ .../src/lib.rs} | 74 ++++++++++- packages/cli/binding/Cargo.toml | 1 + packages/cli/binding/index.cjs | 1 + packages/cli/binding/index.d.cts | 9 ++ packages/cli/binding/src/lib.rs | 2 + packages/cli/binding/src/local_cli.rs | 24 ++++ packages/cli/src/version.ts | 42 +----- 20 files changed, 573 insertions(+), 134 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/version_manifest_resolution.global.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/version_manifest_resolution.local.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/version_project_boundary.global.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/version_project_boundary.local.md create mode 100644 crates/vp_local_cli/Cargo.toml rename crates/{vp_global_cli/src/commands/local_install.rs => vp_local_cli/src/lib.rs} (61%) create mode 100644 packages/cli/binding/src/local_cli.rs diff --git a/Cargo.lock b/Cargo.lock index 1846b921fc..457bc620d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8651,6 +8651,7 @@ dependencies = [ "vp_cli_help", "vp_command", "vp_error", + "vp_local_cli", "vp_migration", "vp_pm_cli", "vp_shared", @@ -8752,12 +8753,10 @@ dependencies = [ "indoc", "node-semver", "owo-colors", - "oxc_resolver", "rustc-hash", "same-file", "serde", "serde_json", - "serde_yaml", "serial_test", "tar", "temp-env", @@ -8770,11 +8769,11 @@ dependencies = [ "vp_command", "vp_error", "vp_js_runtime", + "vp_local_cli", "vp_pm_cli", "vp_setup", "vp_shared", "vp_toolchain", - "vt_glob", "vt_path", "vt_str", "vt_workspace", @@ -8824,6 +8823,20 @@ dependencies = [ "zip", ] +[[package]] +name = "vp_local_cli" +version = "0.0.0" +dependencies = [ + "oxc_resolver", + "serde", + "serde_json", + "serde_yaml", + "vt_glob", + "vt_path", + "vt_str", + "vt_workspace", +] + [[package]] name = "vp_migration" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index 7d54d359fc..a753588fbd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -298,6 +298,7 @@ vp_command = { path = "crates/vp_command" } vp_cli_help = { path = "crates/vp_cli_help" } vp_error = { path = "crates/vp_error" } vp_js_runtime = { path = "crates/vp_js_runtime" } +vp_local_cli = { path = "crates/vp_local_cli" } vp_migration = { path = "crates/vp_migration" } vp_pm_cli = { path = "crates/vp_pm_cli" } vp_pm_cli_macros = { path = "crates/vp_pm_cli_macros" } diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message_local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message_local.md index aa3ea41603..8b3ea736c2 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message_local.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message_local.md @@ -46,5 +46,14 @@ VITE+ - The Unified Toolchain for the Web vp Local vite-plus: - vite-plus Not found + vite-plus + +Tools: + vite + rolldown + vitest + oxfmt + oxlint + oxlint-tsgolint + tsdown ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots.toml index a84b5b14c0..3a573be084 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots.toml @@ -1,3 +1,35 @@ +[[case]] +name = "version_project_boundary" +vp = ["local", "global"] +comment = "Both CLI entry points reject an excluded project's ancestor install, allow workspace members to share it, and find an optional local dependency." +steps = [ + { argv = ["vpt", "mkdir", "-p", "outer/node_modules", "outer/external/inner/node_modules"], snapshot = false }, + { argv = ["vpt", "cp", "-r", "outer-cli", "outer/node_modules/vite-plus"], snapshot = false }, + { argv = ["vp", "--version"], cwd = "outer/external/inner" }, + { argv = ["vp", "--version"], cwd = "outer/packages/member" }, + { argv = ["vpt", "cp", "-r", "outer-cli", "outer/external/inner/node_modules/vite-plus"], snapshot = false }, + { argv = ["vpt", "write-file", "outer/external/inner/package.json", '{"name":"inner","optionalDependencies":{"vite-plus":"9.8.7"}}'], snapshot = false }, + { argv = ["vp", "--version"], cwd = "outer/external/inner" }, +] + +[[case]] +name = "version_manifest_resolution" +vp = ["local", "global"] +comment = "Both CLI entry points handle package exports, BOMs, and malformed ancestors through the same resolver." +steps = [ + { argv = ["vpt", "mkdir", "-p", "outer/external/inner/node_modules"], snapshot = false }, + { argv = ["vpt", "cp", "-r", "outer-cli", "outer/external/inner/node_modules/vite-plus"], snapshot = false }, + { argv = ["vpt", "write-file", "outer/external/inner/node_modules/vite-plus/package.json", '{"name":"vite-plus","version":"9.8.7","exports":{".":"./dist/bin.js"}}'], snapshot = false }, + { argv = ["vp", "--version"], cwd = "outer/external/inner" }, + { argv = ["vpt", "write-file", "outer/external/inner/node_modules/vite-plus/package.json", "\uFEFF{\"name\":\"vite-plus\",\"version\":\"9.8.7\"}"], snapshot = false }, + { argv = ["vp", "--version"], cwd = "outer/external/inner" }, + { argv = ["vpt", "rm", "outer/external/inner/package.json"], snapshot = false }, + { argv = ["vpt", "write-file", "outer/external/inner/pnpm-workspace.yaml", "packages: []\n"], snapshot = false }, + { argv = ["vp", "--version"], cwd = "outer/external/inner" }, + { argv = ["vpt", "write-file", "outer/package.json", "{"], snapshot = false }, + { argv = ["vp", "--version"], cwd = "outer/external/inner" }, +] + [[case]] name = "excluded_project" vp = "global" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/version_manifest_resolution.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/version_manifest_resolution.global.md new file mode 100644 index 0000000000..610c54b064 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/version_manifest_resolution.global.md @@ -0,0 +1,121 @@ +# version_manifest_resolution + +Both CLI entry points handle package exports, BOMs, and malformed ancestors through the same resolver. + +## `vpt mkdir -p outer/external/inner/node_modules` + + +## `vpt cp -r outer-cli outer/external/inner/node_modules/vite-plus` + + +## `vpt write-file outer/external/inner/node_modules/vite-plus/package.json '{"name":"vite-plus","version":"9.8.7","exports":{".":"./dist/bin.js"}}'` + + +## `cd outer/external/inner && vp --version` + +``` +VITE+ - The Unified Toolchain for the Web + +vp + +Local vite-plus: + vite-plus Not found + +Tools: + vite Not found + rolldown Not found + vitest Not found + oxfmt Not found + oxlint Not found + oxlint-tsgolint Not found + tsdown Not found + +Environment: + Package manager Not found + Node.js +``` + +## `vpt write-file outer/external/inner/node_modules/vite-plus/package.json '{"name":"vite-plus","version":"9.8.7"}'` + + +## `cd outer/external/inner && vp --version` + +``` +VITE+ - The Unified Toolchain for the Web + +vp + +Local vite-plus: + vite-plus + +Tools: + vite + rolldown + vitest Not found + oxfmt Not found + oxlint Not found + oxlint-tsgolint Not found + tsdown + +Environment: + Package manager Not found + Node.js +``` + +## `vpt rm outer/external/inner/package.json` + + +## `vpt write-file outer/external/inner/pnpm-workspace.yaml 'packages: [] +'` + + +## `cd outer/external/inner && vp --version` + +``` +VITE+ - The Unified Toolchain for the Web + +vp + +Local vite-plus: + vite-plus + +Tools: + vite + rolldown + vitest Not found + oxfmt Not found + oxlint Not found + oxlint-tsgolint Not found + tsdown + +Environment: + Package manager pnpm latest + Node.js +``` + +## `vpt write-file outer/package.json {` + + +## `cd outer/external/inner && vp --version` + +``` +VITE+ - The Unified Toolchain for the Web + +vp + +Local vite-plus: + vite-plus Not found + +Tools: + vite Not found + rolldown Not found + vitest Not found + oxfmt Not found + oxlint Not found + oxlint-tsgolint Not found + tsdown Not found + +Environment: + Package manager pnpm latest + Node.js +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/version_manifest_resolution.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/version_manifest_resolution.local.md new file mode 100644 index 0000000000..dd164bf6cb --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/version_manifest_resolution.local.md @@ -0,0 +1,87 @@ +# version_manifest_resolution + +Both CLI entry points handle package exports, BOMs, and malformed ancestors through the same resolver. + +## `vpt mkdir -p outer/external/inner/node_modules` + + +## `vpt cp -r outer-cli outer/external/inner/node_modules/vite-plus` + + +## `vpt write-file outer/external/inner/node_modules/vite-plus/package.json '{"name":"vite-plus","version":"9.8.7","exports":{".":"./dist/bin.js"}}'` + + +## `cd outer/external/inner && vp --version` + +``` +VITE+ - The Unified Toolchain for the Web + +vp + +Local vite-plus: + vite-plus Not found +``` + +## `vpt write-file outer/external/inner/node_modules/vite-plus/package.json '{"name":"vite-plus","version":"9.8.7"}'` + + +## `cd outer/external/inner && vp --version` + +``` +VITE+ - The Unified Toolchain for the Web + +vp + +Local vite-plus: + vite-plus + +Tools: + vite Not found + rolldown Not found + vitest Not found + oxfmt Not found + oxlint Not found + oxlint-tsgolint Not found + tsdown Not found +``` + +## `vpt rm outer/external/inner/package.json` + + +## `vpt write-file outer/external/inner/pnpm-workspace.yaml 'packages: [] +'` + + +## `cd outer/external/inner && vp --version` + +``` +VITE+ - The Unified Toolchain for the Web + +vp + +Local vite-plus: + vite-plus + +Tools: + vite Not found + rolldown Not found + vitest Not found + oxfmt Not found + oxlint Not found + oxlint-tsgolint Not found + tsdown Not found +``` + +## `vpt write-file outer/package.json {` + + +## `cd outer/external/inner && vp --version` + +``` +VITE+ - The Unified Toolchain for the Web + +vp + +Local vite-plus: + vite-plus Not found +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/version_project_boundary.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/version_project_boundary.global.md new file mode 100644 index 0000000000..b252daaf8c --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/version_project_boundary.global.md @@ -0,0 +1,87 @@ +# version_project_boundary + +Both CLI entry points reject an excluded project's ancestor install, allow workspace members to share it, and find an optional local dependency. + +## `vpt mkdir -p outer/node_modules outer/external/inner/node_modules` + + +## `vpt cp -r outer-cli outer/node_modules/vite-plus` + + +## `cd outer/external/inner && vp --version` + +``` +VITE+ - The Unified Toolchain for the Web + +vp + +Local vite-plus: + vite-plus Not found + +Tools: + vite Not found + rolldown Not found + vitest Not found + oxfmt Not found + oxlint Not found + oxlint-tsgolint Not found + tsdown Not found + +Environment: + Package manager Not found + Node.js +``` + +## `cd outer/packages/member && vp --version` + +``` +VITE+ - The Unified Toolchain for the Web + +vp + +Local vite-plus: + vite-plus + +Tools: + vite + rolldown + vitest Not found + oxfmt Not found + oxlint Not found + oxlint-tsgolint Not found + tsdown + +Environment: + Package manager Not found + Node.js +``` + +## `vpt cp -r outer-cli outer/external/inner/node_modules/vite-plus` + + +## `vpt write-file outer/external/inner/package.json '{"name":"inner","optionalDependencies":{"vite-plus":"9.8.7"}}'` + + +## `cd outer/external/inner && vp --version` + +``` +VITE+ - The Unified Toolchain for the Web + +vp + +Local vite-plus: + vite-plus + +Tools: + vite + rolldown + vitest Not found + oxfmt Not found + oxlint Not found + oxlint-tsgolint Not found + tsdown + +Environment: + Package manager Not found + Node.js +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/version_project_boundary.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/version_project_boundary.local.md new file mode 100644 index 0000000000..684d23b7ce --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/local_cli_workspace_boundary/snapshots/version_project_boundary.local.md @@ -0,0 +1,66 @@ +# version_project_boundary + +Both CLI entry points reject an excluded project's ancestor install, allow workspace members to share it, and find an optional local dependency. + +## `vpt mkdir -p outer/node_modules outer/external/inner/node_modules` + + +## `vpt cp -r outer-cli outer/node_modules/vite-plus` + + +## `cd outer/external/inner && vp --version` + +``` +VITE+ - The Unified Toolchain for the Web + +vp + +Local vite-plus: + vite-plus Not found +``` + +## `cd outer/packages/member && vp --version` + +``` +VITE+ - The Unified Toolchain for the Web + +vp + +Local vite-plus: + vite-plus + +Tools: + vite Not found + rolldown Not found + vitest Not found + oxfmt Not found + oxlint Not found + oxlint-tsgolint Not found + tsdown Not found +``` + +## `vpt cp -r outer-cli outer/external/inner/node_modules/vite-plus` + + +## `vpt write-file outer/external/inner/package.json '{"name":"inner","optionalDependencies":{"vite-plus":"9.8.7"}}'` + + +## `cd outer/external/inner && vp --version` + +``` +VITE+ - The Unified Toolchain for the Web + +vp + +Local vite-plus: + vite-plus + +Tools: + vite Not found + rolldown Not found + vitest Not found + oxfmt Not found + oxlint Not found + oxlint-tsgolint Not found + tsdown Not found +``` diff --git a/crates/vp_global_cli/Cargo.toml b/crates/vp_global_cli/Cargo.toml index 4e5eb2dc2b..3caed05255 100644 --- a/crates/vp_global_cli/Cargo.toml +++ b/crates/vp_global_cli/Cargo.toml @@ -23,7 +23,6 @@ futures = { workspace = true } flate2 = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -serde_yaml = { workspace = true } node-semver = { workspace = true } thiserror = { workspace = true } tar = { workspace = true } @@ -32,7 +31,6 @@ tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } owo-colors = { workspace = true } same-file = { workspace = true } -oxc_resolver = { workspace = true } rustc-hash = { workspace = true } crossterm = { workspace = true } indexmap = { workspace = true } @@ -40,9 +38,9 @@ indicatif = { workspace = true } indoc = { workspace = true } vp_error = { workspace = true } vp_js_runtime = { workspace = true } +vp_local_cli = { workspace = true } vp_pm_cli = { workspace = true } vt_path = { workspace = true } -vt_glob = { workspace = true } vp_command = { workspace = true } vp_cli_help = { workspace = true } vp_setup = { workspace = true } diff --git a/crates/vp_global_cli/src/commands/mod.rs b/crates/vp_global_cli/src/commands/mod.rs index 7b19dd778f..d8556a1982 100644 --- a/crates/vp_global_cli/src/commands/mod.rs +++ b/crates/vp_global_cli/src/commands/mod.rs @@ -16,62 +16,12 @@ //! Category C - Local CLI Delegation: //! - `delegate`: Local CLI delegation -use std::collections::HashMap; - +use vp_local_cli::{find_nearest_package_json, package_json_has_vite_plus_dependency}; use vp_shared::{PrependOptions, output, prepend_tools_to_path_env}; use vt_path::{AbsolutePath, AbsolutePathBuf}; use crate::{error::Error, js_executor::JsExecutor}; -mod local_install; -pub(crate) use local_install::local_vite_plus_boundary; - -#[derive(serde::Deserialize, Default)] -#[serde(rename_all = "camelCase")] -struct DepCheckPackageJson { - #[serde(default)] - dependencies: HashMap, - #[serde(default)] - dev_dependencies: HashMap, - #[serde(default)] - optional_dependencies: HashMap, -} - -impl DepCheckPackageJson { - fn has_vite_plus(&self) -> bool { - self.dependencies.contains_key("vite-plus") - || self.dev_dependencies.contains_key("vite-plus") - || self.optional_dependencies.contains_key("vite-plus") - } -} - -fn find_nearest_package_json(cwd: &AbsolutePath) -> Option { - let mut current = cwd; - loop { - let package_json_path = current.join("package.json"); - if package_json_path.as_path().exists() { - return Some(package_json_path); - } - match current.parent() { - Some(parent) if parent != current => current = parent, - _ => return None, - } - } -} - -fn package_json_has_vite_plus_dependency(package_json_path: &AbsolutePath) -> bool { - read_dependency_manifest(package_json_path).is_some_and(|pkg| pkg.has_vite_plus()) -} - -fn read_dependency_manifest(package_json_path: &AbsolutePath) -> Option { - let content = std::fs::read(package_json_path).ok()?; - serde_json::from_slice(strip_bom(&content)).ok() -} - -fn strip_bom(content: &[u8]) -> &[u8] { - content.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(content) -} - fn find_vite_plus_dependency(cwd: &AbsolutePath) -> Option { let mut current = cwd; loop { diff --git a/crates/vp_global_cli/src/commands/version.rs b/crates/vp_global_cli/src/commands/version.rs index 1f9b83fc55..00c40c5b3f 100644 --- a/crates/vp_global_cli/src/commands/version.rs +++ b/crates/vp_global_cli/src/commands/version.rs @@ -12,7 +12,7 @@ use vp_pm_cli::get_package_manager_type_and_version; use vt_path::{AbsolutePath, AbsolutePathBuf}; use vt_workspace::find_workspace_root; -use crate::{commands::env::config::resolve_version, error::Error, help, js_executor::JsExecutor}; +use crate::{commands::env::config::resolve_version, error::Error, help}; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -65,7 +65,7 @@ fn read_package_json(package_json_path: &Path) -> Option { } fn find_local_vite_plus(cwd: &AbsolutePath) -> Option { - let resolved = JsExecutor::resolve_local_vite_plus_package(cwd)?; + let resolved = vp_local_cli::resolve_local_vite_plus_package(cwd)?; Some(LocalVitePlus { version: resolved.package_json()?.version()?.to_owned(), package_dir: resolved.path().parent()?.to_path_buf(), diff --git a/crates/vp_global_cli/src/js_executor.rs b/crates/vp_global_cli/src/js_executor.rs index 6372856dba..13bac759bf 100644 --- a/crates/vp_global_cli/src/js_executor.rs +++ b/crates/vp_global_cli/src/js_executor.rs @@ -407,31 +407,11 @@ impl JsExecutor { Ok(output) } - /// Resolve the local package while restricting lookup to the project boundary. - pub(crate) fn resolve_local_vite_plus_package( - project_path: &AbsolutePath, - ) -> Option { - use oxc_resolver::{ResolveOptions, Resolver, Restriction}; - - let mut options = ResolveOptions { - condition_names: vec!["import".into(), "node".into()], - ..ResolveOptions::default() - }; - if let Some(boundary) = commands::local_vite_plus_boundary(project_path) { - // Restrictions inspect the lookup path before symlinks are resolved. - // A project-local link may point to a package stored outside the project. - options.restrictions.push(Restriction::Fn(std::sync::Arc::new(move |path| { - path.starts_with(boundary.as_path()) - }))); - } - Resolver::new(options).resolve(project_path, "vite-plus/package.json").ok() - } - /// Resolve the local vite-plus package root from the project directory. pub(crate) fn resolve_local_vite_plus_package_dir( project_path: &AbsolutePath, ) -> Option { - let resolved = Self::resolve_local_vite_plus_package(project_path)?; + let resolved = vp_local_cli::resolve_local_vite_plus_package(project_path)?; let pkg_dir = resolved.path().parent()?; AbsolutePathBuf::new(pkg_dir.to_path_buf()) } @@ -453,16 +433,8 @@ impl JsExecutor { /// Resolve the version of the project-local `vite-plus`, if one is installed. fn resolve_local_vite_plus_version(project_path: &AbsolutePath) -> Option { - let package_dir = JsExecutor::resolve_local_vite_plus_package_dir(project_path)?; - read_package_json_version(package_dir.join("package.json")) -} - -/// Read the top-level `version` string from a package.json. Returns `None` when -/// the file is missing, unreadable, or has no string `version`. -fn read_package_json_version(pkg_json: impl AsRef) -> Option { - let content = std::fs::read_to_string(pkg_json).ok()?; - let value: serde_json::Value = serde_json::from_str(&content).ok()?; - value.get("version")?.as_str().map(str::to_string) + let resolved = vp_local_cli::resolve_local_vite_plus_package(project_path)?; + Some(resolved.package_json()?.version()?.to_owned()) } /// True when a version is a pkg.pr.new / registry-bridge preview build. @@ -629,7 +601,7 @@ mod tests { write_local_cli(&workspace, "0.3.0"); if ancestor == Some("{") { // Oxc still reads package scope above a rootless workspace. - assert!(JsExecutor::resolve_local_vite_plus_package(&workspace).is_none()); + assert!(vp_local_cli::resolve_local_vite_plus_package(&workspace).is_none()); } else { let package = JsExecutor::resolve_local_vite_plus_package_dir(&workspace) .expect("a missing root manifest must not prevent a local installation"); @@ -824,6 +796,19 @@ mod tests { ); } + #[test] + fn local_version_uses_resolver_metadata() { + let temp = tempfile::tempdir().unwrap(); + let project = AbsolutePath::new(temp.path()).unwrap(); + std::fs::write(project.join("package.json"), "{}").unwrap(); + write_local_cli(project, "0.3.0"); + let manifest = project.join("node_modules/vite-plus/package.json"); + let content = std::fs::read_to_string(&manifest).unwrap(); + std::fs::write(manifest, vt_str::format!("\u{feff}{content}").as_bytes()).unwrap(); + + assert_eq!(resolve_local_vite_plus_version(project).as_deref(), Some("0.3.0")); + } + #[test] fn test_local_vite_plus_is_older() { // Older local should escalate. diff --git a/crates/vp_local_cli/Cargo.toml b/crates/vp_local_cli/Cargo.toml new file mode 100644 index 0000000000..a29f25faae --- /dev/null +++ b/crates/vp_local_cli/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "vp_local_cli" +version = "0.0.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +publish = false +rust-version.workspace = true + +[dependencies] +oxc_resolver = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +vt_glob = { workspace = true } +vt_path = { workspace = true } +vt_str = { workspace = true } +vt_workspace = { workspace = true } + +[lints] +workspace = true diff --git a/crates/vp_global_cli/src/commands/local_install.rs b/crates/vp_local_cli/src/lib.rs similarity index 61% rename from crates/vp_global_cli/src/commands/local_install.rs rename to crates/vp_local_cli/src/lib.rs index 9f1dc3f879..d6a25734aa 100644 --- a/crates/vp_global_cli/src/commands/local_install.rs +++ b/crates/vp_local_cli/src/lib.rs @@ -1,15 +1,85 @@ +//! Project-local vite-plus resolution shared by the global CLI and NAPI binding. + +use std::collections::BTreeMap; + use serde::Deserialize; use vt_glob::path::PathGlobSet; use vt_path::{AbsolutePath, AbsolutePathBuf}; use vt_str::Str; use vt_workspace::{WorkspaceFile, WorkspaceRoot, find_workspace_root}; -use super::{find_nearest_package_json, read_dependency_manifest, strip_bom}; +/// Resolve the local package while restricting lookup to the project boundary. +pub fn resolve_local_vite_plus_package( + project_path: &AbsolutePath, +) -> Option { + use oxc_resolver::{ResolveOptions, Resolver, Restriction}; + + let mut options = ResolveOptions { + condition_names: vec!["import".into(), "node".into()], + ..ResolveOptions::default() + }; + if let Some(boundary) = local_vite_plus_boundary(project_path) { + // Restrictions inspect the lookup path before symlinks are resolved. + // A project-local link may point to a package stored outside the project. + options.restrictions.push(Restriction::Fn(std::sync::Arc::new(move |path| { + path.starts_with(boundary.as_path()) + }))); + } + Resolver::new(options).resolve(project_path, "vite-plus/package.json").ok() +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DepCheckPackageJson { + #[serde(default)] + dependencies: BTreeMap, + #[serde(default)] + dev_dependencies: BTreeMap, + #[serde(default)] + optional_dependencies: BTreeMap, +} + +impl DepCheckPackageJson { + fn has_vite_plus(&self) -> bool { + self.dependencies.contains_key("vite-plus") + || self.dev_dependencies.contains_key("vite-plus") + || self.optional_dependencies.contains_key("vite-plus") + } +} + +/// Find the nearest package manifest, including unreadable or malformed files. +pub fn find_nearest_package_json(cwd: &AbsolutePath) -> Option { + let mut current = cwd; + loop { + let package_json_path = current.join("package.json"); + if package_json_path.as_path().exists() { + return Some(package_json_path); + } + match current.parent() { + Some(parent) if parent != current => current = parent, + _ => return None, + } + } +} + +/// Check the manifest for a vite-plus dependency in any install dependency group. +pub fn package_json_has_vite_plus_dependency(package_json_path: &AbsolutePath) -> bool { + read_dependency_manifest(package_json_path).is_some_and(|pkg| pkg.has_vite_plus()) +} + +fn read_dependency_manifest(package_json_path: &AbsolutePath) -> Option { + let content = std::fs::read(package_json_path).ok()?; + serde_json::from_slice(strip_bom(&content)).ok() +} + +fn strip_bom(content: &[u8]) -> &[u8] { + content.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(content) +} /// Bound declared Vite+ projects at their package root, extending to a /// workspace root only for actual members. Unknown manifests keep the /// nearest known package boundary instead of permitting an ancestor install. -pub(crate) fn local_vite_plus_boundary(cwd: &AbsolutePath) -> Option { +fn local_vite_plus_boundary(cwd: &AbsolutePath) -> Option { let package_json = find_nearest_package_json(cwd); let Ok((workspace, _)) = find_workspace_root(cwd) else { return Some(package_json?.parent()?.to_absolute_path_buf()); diff --git a/packages/cli/binding/Cargo.toml b/packages/cli/binding/Cargo.toml index 96ed850a4f..2be77c10b9 100644 --- a/packages/cli/binding/Cargo.toml +++ b/packages/cli/binding/Cargo.toml @@ -31,6 +31,7 @@ vp_command = { workspace = true } vp_cli_help = { workspace = true } vp_error = { workspace = true } vp_migration = { workspace = true } +vp_local_cli = { workspace = true } vp_pm_cli = { workspace = true } vt_path = { workspace = true } vt_select = { workspace = true } diff --git a/packages/cli/binding/index.cjs b/packages/cli/binding/index.cjs index deb4318cfc..1f17300025 100644 --- a/packages/cli/binding/index.cjs +++ b/packages/cli/binding/index.cjs @@ -973,6 +973,7 @@ module.exports.parseCreateArgs = nativeBinding.parseCreateArgs; module.exports.parseHooksArgs = nativeBinding.parseHooksArgs; module.exports.parseMigrateArgs = nativeBinding.parseMigrateArgs; module.exports.parseStagedArgs = nativeBinding.parseStagedArgs; +module.exports.resolveLocalVitePlus = nativeBinding.resolveLocalVitePlus; module.exports.rewriteEslint = nativeBinding.rewriteEslint; module.exports.rewriteImportsInDirectory = nativeBinding.rewriteImportsInDirectory; module.exports.rewritePrettier = nativeBinding.rewritePrettier; diff --git a/packages/cli/binding/index.d.cts b/packages/cli/binding/index.d.cts index 4bb5e49a78..2437b4c306 100644 --- a/packages/cli/binding/index.d.cts +++ b/packages/cli/binding/index.d.cts @@ -3635,6 +3635,12 @@ export interface JsCommandResolvedResult { envs: Record; } +/** Metadata for the project-local vite-plus installation. */ +export interface LocalVitePlusMetadata { + version: string; + path: string; +} + /** * Merge JSON configuration file into vite config file * @@ -3765,6 +3771,9 @@ export interface PathAccess { readDir: boolean; } +/** Resolve local vite-plus with the same workspace boundary as the global CLI. */ +export declare function resolveLocalVitePlus(cwd: string): LocalVitePlusMetadata | null; + /** * Rewrite ESLint scripts: rename `eslint` → `vp lint` and strip ESLint-only flags. * diff --git a/packages/cli/binding/src/lib.rs b/packages/cli/binding/src/lib.rs index e195bfb55b..2c4b47631c 100644 --- a/packages/cli/binding/src/lib.rs +++ b/packages/cli/binding/src/lib.rs @@ -24,6 +24,8 @@ mod exec; #[allow(dead_code)] mod js_command_args; #[allow(dead_code)] +mod local_cli; +#[allow(dead_code)] mod migration; #[allow(dead_code)] mod package_manager; diff --git a/packages/cli/binding/src/local_cli.rs b/packages/cli/binding/src/local_cli.rs new file mode 100644 index 0000000000..4e2697be05 --- /dev/null +++ b/packages/cli/binding/src/local_cli.rs @@ -0,0 +1,24 @@ +use napi::{Result, Status}; +use napi_derive::napi; +use vt_path::AbsolutePathBuf; + +/// Metadata for the project-local vite-plus installation. +#[napi(object, object_from_js = false)] +pub struct LocalVitePlusMetadata { + pub version: String, + pub path: String, +} + +/// Resolve local vite-plus with the same workspace boundary as the global CLI. +#[napi] +pub fn resolve_local_vite_plus(cwd: String) -> Result> { + let cwd = AbsolutePathBuf::new(cwd.into()).ok_or_else(|| { + napi::Error::new(Status::InvalidArg, "Working directory must be absolute") + })?; + Ok(vp_local_cli::resolve_local_vite_plus_package(&cwd).and_then(|resolved| { + Some(LocalVitePlusMetadata { + version: resolved.package_json()?.version()?.to_owned(), + path: resolved.path().parent()?.to_str()?.to_owned(), + }) + })) +} diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts index 49019c7ff4..bf12d1212a 100644 --- a/packages/cli/src/version.ts +++ b/packages/cli/src/version.ts @@ -1,10 +1,6 @@ -import fs from 'node:fs'; -import path from 'node:path'; - +import { resolveLocalVitePlus } from '../binding/index.js'; import cliPkg from '../package.json' with { type: 'json' }; -import { VITE_PLUS_NAME } from './utils/constants.ts'; import { renderCliDoc } from './utils/help.ts'; -import { detectPackageMetadata, hasVitePlusDependency } from './utils/package.ts'; import { accent, log, printHeader } from './utils/terminal.ts'; /** Tool display names in the order shown by `vp --version`. */ @@ -18,12 +14,6 @@ const TOOL_DISPLAY_ORDER = [ 'tsdown', ] as const; -interface LocalPackageMetadata { - name: string; - version: string; - path: string; -} - function getGlobalVersion(): string | null { return process.env.VP_GLOBAL_VERSION ?? null; } @@ -32,34 +22,6 @@ function getCliVersion(): string | null { return cliPkg.version ?? null; } -function getLocalMetadata(cwd: string): LocalPackageMetadata | null { - if (!isVitePlusDeclaredInAncestors(cwd)) { - return null; - } - return detectPackageMetadata(cwd, VITE_PLUS_NAME) ?? null; -} - -function isVitePlusDeclaredInAncestors(cwd: string): boolean { - let currentDir = path.resolve(cwd); - while (true) { - const packageJsonPath = path.join(currentDir, 'package.json'); - try { - const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - if (hasVitePlusDependency(pkg)) { - return true; - } - } catch { - // no package.json at this level - } - const parentDir = path.dirname(currentDir); - if (parentDir === currentDir) { - break; - } - currentDir = parentDir; - } - return false; -} - /** * Resolve all tool versions from the locally installed vite-plus package. * Uses the `vite-plus/versions` export generated by `syncToolchainExports()`. @@ -82,7 +44,7 @@ async function resolveToolVersions(localPackagePath: string): Promise Date: Sun, 13 Sep 2026 01:13:27 +0800 Subject: [PATCH 14/14] test(cli): align direct version snapshot --- .../command_version/snapshots/command_version.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_version/snapshots/command_version.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_version/snapshots/command_version.md index 1507a93fcf..9836862491 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_version/snapshots/command_version.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_version/snapshots/command_version.md @@ -8,5 +8,14 @@ VITE+ - The Unified Toolchain for the Web vp Local vite-plus: - vite-plus Not found + vite-plus + +Tools: + vite + rolldown + vitest + oxfmt + oxlint + oxlint-tsgolint + tsdown ```