From 57b625ef6e70bebbbc04b05d273736cfbffed457 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 3 Jul 2026 15:05:08 +0800 Subject: [PATCH 01/33] feat(cli): global -C flag and workspace-root target elicitation for app commands Implements rfcs/cwd-flag.md (RFC #2022): - vp -C runs any command as if started in (git/make/pnpm convention), parsed by both the global binary and the local bin; the spawned tool gets as its working directory, no process.chdir - bare dev/build/preview/pack at a workspace root now resolve a target: defaultPackage from the root config (static extraction, works without a vite-plus install), single-runnable auto-select in interactive terminals, otherwise a package listing with -C hints and exit 1 - positional semantics are untouched: vite [root] and tsdown entries behave exactly as before (covered by a parity regression snap test) - interactive picker is a follow-up pending vite_select prompt support --- crates/vite_global_cli/src/cli.rs | 15 +- packages/cli/binding/src/cli/app_target.rs | 208 ++++++++++++++++++ packages/cli/binding/src/cli/mod.rs | 11 + .../command-app-root-listing/package.json | 8 + .../packages/lib/package.json | 6 + .../packages/lib/src/index.ts | 1 + .../packages/web/index.html | 6 + .../packages/web/package.json | 5 + .../command-app-root-listing/snap.txt | 21 ++ .../command-app-root-listing/steps.json | 6 + .../snap-tests/command-cwd-flag/package.json | 8 + .../packages/hello/package.json | 9 + .../packages/hello/src/index.ts | 3 + .../cli/snap-tests/command-cwd-flag/snap.txt | 41 ++++ .../snap-tests/command-cwd-flag/steps.json | 12 + .../package.json | 5 + .../command-default-package-missing/snap.txt | 3 + .../steps.json | 5 + .../vite.config.ts | 3 + .../command-default-package/package.json | 8 + .../packages/hello/package.json | 6 + .../packages/hello/src/index.ts | 3 + .../command-default-package/snap.txt | 10 + .../command-default-package/steps.json | 6 + .../command-default-package/vite.config.ts | 3 + packages/cli/src/bin.ts | 22 ++ packages/cli/src/define-config.ts | 9 + 27 files changed, 442 insertions(+), 1 deletion(-) create mode 100644 packages/cli/binding/src/cli/app_target.rs create mode 100644 packages/cli/snap-tests/command-app-root-listing/package.json create mode 100644 packages/cli/snap-tests/command-app-root-listing/packages/lib/package.json create mode 100644 packages/cli/snap-tests/command-app-root-listing/packages/lib/src/index.ts create mode 100644 packages/cli/snap-tests/command-app-root-listing/packages/web/index.html create mode 100644 packages/cli/snap-tests/command-app-root-listing/packages/web/package.json create mode 100644 packages/cli/snap-tests/command-app-root-listing/snap.txt create mode 100644 packages/cli/snap-tests/command-app-root-listing/steps.json create mode 100644 packages/cli/snap-tests/command-cwd-flag/package.json create mode 100644 packages/cli/snap-tests/command-cwd-flag/packages/hello/package.json create mode 100644 packages/cli/snap-tests/command-cwd-flag/packages/hello/src/index.ts create mode 100644 packages/cli/snap-tests/command-cwd-flag/snap.txt create mode 100644 packages/cli/snap-tests/command-cwd-flag/steps.json create mode 100644 packages/cli/snap-tests/command-default-package-missing/package.json create mode 100644 packages/cli/snap-tests/command-default-package-missing/snap.txt create mode 100644 packages/cli/snap-tests/command-default-package-missing/steps.json create mode 100644 packages/cli/snap-tests/command-default-package-missing/vite.config.ts create mode 100644 packages/cli/snap-tests/command-default-package/package.json create mode 100644 packages/cli/snap-tests/command-default-package/packages/hello/package.json create mode 100644 packages/cli/snap-tests/command-default-package/packages/hello/src/index.ts create mode 100644 packages/cli/snap-tests/command-default-package/snap.txt create mode 100644 packages/cli/snap-tests/command-default-package/steps.json create mode 100644 packages/cli/snap-tests/command-default-package/vite.config.ts diff --git a/crates/vite_global_cli/src/cli.rs b/crates/vite_global_cli/src/cli.rs index 2d91427f40..c88da539e0 100644 --- a/crates/vite_global_cli/src/cli.rs +++ b/crates/vite_global_cli/src/cli.rs @@ -53,6 +53,10 @@ pub struct Args { #[arg(short = 'V', long = "version")] pub version: bool, + /// Run as if vp was started in instead of the current working directory + #[arg(short = 'C', value_name = "DIR")] + pub chdir: Option, + #[clap(subcommand)] pub command: Option, } @@ -848,10 +852,19 @@ pub async fn run_command(cwd: AbsolutePathBuf, args: Args) -> Result Result { + // Apply the global `-C ` flag before anything reads cwd, so local CLI + // resolution and command execution behave as if vp was started in . + if let Some(dir) = &args.chdir { + cwd.push(dir); + if !cwd.as_path().is_dir() { + return Err(Error::UserMessage(format!("directory not found: {dir}").into())); + } + } + // Handle --version flag (Category B: delegates to JS) if args.version { return commands::version::execute(cwd).await; diff --git a/packages/cli/binding/src/cli/app_target.rs b/packages/cli/binding/src/cli/app_target.rs new file mode 100644 index 0000000000..a9c22a0c43 --- /dev/null +++ b/packages/cli/binding/src/cli/app_target.rs @@ -0,0 +1,208 @@ +//! Target elicitation for bare app commands at a workspace root. +//! +//! A bare `vp dev`/`build`/`preview`/`pack` at a workspace root has no target +//! and would silently run against the root. Resolution order (rfcs/cwd-flag.md): +//! explicit `-C` and positional targets are handled before this code and skip +//! elicitation entirely; then `defaultPackage` from the root config, then the +//! workspace package listing (interactive picker planned once `vite_select` +//! supports a custom prompt), then exit 1. + +use std::io::IsTerminal; + +use vite_error::Error; +use vite_path::AbsolutePathBuf; +use vite_shared::output; +use vite_task::ExitStatus; +use vite_workspace::WorkspaceFile; + +use super::types::SynthesizableSubcommand; + +/// Where a bare app command should run. +pub(super) enum AppTarget { + /// No elicitation applies; run in the invocation directory as today. + CurrentDir, + /// Run as if invoked in this directory (implicit `-C`). + Dir(AbsolutePathBuf), + /// Elicitation printed its output and decided the exit code. + Exit(ExitStatus), +} + +struct PackageRow { + name: String, + path: String, + absolute: AbsolutePathBuf, + runnable: bool, +} + +/// App commands are the single-target subcommands; everything else never +/// goes through elicitation. +fn app_command_parts(subcommand: &SynthesizableSubcommand) -> Option<(&'static str, &[String])> { + match subcommand { + SynthesizableSubcommand::Dev { args } => Some(("dev", args)), + SynthesizableSubcommand::Build { args } => Some(("build", args)), + SynthesizableSubcommand::Preview { args } => Some(("preview", args)), + SynthesizableSubcommand::Pack { args } => Some(("pack", args)), + _ => None, + } +} + +/// Bare = no positional target. A non-flag token may be a flag value +/// (`--port 3000`), so any non-flag argument conservatively disables +/// elicitation and forwards the invocation unchanged. +fn is_bare(args: &[String]) -> bool { + args.iter().all(|arg| arg.starts_with('-')) +} + +/// Mirror of the global command picker's interactivity gate. +fn is_interactive() -> bool { + const CI_ENV_VARS: &[&str] = + &["CI", "CONTINUOUS_INTEGRATION", "GITHUB_ACTIONS", "GITLAB_CI", "BUILDKITE", "JENKINS_URL"]; + std::io::stdin().is_terminal() + && std::io::stdout().is_terminal() + && std::env::var("TERM").map_or(true, |term| term != "dumb") + && !CI_ENV_VARS.iter().any(|key| std::env::var_os(key).is_some()) +} + +/// Heuristic ranking signal: does `dir` look runnable for `command`? +/// Used for ordering and single-candidate auto-selection, never for hiding. +fn looks_runnable(dir: &AbsolutePathBuf, command: &str) -> bool { + const VITE_CONFIGS: &[&str] = + &["vite.config.ts", "vite.config.js", "vite.config.mts", "vite.config.mjs"]; + let has_vite_config = VITE_CONFIGS.iter().any(|name| dir.as_path().join(name).is_file()); + match command { + "pack" => has_vite_config || dir.as_path().join("src/index.ts").is_file(), + _ => has_vite_config || dir.as_path().join("index.html").is_file(), + } +} + +/// `defaultPackage` from the root `vite.config.*`, read via static extraction +/// so it works at roots without a vite-plus install (non-workspace framework +/// repos). The value must be a static string literal. +fn resolve_default_package(command: &str, cwd: &AbsolutePathBuf) -> Option { + let fields = vite_static_config::resolve_static_config(cwd); + match fields.get("defaultPackage") { + Some(vite_static_config::FieldValue::Json(serde_json::Value::String(dir))) => { + let mut target = cwd.clone(); + target.push(dir.trim_start_matches("./")); + if !target.as_path().is_dir() { + output::error(&format!("defaultPackage points to a missing directory: {dir}")); + return Some(AppTarget::Exit(ExitStatus(1))); + } + output::note(&format!("vp {command}: using {dir} (defaultPackage)")); + Some(AppTarget::Dir(target)) + } + Some(vite_static_config::FieldValue::Json(other)) => { + output::error(&format!("defaultPackage must be a string of a directory, got: {other}")); + Some(AppTarget::Exit(ExitStatus(1))) + } + Some(vite_static_config::FieldValue::NonStatic) => { + output::error( + "defaultPackage in vite.config.ts must be a static string literal so vp can read it without executing the config", + ); + Some(AppTarget::Exit(ExitStatus(1))) + } + None => None, + } +} + +pub(super) fn resolve_app_target( + subcommand: &SynthesizableSubcommand, + cwd: &AbsolutePathBuf, +) -> Result { + let Some((command, args)) = app_command_parts(subcommand) else { + return Ok(AppTarget::CurrentDir); + }; + if !is_bare(args) { + return Ok(AppTarget::CurrentDir); + } + + let (workspace_root, rel_from_root) = vite_workspace::find_workspace_root(cwd)?; + if !rel_from_root.as_str().is_empty() { + return Ok(AppTarget::CurrentDir); + } + + if let Some(target) = resolve_default_package(command, cwd) { + return Ok(target); + } + + // Only real workspaces have a package list to offer; a standalone package + // root keeps today's behavior. + if matches!(workspace_root.workspace_file, WorkspaceFile::NonWorkspacePackage(_)) { + return Ok(AppTarget::CurrentDir); + } + + let graph = vite_workspace::load_package_graph(&workspace_root) + .map_err(|e| Error::Anyhow(e.into()))?; + let mut rows: Vec = graph + .node_weights() + .filter(|info| !info.path.as_str().is_empty()) + .map(|info| PackageRow { + name: info.package_json.name.to_string(), + path: info.path.as_str().to_string(), + absolute: info.absolute_path.to_absolute_path_buf(), + runnable: false, + }) + .collect(); + if rows.is_empty() { + return Ok(AppTarget::CurrentDir); + } + for row in &mut rows { + row.runnable = looks_runnable(&row.absolute, command); + } + rows.sort_by(|a, b| (!a.runnable, a.path.as_str()).cmp(&(!b.runnable, b.path.as_str()))); + + // With exactly one likely-runnable package, an interactive terminal + // auto-selects it (the degenerate picker). + if is_interactive() && rows.iter().filter(|row| row.runnable).count() == 1 { + let row = rows.iter().find(|row| row.runnable).expect("one runnable row exists"); + println!("Selected package: {} ({})", row.name, row.path); + println!("Tip: run this directly with `vp -C {} {command}`", row.path); + return Ok(AppTarget::Dir(row.absolute.clone())); + } + + output::error(&format!("`vp {command}` at the workspace root needs a target package.")); + output::raw_stderr(""); + output::raw_stderr(" Packages in this workspace:"); + let name_width = rows.iter().map(|row| row.name.len()).max().unwrap_or(0); + for row in &rows { + output::raw_stderr(&format!(" {:>(); + assert!(is_bare(&to_args(&[]))); + assert!(is_bare(&to_args(&["--watch"]))); + assert!(is_bare(&to_args(&["-w", "--minify"]))); + // A positional target disables elicitation. + assert!(!is_bare(&to_args(&["apps/web"]))); + // A flag value is indistinguishable from a positional without knowing + // the tool's flag arity, so it conservatively counts as non-bare. + assert!(!is_bare(&to_args(&["--port", "3000"]))); + } + + #[test] + fn only_app_commands_elicit() { + let args = vec![]; + for (subcommand, expected) in [ + (SynthesizableSubcommand::Dev { args: args.clone() }, Some("dev")), + (SynthesizableSubcommand::Build { args: args.clone() }, Some("build")), + (SynthesizableSubcommand::Preview { args: args.clone() }, Some("preview")), + (SynthesizableSubcommand::Pack { args: args.clone() }, Some("pack")), + (SynthesizableSubcommand::Lint { args: args.clone() }, None), + (SynthesizableSubcommand::Test { args: args.clone() }, None), + ] { + assert_eq!(app_command_parts(&subcommand).map(|(name, _)| name), expected); + } + } +} diff --git a/packages/cli/binding/src/cli/mod.rs b/packages/cli/binding/src/cli/mod.rs index 5033a24339..2ef30f0275 100644 --- a/packages/cli/binding/src/cli/mod.rs +++ b/packages/cli/binding/src/cli/mod.rs @@ -3,6 +3,7 @@ //! This module contains all the CLI-related code. //! It handles argument parsing, command dispatching, and orchestration of the task execution. +mod app_target; mod execution; mod handler; mod help; @@ -46,6 +47,16 @@ async fn execute_direct_subcommand( cwd: &AbsolutePathBuf, options: Option, ) -> Result { + // A bare app command at a workspace root resolves its target first + // (defaultPackage, package listing); the command then runs as if invoked + // in the resolved directory (rfcs/cwd-flag.md). + let elicited_cwd = match app_target::resolve_app_target(&subcommand, cwd)? { + app_target::AppTarget::CurrentDir => None, + app_target::AppTarget::Dir(dir) => Some(dir), + app_target::AppTarget::Exit(status) => return Ok(status), + }; + let cwd = elicited_cwd.as_ref().unwrap_or(cwd); + let (workspace_root, _) = vite_workspace::find_workspace_root(cwd)?; let workspace_path: Arc = workspace_root.path.into(); diff --git a/packages/cli/snap-tests/command-app-root-listing/package.json b/packages/cli/snap-tests/command-app-root-listing/package.json new file mode 100644 index 0000000000..e63823dcc9 --- /dev/null +++ b/packages/cli/snap-tests/command-app-root-listing/package.json @@ -0,0 +1,8 @@ +{ + "name": "command-app-root-listing", + "version": "1.0.0", + "workspaces": [ + "packages/*" + ], + "type": "module" +} diff --git a/packages/cli/snap-tests/command-app-root-listing/packages/lib/package.json b/packages/cli/snap-tests/command-app-root-listing/packages/lib/package.json new file mode 100644 index 0000000000..bc55518964 --- /dev/null +++ b/packages/cli/snap-tests/command-app-root-listing/packages/lib/package.json @@ -0,0 +1,6 @@ +{ + "name": "lib", + "version": "1.0.0", + "type": "module", + "main": "src/index.ts" +} diff --git a/packages/cli/snap-tests/command-app-root-listing/packages/lib/src/index.ts b/packages/cli/snap-tests/command-app-root-listing/packages/lib/src/index.ts new file mode 100644 index 0000000000..7b8c293a75 --- /dev/null +++ b/packages/cli/snap-tests/command-app-root-listing/packages/lib/src/index.ts @@ -0,0 +1 @@ +export const lib = true; diff --git a/packages/cli/snap-tests/command-app-root-listing/packages/web/index.html b/packages/cli/snap-tests/command-app-root-listing/packages/web/index.html new file mode 100644 index 0000000000..b61cdc6f1d --- /dev/null +++ b/packages/cli/snap-tests/command-app-root-listing/packages/web/index.html @@ -0,0 +1,6 @@ + + + +

web app

+ + diff --git a/packages/cli/snap-tests/command-app-root-listing/packages/web/package.json b/packages/cli/snap-tests/command-app-root-listing/packages/web/package.json new file mode 100644 index 0000000000..886a9e5f15 --- /dev/null +++ b/packages/cli/snap-tests/command-app-root-listing/packages/web/package.json @@ -0,0 +1,5 @@ +{ + "name": "web", + "version": "1.0.0", + "type": "module" +} diff --git a/packages/cli/snap-tests/command-app-root-listing/snap.txt b/packages/cli/snap-tests/command-app-root-listing/snap.txt new file mode 100644 index 0000000000..7cd75deb38 --- /dev/null +++ b/packages/cli/snap-tests/command-app-root-listing/snap.txt @@ -0,0 +1,21 @@ +> vp build || echo 'exited non-zero' # bare build at the workspace root lists packages instead of building the root +error: `vp build` at the workspace root needs a target package. + + Packages in this workspace: + web packages/web + lib packages/lib + + Pass a directory: vp -C packages/web build + Or run every package's build script: vp run -r build +exited non-zero + +> vp dev || echo 'exited non-zero' # bare dev at the root no longer starts a server against the root +error: `vp dev` at the workspace root needs a target package. + + Packages in this workspace: + web packages/web + lib packages/lib + + Pass a directory: vp -C packages/web dev + Or run every package's dev script: vp run -r dev +exited non-zero diff --git a/packages/cli/snap-tests/command-app-root-listing/steps.json b/packages/cli/snap-tests/command-app-root-listing/steps.json new file mode 100644 index 0000000000..3ee5bf62d0 --- /dev/null +++ b/packages/cli/snap-tests/command-app-root-listing/steps.json @@ -0,0 +1,6 @@ +{ + "commands": [ + "vp build || echo 'exited non-zero' # bare build at the workspace root lists packages instead of building the root", + "vp dev || echo 'exited non-zero' # bare dev at the root no longer starts a server against the root" + ] +} diff --git a/packages/cli/snap-tests/command-cwd-flag/package.json b/packages/cli/snap-tests/command-cwd-flag/package.json new file mode 100644 index 0000000000..1fff4a5751 --- /dev/null +++ b/packages/cli/snap-tests/command-cwd-flag/package.json @@ -0,0 +1,8 @@ +{ + "name": "command-cwd-flag", + "version": "1.0.0", + "workspaces": [ + "packages/*" + ], + "type": "module" +} diff --git a/packages/cli/snap-tests/command-cwd-flag/packages/hello/package.json b/packages/cli/snap-tests/command-cwd-flag/packages/hello/package.json new file mode 100644 index 0000000000..c765a5d476 --- /dev/null +++ b/packages/cli/snap-tests/command-cwd-flag/packages/hello/package.json @@ -0,0 +1,9 @@ +{ + "name": "hello", + "version": "1.0.0", + "type": "module", + "main": "src/index.ts", + "scripts": { + "where": "node -e \"console.log('cwd base: ' + require('node:path').basename(process.cwd()))\"" + } +} diff --git a/packages/cli/snap-tests/command-cwd-flag/packages/hello/src/index.ts b/packages/cli/snap-tests/command-cwd-flag/packages/hello/src/index.ts new file mode 100644 index 0000000000..905c7a9bbb --- /dev/null +++ b/packages/cli/snap-tests/command-cwd-flag/packages/hello/src/index.ts @@ -0,0 +1,3 @@ +export function hello(name: string): string { + return `hello ${name}`; +} diff --git a/packages/cli/snap-tests/command-cwd-flag/snap.txt b/packages/cli/snap-tests/command-cwd-flag/snap.txt new file mode 100644 index 0000000000..d67e34d1f0 --- /dev/null +++ b/packages/cli/snap-tests/command-cwd-flag/snap.txt @@ -0,0 +1,41 @@ +> vp -C packages/hello pack # -C packs the package from the workspace root +ℹ entry: src/index.ts +ℹ Build start +ℹ dist/index.mjs kB │ gzip: kB +ℹ 1 files, total: kB +✔ Build complete in ms + +> ls packages/hello/dist # output lands in the target package +index.mjs + +> cd packages/hello && vp pack # the cd form is equivalent +ℹ entry: src/index.ts +ℹ Build start +ℹ Cleaning 1 files +ℹ dist/index.mjs kB │ gzip: kB +ℹ 1 files, total: kB +✔ Build complete in ms + +> vp -C packages/hello run where # -C applies to vp run as well +~/packages/hello$ node -e "console.log('cwd base: ' + require('node:path').basename(process.cwd()))" ⊘ cache disabled +cwd base: hello + + +> cd packages/hello && vp run where # equivalent cd form for run +~/packages/hello$ node -e "console.log('cwd base: ' + require('node:path').basename(process.cwd()))" ⊘ cache disabled +cwd base: hello + + +> vp -C packages/missing build || echo 'errored as expected' # missing directory errors +error: directory not found: packages/missing +errored as expected + +> vp pack packages/hello # positional stays a tsdown entry resolved from the invocation directory +ℹ entry: packages/hello +ℹ Build start +ℹ dist/hello.mjs kB │ gzip: kB +ℹ 1 files, total: kB +✔ Build complete in ms + +> ls dist # upstream semantics: output lands at the invocation directory, not in the package +hello.mjs diff --git a/packages/cli/snap-tests/command-cwd-flag/steps.json b/packages/cli/snap-tests/command-cwd-flag/steps.json new file mode 100644 index 0000000000..fc23fe99ec --- /dev/null +++ b/packages/cli/snap-tests/command-cwd-flag/steps.json @@ -0,0 +1,12 @@ +{ + "commands": [ + "vp -C packages/hello pack # -C packs the package from the workspace root", + "ls packages/hello/dist # output lands in the target package", + "cd packages/hello && vp pack # the cd form is equivalent", + "vp -C packages/hello run where # -C applies to vp run as well", + "cd packages/hello && vp run where # equivalent cd form for run", + "vp -C packages/missing build || echo 'errored as expected' # missing directory errors", + "vp pack packages/hello # positional stays a tsdown entry resolved from the invocation directory", + "ls dist # upstream semantics: output lands at the invocation directory, not in the package" + ] +} diff --git a/packages/cli/snap-tests/command-default-package-missing/package.json b/packages/cli/snap-tests/command-default-package-missing/package.json new file mode 100644 index 0000000000..fb8928c6d7 --- /dev/null +++ b/packages/cli/snap-tests/command-default-package-missing/package.json @@ -0,0 +1,5 @@ +{ + "name": "command-default-package-missing", + "version": "1.0.0", + "type": "module" +} diff --git a/packages/cli/snap-tests/command-default-package-missing/snap.txt b/packages/cli/snap-tests/command-default-package-missing/snap.txt new file mode 100644 index 0000000000..89cc6b2769 --- /dev/null +++ b/packages/cli/snap-tests/command-default-package-missing/snap.txt @@ -0,0 +1,3 @@ +> vp build || echo 'errored as expected' # defaultPackage pointing at a missing directory errors +error: defaultPackage points to a missing directory: ./packages/nope +errored as expected diff --git a/packages/cli/snap-tests/command-default-package-missing/steps.json b/packages/cli/snap-tests/command-default-package-missing/steps.json new file mode 100644 index 0000000000..e8d67be235 --- /dev/null +++ b/packages/cli/snap-tests/command-default-package-missing/steps.json @@ -0,0 +1,5 @@ +{ + "commands": [ + "vp build || echo 'errored as expected' # defaultPackage pointing at a missing directory errors" + ] +} diff --git a/packages/cli/snap-tests/command-default-package-missing/vite.config.ts b/packages/cli/snap-tests/command-default-package-missing/vite.config.ts new file mode 100644 index 0000000000..8204c22024 --- /dev/null +++ b/packages/cli/snap-tests/command-default-package-missing/vite.config.ts @@ -0,0 +1,3 @@ +export default { + defaultPackage: './packages/nope', +}; diff --git a/packages/cli/snap-tests/command-default-package/package.json b/packages/cli/snap-tests/command-default-package/package.json new file mode 100644 index 0000000000..f633b65741 --- /dev/null +++ b/packages/cli/snap-tests/command-default-package/package.json @@ -0,0 +1,8 @@ +{ + "name": "command-default-package", + "version": "1.0.0", + "workspaces": [ + "packages/*" + ], + "type": "module" +} diff --git a/packages/cli/snap-tests/command-default-package/packages/hello/package.json b/packages/cli/snap-tests/command-default-package/packages/hello/package.json new file mode 100644 index 0000000000..5ff9801f10 --- /dev/null +++ b/packages/cli/snap-tests/command-default-package/packages/hello/package.json @@ -0,0 +1,6 @@ +{ + "name": "hello", + "version": "1.0.0", + "type": "module", + "main": "src/index.ts" +} diff --git a/packages/cli/snap-tests/command-default-package/packages/hello/src/index.ts b/packages/cli/snap-tests/command-default-package/packages/hello/src/index.ts new file mode 100644 index 0000000000..905c7a9bbb --- /dev/null +++ b/packages/cli/snap-tests/command-default-package/packages/hello/src/index.ts @@ -0,0 +1,3 @@ +export function hello(name: string): string { + return `hello ${name}`; +} diff --git a/packages/cli/snap-tests/command-default-package/snap.txt b/packages/cli/snap-tests/command-default-package/snap.txt new file mode 100644 index 0000000000..a3519bf807 --- /dev/null +++ b/packages/cli/snap-tests/command-default-package/snap.txt @@ -0,0 +1,10 @@ +> vp pack # bare pack at the root follows defaultPackage +note: vp pack: using ./packages/hello (defaultPackage) +ℹ entry: src/index.ts +ℹ Build start +ℹ dist/index.mjs kB │ gzip: kB +ℹ 1 files, total: kB +✔ Build complete in ms + +> ls packages/hello/dist # output lands in the configured package +index.mjs diff --git a/packages/cli/snap-tests/command-default-package/steps.json b/packages/cli/snap-tests/command-default-package/steps.json new file mode 100644 index 0000000000..1c13e7a826 --- /dev/null +++ b/packages/cli/snap-tests/command-default-package/steps.json @@ -0,0 +1,6 @@ +{ + "commands": [ + "vp pack # bare pack at the root follows defaultPackage", + "ls packages/hello/dist # output lands in the configured package" + ] +} diff --git a/packages/cli/snap-tests/command-default-package/vite.config.ts b/packages/cli/snap-tests/command-default-package/vite.config.ts new file mode 100644 index 0000000000..dcfe02f744 --- /dev/null +++ b/packages/cli/snap-tests/command-default-package/vite.config.ts @@ -0,0 +1,3 @@ +export default { + defaultPackage: './packages/hello', +}; diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index ab86d07e83..46a6e299dd 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -10,6 +10,7 @@ * If no local installation is found, this global dist/bin.js is used as fallback. */ +import fs from 'node:fs'; import path from 'node:path'; import { run } from '../binding/index.js'; @@ -38,6 +39,27 @@ function getErrorMessage(err: unknown): string { // Parse command line arguments let args = process.argv.slice(2); +// Global `-C ` flag: run as if vp was started in . The global Rust +// CLI parses this itself and spawns bin.js with the target cwd already set; +// this branch covers direct local-bin invocations (`pnpm exec vp -C ...`). +if (args[0] === '-C' || (args[0]?.startsWith('-C') && args[0].length > 2)) { + const inline = args[0] !== '-C'; + const dir = inline ? args[0].slice(2) : args[1]; + if (!dir) { + errorMsg('-C requires a directory argument'); + process.exit(1); + } + const target = path.resolve(dir); + const stat = fs.statSync(target, { throwIfNoEntry: false }); + if (!stat?.isDirectory()) { + errorMsg(`directory not found: ${dir}`); + process.exit(1); + } + process.chdir(target); + args = args.slice(inline ? 1 : 2); + process.argv = process.argv.slice(0, 2).concat(args); +} + // Transform `vp help [command]` into `vp [command] --help` if (args[0] === 'help' && args[1]) { args = [args[1], '--help', ...args.slice(2)]; diff --git a/packages/cli/src/define-config.ts b/packages/cli/src/define-config.ts index 9b3065b723..e67bc84f76 100644 --- a/packages/cli/src/define-config.ts +++ b/packages/cli/src/define-config.ts @@ -53,6 +53,15 @@ declare module '@voidzero-dev/vite-plus-core' { pack?: PackUserConfig | PackUserConfig[]; + /** + * Default target directory for `vp dev` / `build` / `preview` / `pack` + * when invoked bare in the directory containing this config (an implicit + * `vp -C `). Relative to the config file's directory. Must be a + * static string literal: vp reads it without executing the config, so it + * also works at roots without a vite-plus install. + */ + defaultPackage?: string; + run?: RunConfig; staged?: StagedConfig; From cbaa54669cad71cc150effa43c3632d44addb854 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 3 Jul 2026 16:21:33 +0800 Subject: [PATCH 02/33] refactor(cli): dedupe interactivity gate and vite config probing, normalize -C paths Post-review cleanup: - hoist the CI/interactive-terminal gate into vite_shared and reuse it from the command picker, vite_install, and app-target elicitation (the new copy had drifted to 6 of 11 CI vars) - expose vite_static_config::has_config_file and use it for runnable ranking (the hand-rolled list missed vite.config.cjs/.cts) - normalize -C and defaultPackage joins with clean() so upward workspace walks never see ./ or .. components - read defaultPackage before the workspace lookup so package.json-less framework roots (the RFC's motivating shape) work - exempt -h/--help/-V/--version from elicitation so vp dev --help always reaches the tool - accept -C=dir in bin.ts to match the clap grammar; simplify row construction, single-runnable check, and the target binding in mod.rs --- crates/vite_global_cli/src/cli.rs | 3 +- crates/vite_global_cli/src/command_picker.rs | 25 +--- crates/vite_install/src/package_manager.rs | 21 +--- crates/vite_shared/src/interactivity.rs | 32 +++++ crates/vite_shared/src/lib.rs | 2 + crates/vite_static_config/src/lib.rs | 5 + packages/cli/binding/src/cli/app_target.rs | 116 +++++++++---------- packages/cli/binding/src/cli/mod.rs | 12 +- packages/cli/src/bin.ts | 7 +- 9 files changed, 110 insertions(+), 113 deletions(-) create mode 100644 crates/vite_shared/src/interactivity.rs diff --git a/crates/vite_global_cli/src/cli.rs b/crates/vite_global_cli/src/cli.rs index c88da539e0..1688f642ee 100644 --- a/crates/vite_global_cli/src/cli.rs +++ b/crates/vite_global_cli/src/cli.rs @@ -858,8 +858,9 @@ pub async fn run_command_with_options( ) -> Result { // Apply the global `-C ` flag before anything reads cwd, so local CLI // resolution and command execution behave as if vp was started in . + // `clean()` normalizes `.`/`..` so upward workspace walks never see them. if let Some(dir) = &args.chdir { - cwd.push(dir); + cwd = cwd.join(dir).clean(); if !cwd.as_path().is_dir() { return Err(Error::UserMessage(format!("directory not found: {dir}").into())); } diff --git a/crates/vite_global_cli/src/command_picker.rs b/crates/vite_global_cli/src/command_picker.rs index dc31289b9c..540faef750 100644 --- a/crates/vite_global_cli/src/command_picker.rs +++ b/crates/vite_global_cli/src/command_picker.rs @@ -1,7 +1,7 @@ //! Interactive top-level command picker for `vp`. use std::{ - io::{self, IsTerminal, Write}, + io::{self, Write}, ops::ControlFlow, }; @@ -114,20 +114,6 @@ const COMMANDS: &[CommandEntry] = &[ }, ]; -const CI_ENV_VARS: &[&str] = &[ - "CI", - "CONTINUOUS_INTEGRATION", - "GITHUB_ACTIONS", - "GITLAB_CI", - "CIRCLECI", - "TRAVIS", - "JENKINS_URL", - "BUILDKITE", - "DRONE", - "CODEBUILD_BUILD_ID", - "TF_BUILD", -]; - pub fn pick_top_level_command_if_interactive( cwd: &AbsolutePath, ) -> io::Result { @@ -144,14 +130,7 @@ pub fn pick_top_level_command_if_interactive( } fn should_enable_picker() -> bool { - std::io::stdin().is_terminal() - && std::io::stdout().is_terminal() - && std::env::var("TERM").map_or(true, |term| term != "dumb") - && !is_ci_environment() -} - -fn is_ci_environment() -> bool { - CI_ENV_VARS.iter().any(|key| std::env::var_os(key).is_some()) + vite_shared::is_interactive_terminal() } fn run_picker(command_order: &[usize]) -> io::Result> { diff --git a/crates/vite_install/src/package_manager.rs b/crates/vite_install/src/package_manager.rs index e7fcb975f1..e8966cefb4 100644 --- a/crates/vite_install/src/package_manager.rs +++ b/crates/vite_install/src/package_manager.rs @@ -1229,26 +1229,7 @@ async fn set_dev_engines_package_manager_field( } pub(crate) use vite_shared::format_path_prepended as format_path_env; - -/// Common CI environment variables -const CI_ENV_VARS: &[&str] = &[ - "CI", - "CONTINUOUS_INTEGRATION", - "GITHUB_ACTIONS", - "GITLAB_CI", - "CIRCLECI", - "TRAVIS", - "JENKINS_URL", - "BUILDKITE", - "DRONE", - "CODEBUILD_BUILD_ID", // AWS CodeBuild - "TF_BUILD", // Azure Pipelines -]; - -/// Check if running in a CI environment -fn is_ci_environment() -> bool { - CI_ENV_VARS.iter().any(|key| env::var(key).is_ok()) -} +use vite_shared::is_ci_environment; /// Interactive menu for selecting a package manager with keyboard navigation fn interactive_package_manager_menu() -> Result { diff --git a/crates/vite_shared/src/interactivity.rs b/crates/vite_shared/src/interactivity.rs new file mode 100644 index 0000000000..7d195db5f1 --- /dev/null +++ b/crates/vite_shared/src/interactivity.rs @@ -0,0 +1,32 @@ +//! Shared CI-environment and interactive-terminal detection. + +use std::io::IsTerminal; + +/// Common CI environment variables. +const CI_ENV_VARS: &[&str] = &[ + "CI", + "CONTINUOUS_INTEGRATION", + "GITHUB_ACTIONS", + "GITLAB_CI", + "CIRCLECI", + "TRAVIS", + "JENKINS_URL", + "BUILDKITE", + "DRONE", + "CODEBUILD_BUILD_ID", // AWS CodeBuild + "TF_BUILD", // Azure Pipelines +]; + +/// Check if running in a CI environment. +pub fn is_ci_environment() -> bool { + CI_ENV_VARS.iter().any(|key| std::env::var_os(key).is_some()) +} + +/// True when vp can show interactive prompts: stdin and stdout are terminals, +/// the terminal is capable, and this is not a CI environment. +pub fn is_interactive_terminal() -> bool { + std::io::stdin().is_terminal() + && std::io::stdout().is_terminal() + && std::env::var("TERM").map_or(true, |term| term != "dumb") + && !is_ci_environment() +} diff --git a/crates/vite_shared/src/lib.rs b/crates/vite_shared/src/lib.rs index 5abf7b727f..3f7b0173b3 100644 --- a/crates/vite_shared/src/lib.rs +++ b/crates/vite_shared/src/lib.rs @@ -11,6 +11,7 @@ mod env_config; pub mod env_vars; mod error; pub mod header; +mod interactivity; mod home; mod http; mod json_edit; @@ -24,6 +25,7 @@ mod tracing; pub use env_config::{EnvConfig, TestEnvGuard}; pub use error::format_error_chain; pub use home::{VP_BINARY_NAME, get_vp_home}; +pub use interactivity::{is_ci_environment, is_interactive_terminal}; pub use http::shared_http_client; pub use json_edit::{JsonStyle, edit_json_object, insert_after}; pub use package_json::{ diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index 29d65539e0..d5633752cd 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -87,6 +87,11 @@ const CONFIG_FILE_NAMES: &[&str] = &[ "vite.config.cts", ]; +/// Returns true when `dir` directly contains a Vite config file. +pub fn has_config_file(dir: &AbsolutePath) -> bool { + resolve_config_path(dir).is_some() +} + /// Resolve the vite config file path in the given directory. /// /// Tries each config file name in priority order and returns the first one that exists. diff --git a/packages/cli/binding/src/cli/app_target.rs b/packages/cli/binding/src/cli/app_target.rs index a9c22a0c43..77206cab90 100644 --- a/packages/cli/binding/src/cli/app_target.rs +++ b/packages/cli/binding/src/cli/app_target.rs @@ -3,11 +3,9 @@ //! A bare `vp dev`/`build`/`preview`/`pack` at a workspace root has no target //! and would silently run against the root. Resolution order (rfcs/cwd-flag.md): //! explicit `-C` and positional targets are handled before this code and skip -//! elicitation entirely; then `defaultPackage` from the root config, then the -//! workspace package listing (interactive picker planned once `vite_select` -//! supports a custom prompt), then exit 1. - -use std::io::IsTerminal; +//! elicitation entirely; then `defaultPackage` from the config in the +//! invocation directory, then the workspace package listing (interactive +//! picker planned once `vite_select` supports a custom prompt), then exit 1. use vite_error::Error; use vite_path::AbsolutePathBuf; @@ -46,61 +44,49 @@ fn app_command_parts(subcommand: &SynthesizableSubcommand) -> Option<(&'static s } } -/// Bare = no positional target. A non-flag token may be a flag value -/// (`--port 3000`), so any non-flag argument conservatively disables -/// elicitation and forwards the invocation unchanged. +/// Bare = no positional target and no help-like flag. A non-flag token may be +/// a flag value (`--port 3000`), so any non-flag argument conservatively +/// disables elicitation; help/version requests are answered by the underlying +/// tool and must never be redirected. fn is_bare(args: &[String]) -> bool { - args.iter().all(|arg| arg.starts_with('-')) -} - -/// Mirror of the global command picker's interactivity gate. -fn is_interactive() -> bool { - const CI_ENV_VARS: &[&str] = - &["CI", "CONTINUOUS_INTEGRATION", "GITHUB_ACTIONS", "GITLAB_CI", "BUILDKITE", "JENKINS_URL"]; - std::io::stdin().is_terminal() - && std::io::stdout().is_terminal() - && std::env::var("TERM").map_or(true, |term| term != "dumb") - && !CI_ENV_VARS.iter().any(|key| std::env::var_os(key).is_some()) + args.iter().all(|arg| { + arg.starts_with('-') && !matches!(arg.as_str(), "-h" | "--help" | "-V" | "--version") + }) } /// Heuristic ranking signal: does `dir` look runnable for `command`? /// Used for ordering and single-candidate auto-selection, never for hiding. fn looks_runnable(dir: &AbsolutePathBuf, command: &str) -> bool { - const VITE_CONFIGS: &[&str] = - &["vite.config.ts", "vite.config.js", "vite.config.mts", "vite.config.mjs"]; - let has_vite_config = VITE_CONFIGS.iter().any(|name| dir.as_path().join(name).is_file()); + let has_vite_config = vite_static_config::has_config_file(dir); match command { "pack" => has_vite_config || dir.as_path().join("src/index.ts").is_file(), _ => has_vite_config || dir.as_path().join("index.html").is_file(), } } -/// `defaultPackage` from the root `vite.config.*`, read via static extraction -/// so it works at roots without a vite-plus install (non-workspace framework -/// repos). The value must be a static string literal. +/// `defaultPackage` from the `vite.config.*` in `cwd`, read via static +/// extraction so it works at roots without a vite-plus install (non-workspace +/// framework repos). The value must be a static string literal. fn resolve_default_package(command: &str, cwd: &AbsolutePathBuf) -> Option { - let fields = vite_static_config::resolve_static_config(cwd); - match fields.get("defaultPackage") { + let fail = |msg: &str| { + output::error(msg); + Some(AppTarget::Exit(ExitStatus(1))) + }; + match vite_static_config::resolve_static_config(cwd).get("defaultPackage") { Some(vite_static_config::FieldValue::Json(serde_json::Value::String(dir))) => { - let mut target = cwd.clone(); - target.push(dir.trim_start_matches("./")); + let target = cwd.join(&dir).clean(); if !target.as_path().is_dir() { - output::error(&format!("defaultPackage points to a missing directory: {dir}")); - return Some(AppTarget::Exit(ExitStatus(1))); + return fail(&format!("defaultPackage points to a missing directory: {dir}")); } output::note(&format!("vp {command}: using {dir} (defaultPackage)")); Some(AppTarget::Dir(target)) } Some(vite_static_config::FieldValue::Json(other)) => { - output::error(&format!("defaultPackage must be a string of a directory, got: {other}")); - Some(AppTarget::Exit(ExitStatus(1))) - } - Some(vite_static_config::FieldValue::NonStatic) => { - output::error( - "defaultPackage in vite.config.ts must be a static string literal so vp can read it without executing the config", - ); - Some(AppTarget::Exit(ExitStatus(1))) + fail(&format!("defaultPackage must be a string of a directory, got: {other}")) } + Some(vite_static_config::FieldValue::NonStatic) => fail( + "defaultPackage in vite.config.ts must be a static string literal so vp can read it without executing the config", + ), None => None, } } @@ -116,18 +102,21 @@ pub(super) fn resolve_app_target( return Ok(AppTarget::CurrentDir); } - let (workspace_root, rel_from_root) = vite_workspace::find_workspace_root(cwd)?; - if !rel_from_root.as_str().is_empty() { - return Ok(AppTarget::CurrentDir); - } - + // `defaultPackage` comes before any workspace lookup: the non-workspace + // framework shape (a Laravel-style root with a vite.config.ts pointer and + // no package.json up-tree) has no workspace metadata at all. if let Some(target) = resolve_default_package(command, cwd) { return Ok(target); } - // Only real workspaces have a package list to offer; a standalone package - // root keeps today's behavior. - if matches!(workspace_root.workspace_file, WorkspaceFile::NonWorkspacePackage(_)) { + // The package listing needs workspace metadata; anything unresolvable + // keeps today's behavior (the caller surfaces its own workspace errors). + let Ok((workspace_root, rel_from_root)) = vite_workspace::find_workspace_root(cwd) else { + return Ok(AppTarget::CurrentDir); + }; + if !rel_from_root.as_str().is_empty() + || matches!(workspace_root.workspace_file, WorkspaceFile::NonWorkspacePackage(_)) + { return Ok(AppTarget::CurrentDir); } @@ -136,25 +125,26 @@ pub(super) fn resolve_app_target( let mut rows: Vec = graph .node_weights() .filter(|info| !info.path.as_str().is_empty()) - .map(|info| PackageRow { - name: info.package_json.name.to_string(), - path: info.path.as_str().to_string(), - absolute: info.absolute_path.to_absolute_path_buf(), - runnable: false, + .map(|info| { + let absolute = info.absolute_path.to_absolute_path_buf(); + PackageRow { + name: info.package_json.name.to_string(), + path: info.path.as_str().to_string(), + runnable: looks_runnable(&absolute, command), + absolute, + } }) .collect(); if rows.is_empty() { return Ok(AppTarget::CurrentDir); } - for row in &mut rows { - row.runnable = looks_runnable(&row.absolute, command); - } rows.sort_by(|a, b| (!a.runnable, a.path.as_str()).cmp(&(!b.runnable, b.path.as_str()))); - // With exactly one likely-runnable package, an interactive terminal - // auto-selects it (the degenerate picker). - if is_interactive() && rows.iter().filter(|row| row.runnable).count() == 1 { - let row = rows.iter().find(|row| row.runnable).expect("one runnable row exists"); + // With exactly one likely-runnable package (rows are sorted runnable + // first), an interactive terminal auto-selects it (the degenerate picker). + let single_runnable = rows[0].runnable && rows.get(1).is_none_or(|row| !row.runnable); + if single_runnable && vite_shared::is_interactive_terminal() { + let row = &rows[0]; println!("Selected package: {} ({})", row.name, row.path); println!("Tip: run this directly with `vp -C {} {command}`", row.path); return Ok(AppTarget::Dir(row.absolute.clone())); @@ -168,7 +158,7 @@ pub(super) fn resolve_app_target( output::raw_stderr(&format!(" {:>(); assert!(is_bare(&to_args(&[]))); assert!(is_bare(&to_args(&["--watch"]))); @@ -189,6 +179,10 @@ mod tests { // A flag value is indistinguishable from a positional without knowing // the tool's flag arity, so it conservatively counts as non-bare. assert!(!is_bare(&to_args(&["--port", "3000"]))); + // Help/version requests go to the underlying tool, never elicitation. + assert!(!is_bare(&to_args(&["--help"]))); + assert!(!is_bare(&to_args(&["-h"]))); + assert!(!is_bare(&to_args(&["--watch", "--version"]))); } #[test] diff --git a/packages/cli/binding/src/cli/mod.rs b/packages/cli/binding/src/cli/mod.rs index 2ef30f0275..de0c501139 100644 --- a/packages/cli/binding/src/cli/mod.rs +++ b/packages/cli/binding/src/cli/mod.rs @@ -50,12 +50,14 @@ async fn execute_direct_subcommand( // A bare app command at a workspace root resolves its target first // (defaultPackage, package listing); the command then runs as if invoked // in the resolved directory (rfcs/cwd-flag.md). - let elicited_cwd = match app_target::resolve_app_target(&subcommand, cwd)? { - app_target::AppTarget::CurrentDir => None, - app_target::AppTarget::Dir(dir) => Some(dir), - app_target::AppTarget::Exit(status) => return Ok(status), + let target = app_target::resolve_app_target(&subcommand, cwd)?; + if let app_target::AppTarget::Exit(status) = target { + return Ok(status); + } + let cwd = match &target { + app_target::AppTarget::Dir(dir) => dir, + _ => cwd, }; - let cwd = elicited_cwd.as_ref().unwrap_or(cwd); let (workspace_root, _) = vite_workspace::find_workspace_root(cwd)?; let workspace_path: Arc = workspace_root.path.into(); diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 46a6e299dd..e3b782e18c 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -42,9 +42,10 @@ let args = process.argv.slice(2); // Global `-C ` flag: run as if vp was started in . The global Rust // CLI parses this itself and spawns bin.js with the target cwd already set; // this branch covers direct local-bin invocations (`pnpm exec vp -C ...`). -if (args[0] === '-C' || (args[0]?.startsWith('-C') && args[0].length > 2)) { - const inline = args[0] !== '-C'; - const dir = inline ? args[0].slice(2) : args[1]; +// Accepts `-C dir`, `-Cdir`, and `-C=dir`, matching the clap grammar. +if (args[0]?.startsWith('-C')) { + const inline = args[0].length > 2; + const dir = inline ? args[0].slice(args[0][2] === '=' ? 3 : 2) : args[1]; if (!dir) { errorMsg('-C requires a directory argument'); process.exit(1); From 1388f4236d7e72e68f6186ccbd34ede5c7bedd3a Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 3 Jul 2026 22:29:35 +0800 Subject: [PATCH 03/33] refactor: memoized terminal checks behind a clippy rule, document -C and defaultPackage Review follow-ups: - reuse vite_powershell::is_stdin_terminal (memoized) and add memoized stdout/stderr wrappers in vite_shared; migrate all 19 direct is_terminal() call sites and enforce via disallowed-methods in .clippy.toml - keep the CI env check in is_interactive_terminal: it is not covered by the TTY checks because some CI systems run jobs in a PTY (Buildkite does by default); documented in the code - document -C and the workspace-root behavior in docs/guide/monorepo.md and defaultPackage in docs/config/index.md --- .clippy.toml | 1 + Cargo.lock | 1 + crates/vite_global_cli/src/cli.rs | 4 +-- .../src/commands/env/list_remote.rs | 4 +-- .../vite_global_cli/src/commands/env/pin.rs | 4 +-- .../src/commands/global/install.rs | 4 +-- .../src/commands/global/mod.rs | 4 +-- .../vite_global_cli/src/commands/implode.rs | 4 +-- crates/vite_global_cli/src/help.rs | 4 +-- crates/vite_global_cli/src/main.rs | 4 +-- crates/vite_global_cli/src/shim/dispatch.rs | 4 +-- crates/vite_global_cli/src/upgrade_check.rs | 7 ++--- crates/vite_install/src/package_manager.rs | 4 +-- crates/vite_js_runtime/src/download.rs | 4 +-- crates/vite_setup/src/install.rs | 4 +-- crates/vite_shared/Cargo.toml | 1 + crates/vite_shared/src/header.rs | 11 +++----- crates/vite_shared/src/interactivity.rs | 28 +++++++++++++++++-- crates/vite_shared/src/lib.rs | 5 +++- docs/config/index.md | 13 +++++++++ docs/guide/monorepo.md | 11 ++++++-- packages/cli/binding/src/check/analysis.rs | 3 +- packages/cli/binding/src/cli/execution.rs | 4 +-- 23 files changed, 86 insertions(+), 47 deletions(-) diff --git a/.clippy.toml b/.clippy.toml index fd0b1c45c2..40215aa466 100644 --- a/.clippy.toml +++ b/.clippy.toml @@ -12,6 +12,7 @@ disallowed-methods = [ { path = "str::replace", reason = "To avoid memory allocation, use `cow_utils::CowUtils::cow_replace` instead." }, { path = "str::replacen", reason = "To avoid memory allocation, use `cow_utils::CowUtils::cow_replacen` instead." }, { path = "std::env::current_dir", reason = "To get an `AbsolutePathBuf`, Use `vite_path::current_dir` instead." }, + { path = "std::io::IsTerminal::is_terminal", reason = "Use `vite_powershell::is_stdin_terminal` for stdin, or `vite_shared::is_stdout_terminal` / `vite_shared::is_stderr_terminal`, which memoize the lookup." }, ] disallowed-types = [ diff --git a/Cargo.lock b/Cargo.lock index e142086f4f..8a90e32b0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8820,6 +8820,7 @@ dependencies = [ "tracing", "tracing-subscriber", "vite_path", + "vite_powershell", "vite_str", ] diff --git a/crates/vite_global_cli/src/cli.rs b/crates/vite_global_cli/src/cli.rs index 1688f642ee..2b7d50c5a9 100644 --- a/crates/vite_global_cli/src/cli.rs +++ b/crates/vite_global_cli/src/cli.rs @@ -3,7 +3,7 @@ //! This module defines the CLI structure using clap and routes commands //! to their appropriate handlers. -use std::{collections::HashSet, ffi::OsStr, io::IsTerminal, process::ExitStatus}; +use std::{collections::HashSet, ffi::OsStr, process::ExitStatus}; use clap::{CommandFactory, FromArgMatches, Parser, Subcommand}; use clap_complete::ArgValueCompleter; @@ -809,7 +809,7 @@ fn should_reinstall_node_mismatches( return true; } - if !std::io::stdin().is_terminal() || std::env::var_os("CI").is_some() { + if !vite_shared::is_stdin_terminal() || std::env::var_os("CI").is_some() { let package_names = packages.iter().map(|package| package.name.as_str()).collect::>().join(", "); output::warn(&format!( diff --git a/crates/vite_global_cli/src/commands/env/list_remote.rs b/crates/vite_global_cli/src/commands/env/list_remote.rs index a42f160968..d8a6c8862a 100644 --- a/crates/vite_global_cli/src/commands/env/list_remote.rs +++ b/crates/vite_global_cli/src/commands/env/list_remote.rs @@ -117,8 +117,8 @@ fn strip_v(version: &str) -> &str { /// Whether colored output should be emitted on stdout. fn use_color() -> bool { - use std::io::IsTerminal; - std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none() + + vite_shared::is_stdout_terminal() && std::env::var_os("NO_COLOR").is_none() } /// Filter versions based on criteria. diff --git a/crates/vite_global_cli/src/commands/env/pin.rs b/crates/vite_global_cli/src/commands/env/pin.rs index 5f67440423..c795151c78 100644 --- a/crates/vite_global_cli/src/commands/env/pin.rs +++ b/crates/vite_global_cli/src/commands/env/pin.rs @@ -8,7 +8,7 @@ //! An existing `engines.node` is never deleted or modified. use std::{ - io::{IsTerminal, Write}, + io::Write, process::ExitStatus, }; @@ -290,7 +290,7 @@ async fn pin_node_version_file( // If a devEngines.runtime range is declared and no longer satisfied, offer to // sync it in interactive terminals and warn otherwise (rfcs/dev-engines.md) - check_dev_engines_sync(cwd, resolved_version, force, std::io::stdin().is_terminal()).await?; + check_dev_engines_sync(cwd, resolved_version, force, vite_shared::is_stdin_terminal()).await?; Ok(true) } diff --git a/crates/vite_global_cli/src/commands/global/install.rs b/crates/vite_global_cli/src/commands/global/install.rs index 0b09e52493..6c0399be4a 100644 --- a/crates/vite_global_cli/src/commands/global/install.rs +++ b/crates/vite_global_cli/src/commands/global/install.rs @@ -3,7 +3,7 @@ use std::{ collections::{HashMap, HashSet}, fs::{File, OpenOptions, TryLockError}, - io::{IsTerminal, Read, Write}, + io::{Read, Write}, process::Stdio, time::Duration, }; @@ -178,7 +178,7 @@ pub async fn install( )); let progress = ProgressBar::new(packages_count as u64); - if std::io::stderr().is_terminal() && std::env::var_os("CI").is_none() { + if vite_shared::is_stderr_terminal() && std::env::var_os("CI").is_none() { let style = ProgressStyle::with_template("{spinner:.cyan} {msg} ({pos}/{len})") .unwrap_or_else(|_| ProgressStyle::default_spinner()) .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]); diff --git a/crates/vite_global_cli/src/commands/global/mod.rs b/crates/vite_global_cli/src/commands/global/mod.rs index 57530e8ae5..e4ec789a73 100644 --- a/crates/vite_global_cli/src/commands/global/mod.rs +++ b/crates/vite_global_cli/src/commands/global/mod.rs @@ -3,7 +3,7 @@ use std::{ collections::HashMap, fs::File, - io::{IsTerminal, Read}, + io::Read, process::Stdio, time::Duration, }; @@ -96,7 +96,7 @@ pub(crate) async fn latest_package_versions( let mut versions = HashMap::with_capacity(specs.len()); let progress = ProgressBar::new(specs.len() as u64); - if std::io::stderr().is_terminal() && std::env::var_os("CI").is_none() { + if vite_shared::is_stderr_terminal() && std::env::var_os("CI").is_none() { let style = ProgressStyle::with_template("{spinner:.cyan} {msg} ({pos}/{len})") .unwrap_or_else(|_| ProgressStyle::default_spinner()) .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]); diff --git a/crates/vite_global_cli/src/commands/implode.rs b/crates/vite_global_cli/src/commands/implode.rs index e2f5ac8f30..dcee246be9 100644 --- a/crates/vite_global_cli/src/commands/implode.rs +++ b/crates/vite_global_cli/src/commands/implode.rs @@ -1,7 +1,7 @@ //! `vp implode` — completely remove vp and all its data from this system. use std::{ - io::{IsTerminal, Write}, + io::Write, process::ExitStatus, }; @@ -132,7 +132,7 @@ fn confirm_implode( home_dir: &AbsolutePathBuf, affected_profiles: &[AffectedProfile], ) -> Result { - if !std::io::stdin().is_terminal() { + if !vite_shared::is_stdin_terminal() { return Err(Error::UserMessage( "Cannot prompt for confirmation: stdin is not a TTY. Use --yes to skip confirmation." .into(), diff --git a/crates/vite_global_cli/src/help.rs b/crates/vite_global_cli/src/help.rs index 6741a3cafe..0f9416a2f2 100644 --- a/crates/vite_global_cli/src/help.rs +++ b/crates/vite_global_cli/src/help.rs @@ -1,6 +1,6 @@ //! Unified help rendering for the global CLI. -use std::{fmt::Write as _, io::IsTerminal}; +use std::fmt::Write as _; use clap::{CommandFactory, error::ErrorKind}; use owo_colors::OwoColorize; @@ -112,7 +112,7 @@ fn write_documentation_footer(output: &mut String, documentation_url: &str) { } pub fn should_style_help() -> bool { - std::io::stdout().is_terminal() + vite_shared::is_stdout_terminal() && std::env::var_os("NO_COLOR").is_none() && std::env::var("CLICOLOR").map_or(true, |value| value != "0") && std::env::var("TERM").map_or(true, |term| term != "dumb") diff --git a/crates/vite_global_cli/src/main.rs b/crates/vite_global_cli/src/main.rs index 1fdd658238..814a51c335 100644 --- a/crates/vite_global_cli/src/main.rs +++ b/crates/vite_global_cli/src/main.rs @@ -24,7 +24,7 @@ mod upgrade_check; use std::{ env, - io::{IsTerminal, Write}, + io::Write, process::{ExitCode, ExitStatus}, }; @@ -165,7 +165,7 @@ fn is_affirmative_response(input: &str) -> bool { } fn should_prompt_for_correction() -> bool { - std::io::stdin().is_terminal() && std::io::stderr().is_terminal() + vite_shared::is_stdin_terminal() && vite_shared::is_stderr_terminal() } fn prompt_to_run_suggested_command(suggestion: &str) -> bool { diff --git a/crates/vite_global_cli/src/shim/dispatch.rs b/crates/vite_global_cli/src/shim/dispatch.rs index 47776f7a31..769a4a3a57 100644 --- a/crates/vite_global_cli/src/shim/dispatch.rs +++ b/crates/vite_global_cli/src/shim/dispatch.rs @@ -231,7 +231,7 @@ fn check_npm_global_install_result( node_dir: &AbsolutePath, node_version: &str, ) { - use std::io::IsTerminal; + let Ok(bin_dir) = config::get_bin_dir() else { return }; @@ -249,7 +249,7 @@ fn check_npm_global_install_result( } } - let is_interactive = std::io::stdin().is_terminal(); + let is_interactive = vite_shared::is_stdin_terminal(); // (bin_name, source_path, package_name) let mut missing_bins: Vec<(String, AbsolutePathBuf, String)> = Vec::new(); let mut managed_conflicts: Vec<(String, String)> = Vec::new(); diff --git a/crates/vite_global_cli/src/upgrade_check.rs b/crates/vite_global_cli/src/upgrade_check.rs index d5fd005279..5d9f330c6a 100644 --- a/crates/vite_global_cli/src/upgrade_check.rs +++ b/crates/vite_global_cli/src/upgrade_check.rs @@ -4,10 +4,7 @@ //! result to `~/.vite-plus/.upgrade-check.json`. Displays a one-line notice on //! stderr when a newer version is available, at most once per 24 hours. -use std::{ - io::IsTerminal, - time::{SystemTime, UNIX_EPOCH}, -}; +use std::time::{SystemTime, UNIX_EPOCH}; use owo_colors::OwoColorize; use serde::{Deserialize, Serialize}; @@ -143,7 +140,7 @@ pub fn display_upgrade_notice(result: &UpgradeCheckResult) { /// Returns `false` for commands excluded by design, quiet modes, and /// machine-readable output flags (--silent, -s, --json, --parseable, --format json). pub fn should_run_for_command(args: &crate::cli::Args) -> bool { - if !cfg!(test) && !std::io::stderr().is_terminal() { + if !cfg!(test) && !vite_shared::is_stderr_terminal() { return false; } diff --git a/crates/vite_install/src/package_manager.rs b/crates/vite_install/src/package_manager.rs index e8966cefb4..abd7bc6671 100644 --- a/crates/vite_install/src/package_manager.rs +++ b/crates/vite_install/src/package_manager.rs @@ -2,7 +2,7 @@ use std::{ collections::HashMap, env, fmt, fs::{self, File}, - io::{self, BufReader, IsTerminal, Write}, + io::{self, BufReader, Write}, path::Path, }; @@ -1385,7 +1385,7 @@ fn prompt_package_manager_selection() -> Result { } // Check if stdin is a TTY (terminal) - if not, use default - if !io::stdin().is_terminal() { + if !vite_shared::is_stdin_terminal() { tracing::info!("Non-interactive environment detected. Using default package manager: pnpm"); return Ok(PackageManagerType::Pnpm); } diff --git a/crates/vite_js_runtime/src/download.rs b/crates/vite_js_runtime/src/download.rs index 5f28bf6f45..21b3949734 100644 --- a/crates/vite_js_runtime/src/download.rs +++ b/crates/vite_js_runtime/src/download.rs @@ -3,7 +3,7 @@ //! This module provides platform-agnostic utilities for downloading, //! verifying, and extracting runtime archives. -use std::{fs::File, io::IsTerminal, time::Duration}; +use std::{fs::File, time::Duration}; use backon::{ExponentialBuilder, Retryable}; use futures_util::StreamExt; @@ -45,7 +45,7 @@ pub async fn download_file( // across retry attempts; its position is reset at the start of every // attempt so a retried download doesn't double-count bytes. let is_ci = vite_shared::EnvConfig::get().is_ci; - let progress = if std::io::stderr().is_terminal() && !is_ci { + let progress = if vite_shared::is_stderr_terminal() && !is_ci { let pb = ProgressBar::new_spinner(); pb.set_style( ProgressStyle::default_spinner() diff --git a/crates/vite_setup/src/install.rs b/crates/vite_setup/src/install.rs index 879af4f4a5..ba20638f39 100644 --- a/crates/vite_setup/src/install.rs +++ b/crates/vite_setup/src/install.rs @@ -5,7 +5,7 @@ use std::{ env, - io::{Cursor, IsTerminal, Read as _, Write as _}, + io::{Cursor, Read as _, Write as _}, path::Path, process::{self, Output}, time::{SystemTime, UNIX_EPOCH}, @@ -132,7 +132,7 @@ fn is_affirmative_response(input: &str) -> bool { } fn should_prompt_release_age_override(silent: bool) -> bool { - !silent && std::io::stdin().is_terminal() && std::io::stderr().is_terminal() + !silent && vite_shared::is_stdin_terminal() && vite_shared::is_stderr_terminal() } fn prompt_release_age_override(version: &str) -> bool { diff --git a/crates/vite_shared/Cargo.toml b/crates/vite_shared/Cargo.toml index 62dbde59a6..3e426b6642 100644 --- a/crates/vite_shared/Cargo.toml +++ b/crates/vite_shared/Cargo.toml @@ -18,6 +18,7 @@ supports-color = "3" tracing = { workspace = true } tracing-subscriber = { workspace = true } vite_path = { workspace = true } +vite_powershell = { workspace = true } vite_str = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] diff --git a/crates/vite_shared/src/header.rs b/crates/vite_shared/src/header.rs index cf4163f0a1..a253269e22 100644 --- a/crates/vite_shared/src/header.rs +++ b/crates/vite_shared/src/header.rs @@ -9,7 +9,6 @@ //! - Gradient/fade generation and RGB ANSI coloring use std::{ - io::IsTerminal, sync::{LazyLock, OnceLock}, }; #[cfg(unix)] @@ -62,13 +61,11 @@ fn fg_rgb(color: Rgb) -> String { } fn should_colorize() -> bool { - let stdout = std::io::stdout(); - stdout.is_terminal() && on(Stream::Stdout).is_some() + crate::is_stdout_terminal() && on(Stream::Stdout).is_some() } fn supports_true_color() -> bool { - let stdout = std::io::stdout(); - stdout.is_terminal() && on(Stream::Stdout).is_some_and(|color| color.has_16m) + crate::is_stdout_terminal() && on(Stream::Stdout).is_some_and(|color| color.has_16m) } fn lerp(a: f64, b: f64, t: f64) -> f64 { @@ -190,7 +187,7 @@ fn parse_osc4_rgb(buffer: &str, index: u8) -> Option { fn is_osc_query_unsupported() -> bool { static UNSUPPORTED: OnceLock = OnceLock::new(); *UNSUPPORTED.get_or_init(|| { - if !std::io::stdout().is_terminal() || !std::io::stdin().is_terminal() { + if !crate::is_stdout_terminal() || !crate::is_stdin_terminal() { return true; } @@ -547,7 +544,7 @@ pub fn vite_plus_header() -> String { /// which is where `vp check --fix` typically runs. #[must_use] pub fn should_print_header() -> bool { - if !std::io::stdout().is_terminal() { + if !crate::is_stdout_terminal() { return false; } if std::env::var_os("GIT_INDEX_FILE").is_some() { diff --git a/crates/vite_shared/src/interactivity.rs b/crates/vite_shared/src/interactivity.rs index 7d195db5f1..fbb17c1c4a 100644 --- a/crates/vite_shared/src/interactivity.rs +++ b/crates/vite_shared/src/interactivity.rs @@ -1,6 +1,6 @@ //! Shared CI-environment and interactive-terminal detection. -use std::io::IsTerminal; +pub use vite_powershell::is_stdin_terminal; /// Common CI environment variables. const CI_ENV_VARS: &[&str] = &[ @@ -22,11 +22,33 @@ pub fn is_ci_environment() -> bool { CI_ENV_VARS.iter().any(|key| std::env::var_os(key).is_some()) } +/// Memoized `stdout` terminal check; the fd's TTY-ness cannot change +/// within a process. +#[expect(clippy::disallowed_methods, reason = "the memoizing wrapper itself")] +pub fn is_stdout_terminal() -> bool { + use std::{io::IsTerminal, sync::LazyLock}; + static IS_TTY: LazyLock = LazyLock::new(|| std::io::stdout().is_terminal()); + *IS_TTY +} + +/// Memoized `stderr` terminal check; the fd's TTY-ness cannot change +/// within a process. +#[expect(clippy::disallowed_methods, reason = "the memoizing wrapper itself")] +pub fn is_stderr_terminal() -> bool { + use std::{io::IsTerminal, sync::LazyLock}; + static IS_TTY: LazyLock = LazyLock::new(|| std::io::stderr().is_terminal()); + *IS_TTY +} + /// True when vp can show interactive prompts: stdin and stdout are terminals, /// the terminal is capable, and this is not a CI environment. +/// +/// The CI check is not redundant with the TTY checks: some CI systems run +/// commands in a PTY (Buildkite does by default), where an interactive prompt +/// would hang the job. pub fn is_interactive_terminal() -> bool { - std::io::stdin().is_terminal() - && std::io::stdout().is_terminal() + is_stdin_terminal() + && is_stdout_terminal() && std::env::var("TERM").map_or(true, |term| term != "dumb") && !is_ci_environment() } diff --git a/crates/vite_shared/src/lib.rs b/crates/vite_shared/src/lib.rs index 3f7b0173b3..dbdceb4f1a 100644 --- a/crates/vite_shared/src/lib.rs +++ b/crates/vite_shared/src/lib.rs @@ -25,7 +25,10 @@ mod tracing; pub use env_config::{EnvConfig, TestEnvGuard}; pub use error::format_error_chain; pub use home::{VP_BINARY_NAME, get_vp_home}; -pub use interactivity::{is_ci_environment, is_interactive_terminal}; +pub use interactivity::{ + is_ci_environment, is_interactive_terminal, is_stderr_terminal, is_stdin_terminal, + is_stdout_terminal, +}; pub use http::shared_http_client; pub use json_edit::{JsonStyle, edit_json_object, insert_after}; pub use package_json::{ diff --git a/docs/config/index.md b/docs/config/index.md index d7f1b25543..d54665244c 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -33,3 +33,16 @@ Vite+ extends the basic Vite configuration with these additions: - [`test`](/config/test) for Vitest - [`pack`](/config/pack) for tsdown - [`staged`](/config/staged) for staged-file checks +- [`defaultPackage`](#defaultpackage) for the default target of bare app commands at a workspace root + +## defaultPackage + +Default target directory for `vp dev` / `vp build` / `vp preview` / `vp pack` when they are invoked bare in the directory containing the config, an implicit [`vp -C `](/guide/monorepo#app-commands): + +```ts [vite.config.ts] +export default { + defaultPackage: './frontend', +}; +``` + +The value must be a static string literal: vp reads it without executing the config, so it also works at repository roots without a vite-plus install (for example a Laravel or Rails repo whose Vite app lives in `frontend/`). An explicit `-C` or positional target always wins over the config. diff --git a/docs/guide/monorepo.md b/docs/guide/monorepo.md index def5b9aa08..b93dbb1002 100644 --- a/docs/guide/monorepo.md +++ b/docs/guide/monorepo.md @@ -147,13 +147,18 @@ This keeps the behavior centralized while letting each team or package own the p The root `vite.config.ts` is most valuable for shared linting, formatting, staged checks, and task definitions. For project-specific development, build, and test behavior, use the setup that best matches each app: -- Pass a folder to built-in Vite commands when you want to target one app: +- Target one package with the global `-C` flag. It behaves exactly like `cd && vp ` and works with every vp command: ```bash -vp dev apps/web -vp build apps/web +vp -C apps/web dev +vp -C apps/web build +vp -C packages/ui pack ``` + Passing a folder as a positional (`vp dev apps/web`) still works and keeps upstream Vite semantics: it sets Vite's `root` option without changing the working directory, so cwd-relative reads in configs and plugins resolve from where you ran vp. Prefer `-C` when the package should behave as if you had `cd`'d into it. + +- Run a bare app command at the workspace root and vp resolves the target for you: with [`defaultPackage`](/config/#defaultpackage) configured it runs there, and otherwise it prints the workspace packages with `-C` hints instead of silently serving or building the root. + - Keep package-specific scripts in each package when the command differs per app: ```json [apps/api/package.json] diff --git a/packages/cli/binding/src/check/analysis.rs b/packages/cli/binding/src/check/analysis.rs index e02d26fb54..e421b5de1d 100644 --- a/packages/cli/binding/src/check/analysis.rs +++ b/packages/cli/binding/src/check/analysis.rs @@ -1,4 +1,3 @@ -use std::io::IsTerminal; use owo_colors::OwoColorize; use vite_shared::output; @@ -141,7 +140,7 @@ pub(super) fn print_stdout_block(block: &str) { pub(super) fn print_summary_line(message: &str) { output::raw(""); - if std::io::stdout().is_terminal() && message.contains('`') { + if vite_shared::is_stdout_terminal() && message.contains('`') { let mut formatted = String::with_capacity(message.len()); let mut segments = message.split('`'); if let Some(first) = segments.next() { diff --git a/packages/cli/binding/src/cli/execution.rs b/packages/cli/binding/src/cli/execution.rs index 5a5811b23d..9e32e411d5 100644 --- a/packages/cli/binding/src/cli/execution.rs +++ b/packages/cli/binding/src/cli/execution.rs @@ -1,4 +1,4 @@ -use std::{borrow::Cow, ffi::OsStr, io::IsTerminal, process::Stdio, sync::Arc}; +use std::{borrow::Cow, ffi::OsStr, process::Stdio, sync::Arc}; use rustc_hash::FxHashMap; use vite_error::Error; @@ -127,7 +127,7 @@ pub(crate) async fn resolve_and_capture_output( resolve_and_build_command(resolver, subcommand, resolved_vite_config, envs, cwd).await?; cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); - if force_color_if_terminal && std::io::stdout().is_terminal() { + if force_color_if_terminal && vite_shared::is_stdout_terminal() { cmd.env("FORCE_COLOR", "1"); } From 320550b35fbf8ac639c70a11360fff37d85a36d7 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Jul 2026 00:32:21 +0800 Subject: [PATCH 04/33] rfc: global -C flag for working-directory switching --- rfcs/cwd-flag.md | 371 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 371 insertions(+) create mode 100644 rfcs/cwd-flag.md diff --git a/rfcs/cwd-flag.md b/rfcs/cwd-flag.md new file mode 100644 index 0000000000..f7c2719231 --- /dev/null +++ b/rfcs/cwd-flag.md @@ -0,0 +1,371 @@ +# RFC: Global `-C` Flag for Working-Directory Switching + +## Summary + +Add a global `-C ` flag to vp, then use it to fix the app-command experience in monorepos. Everything is additive and backward compatible: + +1. **`-C ` global flag** (the feature): for every vp command, `vp -C ` behaves exactly like `cd && vp `, following the `git -C` / `make -C` convention: the first-class "run there" form vp currently lacks. Positional semantics stay untouched: `vp dev ` keeps upstream Vite semantics (`root` only), and `vp pack` positionals stay tsdown entries. +2. **App-command UX built on `-C`**: running `vp dev` / `vp build` / `vp preview` / `vp pack` bare at a workspace root elicits the missing target instead of silently running against the root: an interactive fuzzy package picker in a TTY, a `defaultPackage` config for repos with one blessed target, and a clear listing plus exit 1 when non-interactive. All three are defined as an implicit `-C `. + +In the common flows users never type `-C`: bare `vp dev` at the root goes through the picker or `defaultPackage`, and inside a package it runs there as today. `-C` is the explicit, teachable form underneath. The commands stay singular: `vp dev` still starts exactly one Vite dev server in one directory. Running a task across many packages at once remains the job of `vp run` (`-r`, `--filter`). + +## Motivation + +### Current Pain Points + +**1. vp has no first-class "run this command in that directory" form.** + +For `dev`/`build`/`preview`, the positional is forwarded verbatim to Vite's `[root]`, which re-bases config lookup and `.env` loading, but `process.cwd()` of the Vite process stays at the invocation directory. Any cwd-relative read in a config or plugin diverges even though `root` points at the right app: + +```ts +// apps/admin/vite.config.ts +const cert = fs.readFileSync(path.resolve('certs/dev.pem')) // cwd-relative +``` + +``` +$ cd apps/admin && vp dev # cwd = apps/admin, cert found + + VITE+ v0.2.2 + + ➜ Local: http://localhost:5173/ + +$ vp dev apps/admin # root is right, cwd is still the repo root +failed to load config from /acme/apps/admin/vite.config.ts +error when starting dev server: +Error: ENOENT: no such file or directory, open '/acme/certs/dev.pem' +``` + +For `pack`, directories do not work at all: the positional means entry files/globs (`packages/cli/src/pack-bin.ts`) and config always resolves from `process.cwd()`: + +``` +$ vp pack packages/ui +ℹ entry: packages/ui +ℹ Build start +error: Build failed with 1 error: + +[UNRESOLVED_ENTRY] Cannot resolve entry module packages/ui. +``` + +So the only form that works reliably, uniformly, for every command, is `cd && vp `, and vp offers no flag equivalent of it. + +**2. At a monorepo root, the app commands are silently wrong.** + +The workspace root usually has no app, but `vp dev` happily starts a server pointed at it: + +``` +$ vp dev + + VITE+ v0.2.2 + + ➜ Local: http://localhost:5173/ # opens to a 404, no index.html here +``` + +Nothing errors, nothing guides the user toward the right invocation, and the server exposes the whole repository tree. Fixing this requires eliciting a target, and eliciting a target requires a well-defined primitive to expand to. `-C` is that primitive. + +All of the failures above are reproducible with `vite-plus@0.2.2`: https://github.com/why-reproductions-are-required/vite-plus-monorepo-app-commands-repro + +## Proposed UX + +Example workspace used throughout: + +``` +acme/ +├── pnpm-workspace.yaml +├── vite.config.ts +├── apps/web (Vite app) +├── apps/admin (Vite app) +├── packages/ui (library) +└── packages/utils (library) +``` + +### 1. `-C`: run any vp command in another directory + +These do the same thing, byte for byte: + +```bash +vp -C apps/admin dev +cd apps/admin && vp dev +``` + +It is not limited to the app commands: + +```bash +vp -C apps/web test +vp -C apps/web run build +``` + +Args after the subcommand pass through unchanged: + +``` +$ vp -C apps/admin dev --port 4000 + + VITE+ v0.2.2 + + ➜ Local: http://localhost:4000/ +``` + +And `-C` gives pack its missing directory form: + +``` +$ vp -C packages/ui pack +ℹ entry: src/index.ts +ℹ Build start +ℹ dist/index.mjs 0.10 kB │ gzip: 0.11 kB +✔ Build complete in 9ms +``` + +`vp dev apps/admin` (the positional) is untouched: `root` is set, cwd is not, so the pain point 1 pitfall remains on that form (see Decisions). + +### 2. `vp dev` at the workspace root (interactive terminal) + +Same look and keybindings as the `vp run` task selector, listing packages instead of tasks: + +``` +$ vp dev +Select a package to dev (↑/↓, Enter to run, type to search): + + › web apps/web + admin apps/admin + ui packages/ui + utils packages/utils +``` + +Typing filters fuzzily, with the query shown inline: + +``` +Select a package to dev (↑/↓, Enter to run, type to search): adm + + › admin apps/admin +``` + +Enter confirms, prints the teaching hint once, then runs as an implicit `-C`: + +``` +Selected package: admin (apps/admin) +Tip: run this directly with `vp -C apps/admin dev` + + VITE+ v0.2.2 + + ➜ Local: http://localhost:5173/ + ➜ Network: use --host to expose +``` + +Escape clears the search, Ctrl+C cancels with exit code 130 and runs nothing (matching the task picker). `vp build`, `vp preview`, and `vp pack` at the root look the same, with `Select a package to build` / `preview` / `pack`. + +### 3. Non-interactive at the root (CI, piped output, scripts) + +No picker can appear, so the command fails fast with the same information the picker would have shown: + +``` +$ vp build +✗ `vp build` at the workspace root needs a target package. + + Packages in this workspace: + web apps/web + admin apps/admin + ui packages/ui + utils packages/utils + + Pass a directory: vp -C apps/web build + Or run every package's build script: vp run -r build + +$ echo $? +1 +``` + +### 4. With `defaultPackage` configured + +The motivating repo shape is a framework monorepo where the Vite app lives in a subdirectory of a repo that is not a JS workspace at all, for example a Laravel, Rails, or Go server with a `frontend/` directory: + +``` +shop/ +├── app/ (PHP / Ruby / Go) +├── routes/ +├── composer.json +├── vite.config.ts (root config below) +└── frontend/ (the Vite app) +``` + +There is no `pnpm-workspace.yaml` or `workspaces` field to enumerate, so the picker cannot serve this shape. `defaultPackage` can: + +```ts +// vp reads this key via static extraction and never executes this file, so +// the missing vite-plus install at this root is fine. Vite never loads this +// config either; at this root it is purely a pointer for vp. +export default { + defaultPackage: './frontend', +} +``` + +Bare app commands at the root now behave as `vp -C ./frontend `, with one line of output so it never feels magical: + +``` +$ vp dev +vp dev: using ./frontend (defaultPackage) + + VITE+ v0.2.2 + + ➜ Local: http://localhost:5173/ +``` + +An explicit `-C` still wins: `vp -C apps/admin dev` ignores `defaultPackage`. + +### 5. Inside a sub-package: nothing changes + +``` +$ cd apps/web +$ vp dev + + VITE+ v0.2.2 + + ➜ Local: http://localhost:5173/ +``` + +No picker ever appears below the root. + +## Command Syntax + +``` +vp [-C ] [args...] +``` + +### The `-C ` global flag + +- A vp-global flag, parsed before the subcommand like `git -C` / `make -C`, never forwarded to the underlying tool. It works with every vp command. +- Semantics: run the command exactly as if invoked in ``. The directory is resolved against the invocation cwd; a missing directory errors with `directory not found`. +- The name follows pnpm: its global `-C ` is documented as "Run as if pnpm was started in `` instead of the current working directory", which is this flag's semantics verbatim. Short form only in v1 (git style); pnpm's `--dir` long alias can be added compatibly later if wanted. +- Because it sits before the subcommand, it cannot collide with Vite or tsdown flags, present or future. The subcommands themselves gain zero flags. + +### Positionals and forwarded args: unchanged + +Everything after the subcommand is forwarded verbatim, exactly as today. `vp dev ` keeps upstream Vite semantics (positional = `root` option), `vp pack` positionals stay tsdown entries, and relative option values keep resolving against the process cwd (the invocation directory, or `` under `-C`). There is no directory-vs-entry disambiguation anywhere. + +Rejected alternatives: repurposing the app-command positional to mean "run there" (breaks Vite CLI parity; see Decisions), a per-command picker-forcing flag, and `-F`-style name filters (`-F` already has other meanings on `pack` and `run`/`exec`). + +## Behavior + +### Target directory resolution + +An app command invocation is **bare** when it has no `-C` and no positional target (no Vite `[root]`, no pack entries); flags alone keep it bare. For `vp dev` / `build` / `preview` / `pack`, the target directory is resolved in this order: + +1. **`-C `**: run there. Never triggers the picker. +2. **Positional target present**: forward as today, upstream semantics, vp does not interfere. +3. **`defaultPackage`**, when bare in the directory containing the root config (a workspace root, or the root of a non-workspace repo): implicit `-C`, print a one-line note. +4. **Interactive picker**, when bare at the workspace root in an interactive TTY (and not CI): pick, print hint, run as implicit `-C`. +5. **Non-interactive and bare at the workspace root**: print the package list and the `-C` hint, exit 1. +6. **Anywhere else**: current behavior, run in the current directory. + +"Workspace root" means the current directory's package is the workspace root package, as determined by `vite_workspace::find_workspace_root` (already called on every invocation in `packages/cli/binding/src/cli/mod.rs`). + +### Equivalence invariant + +For every vp command: + +``` +vp -C [args...] === cd && vp [args...] +``` + +The child's spawn cwd is ``, so config lookup, `.env` loading, `process.cwd()` reads in configs and plugins, and relative CLI args all behave as if the user had `cd`'d. The Rust layers never mutate their own cwd; the one exception is the local `vp` bin, which applies `-C` by changing its process cwd at startup before any dispatch, indistinguishable from having been started in ``. + +The global binary also resolves the local `vite-plus` install from ``, matching `cd` exactly; through the package's own `vp` bin the executing CLI is already chosen, so there the invariant assumes a single Vite+ version per workspace (the supported monorepo model). + +### Entry points and version assumption + +- `-C` is parsed by both the global binary and the local bin; the picker and `defaultPackage` live in the local CLI's NAPI binding (`execute_direct_subcommand`), which every entry point executes. Bare `vp dev` at a root is primarily a global-CLI experience, but a root-level `"dev": "vp dev"` script flows through the same logic. +- Pre-1.0, both the global CLI and any local install are assumed to ship this feature; no version negotiation with older CLIs is specified. In the non-workspace shape the root has no local install, so the bundled CLI executes end to end, which is equivalent under this assumption. + +### Picker contents + +- One row per workspace package: name plus relative path. Nothing is filtered out; packages that look runnable for the command (`vite.config.*` or `index.html` for `dev`/`build`/`preview`, a pack config or library entry for `pack`) rank first, then by path, so apps surface at the top while everything stays searchable. +- Fuzzy search over name and path via `vite_select::fuzzy_match`, paging identical to the task picker. +- A runnable workspace root appears as a `(workspace root)` entry, keeping today's "run at root" behavior one keystroke away. +- With exactly one likely-runnable package, the picker auto-selects it, printing only the `Selected package:` line and the tip. + +### `defaultPackage` config + +```ts +export default defineConfig({ + // Relative to the config file's directory. Used by vp dev/build/preview/pack + // when invoked bare next to this config: an implicit -C. + defaultPackage: './frontend', +}) +``` + +- Type: `string`, a single directory. A per-command map can come later if real demand appears. +- Consulted when a bare app command runs in the directory containing the root config: a workspace root, or a non-workspace repo root. The non-workspace shape has no package list, so `defaultPackage` is the only mechanism that covers it. An explicit `-C` always wins. +- A missing directory errors: `defaultPackage points to a missing directory: ./frontend`. +- Read via static extraction (`vite_static_config` + the loader in `packages/cli/binding/src/cli/handler.rs`), like `run` config. At a non-workspace root there is no install to execute the config, so the file must work unexecuted: a plain default-export object with a static string value. If extraction fails and no local install can execute the config, vp errors and names the offending construct. + +## Decisions + +### Vite CLI parity preserved; `-C` carries the `cd` semantics + +The core tension: parity with `vite ` (positional sets `root` only, cwd untouched) and parity with `cd && vp dev` cannot both hold on the same positional. An earlier draft repurposed the positional and accepted a permanent divergence from the upstream CLI. This RFC keeps the positional fully Vite-compatible and puts the `cd` semantics on a new, explicitly named channel instead. Nothing existing changes meaning, pack needs no directory-vs-entry heuristic, and the primitive generalizes to every vp command instead of four. + +The accepted cost: two ways to pass a directory with different semantics. Mitigation: users rarely type either (bare `vp dev` plus picker or `defaultPackage` covers the common flows), and every hint, error, and doc teaches only `-C`. + +Mechanism: `process.chdir()` in the CLI process was rejected as a global mutation that leaks into everything sharing the process. vp is a launcher: the NAPI binding always spawns the tool as a fresh child (`packages/cli/binding/src/cli/execution.rs`), so the child's spawn cwd is free to set, with no upstream change. + +### Root-only interactivity + +Below the root the cwd already identifies the project, so prompting would be noise. At the root the command is ambiguous and silently wrong today; that is where a prompt earns its keep. Unlike bare `vp run`'s informational listing, the app commands exit 1 when non-interactive, because building or serving the wrong directory is worse than failing loudly. + +### Elicitation scope + +`-C` is global and works with every command. The elicitation behaviors (picker, `defaultPackage`, root error) apply only to the single-target app commands, because only they are ambiguous at the root. Tree-scoped commands (`test`, `lint`, `fmt`, `check`) mean "the whole repo" there, which is their desired behavior. Workspace-state commands (`install`, `add`, `outdated`, ...) have the root as their natural home. Orchestrators (`run`, `exec`) own their selection models and remain the way to run one task across many packages. A future command joins the elicitation set exactly when its subject is one package directory. + +## Implementation Architecture + +All changes live in the Rust layers; no upstream Vite or tsdown changes are required. + +- `crates/vite_global_cli/src/cli.rs`: parse the global `-C `; resolve the local install from `` and delegate with `` as the effective cwd. +- `packages/cli/binding/src/cli/types.rs` / `mod.rs`: parse `-C` on the local bin path; in `execute_direct_subcommand`, add the bare-invocation resolution order (workspace-root detection already happens here). +- `packages/cli/binding/src/cli/execution.rs`: spawn the child with cwd set to the target directory. +- Picker: reuse `vite_select` and `vite_workspace`, both already dependencies via the `vite_task` crates. +- `defaultPackage`: extend the `VitePlusConfigLoader` static extraction the same way `run` config is loaded, and add `defaultPackage?: string` to `packages/cli/src/define-config.ts`. +- `packages/cli/src/pack-bin.ts` needs no change: positional handling is untouched and `-C` never reaches it. +- Docs: a `-C` entry in the global CLI docs, `docs/guide/monorepo.md` "App Commands", and a `docs/config/` page for the new key. + +## Compatibility + +Every existing invocation is unchanged. The only behavior change is the bare app command at a workspace root, which goes from "silently serve or build the root" to picker / config / clear error. A runnable root stays available as a picker entry, and `defaultPackage: '.'` restores the old behavior unconditionally. + +## Snap Tests + +Non-interactive branches are covered by snap tests: + +- `vp -C build` / `vp -C pack` / `vp -C run `, plus `-C` with a missing directory. +- Parity regression: `vp dev ` still forwards the positional as Vite `root` with cwd untouched. +- Bare app commands at a workspace root without a TTY: package listing and exit code. +- `defaultPackage`: happy path and missing-directory error. +- Equivalence checks: `vp -C build` and `cd && vp build` produce the same output in a fixture whose config reads `process.cwd()`. + +The interactive picker gets pty snapshot coverage in the `vite_task` repo style (`task_select` fixtures) if the picker lands near `vite_select`, or manual verification via tmux-driven interactive runs otherwise. + +## Open Questions + +1. Does ranking plus search suffice, or is outright filtering of non-runnable packages ever wanted? +2. Add a `VP_DEFAULT_PACKAGE` env override later? Env companions are an established pattern (`NX_DEFAULT_PROJECT`); deferred from v1. +3. Should `vp test` join the elicitation set? Probably not: Vitest already has first-class `projects` semantics at the root (`-C` works with it regardless). +4. Exact non-interactive gate: the `vp run` picker's TTY check plus the `CI` check used by the global command picker? +5. Should `vp dev ` print a one-line tip pointing at `vp -C dev`, or would that be noise on a fully supported upstream form? + +## Appendix: Naming Survey for `defaultPackage` + +How comparable tools name "the member a root-level command targets when none is specified": + +| Tool | Field | Notes | +| --- | --- | --- | +| Ionic CLI | `defaultProject` | active; root config with a `projects` map | +| Nx | `defaultProject` | deprecated in favor of `NX_DEFAULT_PROJECT` env var | +| Angular CLI | `defaultProject` | deprecated in favor of cwd inference | +| Cargo | `workspace.default-members` | plural: root `cargo build` builds all listed members | +| Salesforce DX | `default: true` on the member | marker pattern; needs member enumeration | +| Vercel / Netlify / Amplify | `rootDirectory` / `base` / `appRoot` | per-app deploy config, not a default among many | +| GitHub Actions | `defaults.run.working-directory` | names the mechanism (cwd) | + +The pattern is `default` plus the tool's own noun for the unit: Angular, Nx, and Ionic say "project", Cargo says "members", Salesforce says "package directories". vp's noun is "package" (the picker, `vp run` docs, `vite_workspace`, pnpm vocabulary), hence `defaultPackage`. + +Rejected: `defaultProject` (collides with Vitest `test.projects`, and the picker says "package"), `defaultWorkspace` ("workspace" means the whole monorepo in vp/pnpm vocabulary), `defaultMembers` (plural, implies running in many packages; meaningless without a workspace), `appRoot`/`rootDirectory`/`base` (collide with Vite's `root`/`base` options), member markers (need enumeration, impossible without workspace metadata). The Angular and Nx deprecations do not transfer: cwd inference is built into the resolution order, and per-environment flexibility is open question 2. + +The `-C` scheme does not change this conclusion. Tools with `-C`-style flags (git, make, tar, ninja, terraform, pnpm, yarn, bun) ship the flag with no config-file default at all, and tools that do have a directory config name it after the mechanism precisely because it applies to everything they run (just's `set working-directory`, GitHub Actions' `defaults.run.working-directory`, per-task `cwd` in vp's own `run.tasks`). `defaultPackage` is neither: it selects a member, only for the app commands, only when bare at the root. A mechanism name like `defaultCwd` or `defaultDir` would promise vp-wide effect it does not have; the member-selection name matches its member-selection scope. From 859b6f1cdb5b493923df5d86d37306f2f462f153 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Jul 2026 00:50:47 +0800 Subject: [PATCH 05/33] fix(snap): sort vite_shared exports, show raw error output in root fixtures - cargo fmt: alphabetize the interactivity module/exports in vite_shared (the Lint CI failure) and reflow touched files - drop `|| echo` from the app-root-listing, default-package-missing, and cwd-flag fixtures so the snapshots capture the real exit code (via the runner's [N] prefix) and full error output directly --- .../src/commands/env/list_remote.rs | 1 - .../vite_global_cli/src/commands/env/pin.rs | 5 +--- .../src/commands/global/mod.rs | 8 +------ .../vite_global_cli/src/commands/implode.rs | 5 +--- crates/vite_global_cli/src/shim/dispatch.rs | 2 -- crates/vite_shared/src/header.rs | 4 +--- crates/vite_shared/src/lib.rs | 4 ++-- docs/guide/monorepo.md | 2 +- packages/cli/binding/src/check/analysis.rs | 1 - packages/cli/binding/src/cli/app_target.rs | 4 ++-- .../command-app-root-listing/snap.txt | 6 ++--- .../command-app-root-listing/steps.json | 4 ++-- .../cli/snap-tests/command-cwd-flag/snap.txt | 3 +-- .../snap-tests/command-cwd-flag/steps.json | 2 +- .../command-default-package-missing/snap.txt | 3 +-- .../steps.json | 4 +--- rfcs/cwd-flag.md | 24 +++++++++---------- 17 files changed, 29 insertions(+), 53 deletions(-) diff --git a/crates/vite_global_cli/src/commands/env/list_remote.rs b/crates/vite_global_cli/src/commands/env/list_remote.rs index d8a6c8862a..fa20b7c21e 100644 --- a/crates/vite_global_cli/src/commands/env/list_remote.rs +++ b/crates/vite_global_cli/src/commands/env/list_remote.rs @@ -117,7 +117,6 @@ fn strip_v(version: &str) -> &str { /// Whether colored output should be emitted on stdout. fn use_color() -> bool { - vite_shared::is_stdout_terminal() && std::env::var_os("NO_COLOR").is_none() } diff --git a/crates/vite_global_cli/src/commands/env/pin.rs b/crates/vite_global_cli/src/commands/env/pin.rs index c795151c78..cd3f76c225 100644 --- a/crates/vite_global_cli/src/commands/env/pin.rs +++ b/crates/vite_global_cli/src/commands/env/pin.rs @@ -7,10 +7,7 @@ //! directory has no package.json. An explicit `--target` flag overrides the selection. //! An existing `engines.node` is never deleted or modified. -use std::{ - io::Write, - process::ExitStatus, -}; +use std::{io::Write, process::ExitStatus}; use vite_js_runtime::NodeProvider; use vite_path::AbsolutePathBuf; diff --git a/crates/vite_global_cli/src/commands/global/mod.rs b/crates/vite_global_cli/src/commands/global/mod.rs index e4ec789a73..01eb8ef142 100644 --- a/crates/vite_global_cli/src/commands/global/mod.rs +++ b/crates/vite_global_cli/src/commands/global/mod.rs @@ -1,12 +1,6 @@ //! Managed global package utilities. -use std::{ - collections::HashMap, - fs::File, - io::Read, - process::Stdio, - time::Duration, -}; +use std::{collections::HashMap, fs::File, io::Read, process::Stdio, time::Duration}; use flate2::read::GzDecoder; use futures::{StreamExt, stream::FuturesUnordered}; diff --git a/crates/vite_global_cli/src/commands/implode.rs b/crates/vite_global_cli/src/commands/implode.rs index dcee246be9..fb7c563d38 100644 --- a/crates/vite_global_cli/src/commands/implode.rs +++ b/crates/vite_global_cli/src/commands/implode.rs @@ -1,9 +1,6 @@ //! `vp implode` — completely remove vp and all its data from this system. -use std::{ - io::Write, - process::ExitStatus, -}; +use std::{io::Write, process::ExitStatus}; use directories::BaseDirs; use owo_colors::OwoColorize; diff --git a/crates/vite_global_cli/src/shim/dispatch.rs b/crates/vite_global_cli/src/shim/dispatch.rs index 769a4a3a57..f2516466eb 100644 --- a/crates/vite_global_cli/src/shim/dispatch.rs +++ b/crates/vite_global_cli/src/shim/dispatch.rs @@ -231,8 +231,6 @@ fn check_npm_global_install_result( node_dir: &AbsolutePath, node_version: &str, ) { - - let Ok(bin_dir) = config::get_bin_dir() else { return }; // Derive bin dir from prefix (Unix: prefix/bin, Windows: prefix itself) diff --git a/crates/vite_shared/src/header.rs b/crates/vite_shared/src/header.rs index a253269e22..f4ec220b32 100644 --- a/crates/vite_shared/src/header.rs +++ b/crates/vite_shared/src/header.rs @@ -8,9 +8,7 @@ //! - Stream-based response parsing (modelled after `terminal-colorsaurus`) //! - Gradient/fade generation and RGB ANSI coloring -use std::{ - sync::{LazyLock, OnceLock}, -}; +use std::sync::{LazyLock, OnceLock}; #[cfg(unix)] use std::{ io::Write, diff --git a/crates/vite_shared/src/lib.rs b/crates/vite_shared/src/lib.rs index dbdceb4f1a..a3823629ef 100644 --- a/crates/vite_shared/src/lib.rs +++ b/crates/vite_shared/src/lib.rs @@ -11,9 +11,9 @@ mod env_config; pub mod env_vars; mod error; pub mod header; -mod interactivity; mod home; mod http; +mod interactivity; mod json_edit; pub mod output; mod package_json; @@ -25,11 +25,11 @@ mod tracing; pub use env_config::{EnvConfig, TestEnvGuard}; pub use error::format_error_chain; pub use home::{VP_BINARY_NAME, get_vp_home}; +pub use http::shared_http_client; pub use interactivity::{ is_ci_environment, is_interactive_terminal, is_stderr_terminal, is_stdin_terminal, is_stdout_terminal, }; -pub use http::shared_http_client; pub use json_edit::{JsonStyle, edit_json_object, insert_after}; pub use package_json::{ DevEngineDependency, DevEngineField, DevEngines, Engines, OnFail, PackageJson, dev_engine_entry, diff --git a/docs/guide/monorepo.md b/docs/guide/monorepo.md index b93dbb1002..09498da054 100644 --- a/docs/guide/monorepo.md +++ b/docs/guide/monorepo.md @@ -155,7 +155,7 @@ vp -C apps/web build vp -C packages/ui pack ``` - Passing a folder as a positional (`vp dev apps/web`) still works and keeps upstream Vite semantics: it sets Vite's `root` option without changing the working directory, so cwd-relative reads in configs and plugins resolve from where you ran vp. Prefer `-C` when the package should behave as if you had `cd`'d into it. +Passing a folder as a positional (`vp dev apps/web`) still works and keeps upstream Vite semantics: it sets Vite's `root` option without changing the working directory, so cwd-relative reads in configs and plugins resolve from where you ran vp. Prefer `-C` when the package should behave as if you had `cd`'d into it. - Run a bare app command at the workspace root and vp resolves the target for you: with [`defaultPackage`](/config/#defaultpackage) configured it runs there, and otherwise it prints the workspace packages with `-C` hints instead of silently serving or building the root. diff --git a/packages/cli/binding/src/check/analysis.rs b/packages/cli/binding/src/check/analysis.rs index e421b5de1d..da42c7f2d1 100644 --- a/packages/cli/binding/src/check/analysis.rs +++ b/packages/cli/binding/src/check/analysis.rs @@ -1,4 +1,3 @@ - use owo_colors::OwoColorize; use vite_shared::output; diff --git a/packages/cli/binding/src/cli/app_target.rs b/packages/cli/binding/src/cli/app_target.rs index 77206cab90..001461793f 100644 --- a/packages/cli/binding/src/cli/app_target.rs +++ b/packages/cli/binding/src/cli/app_target.rs @@ -120,8 +120,8 @@ pub(super) fn resolve_app_target( return Ok(AppTarget::CurrentDir); } - let graph = vite_workspace::load_package_graph(&workspace_root) - .map_err(|e| Error::Anyhow(e.into()))?; + let graph = + vite_workspace::load_package_graph(&workspace_root).map_err(|e| Error::Anyhow(e.into()))?; let mut rows: Vec = graph .node_weights() .filter(|info| !info.path.as_str().is_empty()) diff --git a/packages/cli/snap-tests/command-app-root-listing/snap.txt b/packages/cli/snap-tests/command-app-root-listing/snap.txt index 7cd75deb38..c4d62a280f 100644 --- a/packages/cli/snap-tests/command-app-root-listing/snap.txt +++ b/packages/cli/snap-tests/command-app-root-listing/snap.txt @@ -1,4 +1,4 @@ -> vp build || echo 'exited non-zero' # bare build at the workspace root lists packages instead of building the root +[1]> vp build # bare build at the workspace root lists packages instead of building the root error: `vp build` at the workspace root needs a target package. Packages in this workspace: @@ -7,9 +7,8 @@ error: `vp build` at the workspace root needs a target package. Pass a directory: vp -C packages/web build Or run every package's build script: vp run -r build -exited non-zero -> vp dev || echo 'exited non-zero' # bare dev at the root no longer starts a server against the root +[1]> vp dev # bare dev at the root no longer starts a server against the root error: `vp dev` at the workspace root needs a target package. Packages in this workspace: @@ -18,4 +17,3 @@ error: `vp dev` at the workspace root needs a target package. Pass a directory: vp -C packages/web dev Or run every package's dev script: vp run -r dev -exited non-zero diff --git a/packages/cli/snap-tests/command-app-root-listing/steps.json b/packages/cli/snap-tests/command-app-root-listing/steps.json index 3ee5bf62d0..228092b98d 100644 --- a/packages/cli/snap-tests/command-app-root-listing/steps.json +++ b/packages/cli/snap-tests/command-app-root-listing/steps.json @@ -1,6 +1,6 @@ { "commands": [ - "vp build || echo 'exited non-zero' # bare build at the workspace root lists packages instead of building the root", - "vp dev || echo 'exited non-zero' # bare dev at the root no longer starts a server against the root" + "vp build # bare build at the workspace root lists packages instead of building the root", + "vp dev # bare dev at the root no longer starts a server against the root" ] } diff --git a/packages/cli/snap-tests/command-cwd-flag/snap.txt b/packages/cli/snap-tests/command-cwd-flag/snap.txt index d67e34d1f0..2fea02d053 100644 --- a/packages/cli/snap-tests/command-cwd-flag/snap.txt +++ b/packages/cli/snap-tests/command-cwd-flag/snap.txt @@ -26,9 +26,8 @@ cwd base: hello cwd base: hello -> vp -C packages/missing build || echo 'errored as expected' # missing directory errors +[1]> vp -C packages/missing build # missing directory errors error: directory not found: packages/missing -errored as expected > vp pack packages/hello # positional stays a tsdown entry resolved from the invocation directory ℹ entry: packages/hello diff --git a/packages/cli/snap-tests/command-cwd-flag/steps.json b/packages/cli/snap-tests/command-cwd-flag/steps.json index fc23fe99ec..c4a2d56215 100644 --- a/packages/cli/snap-tests/command-cwd-flag/steps.json +++ b/packages/cli/snap-tests/command-cwd-flag/steps.json @@ -5,7 +5,7 @@ "cd packages/hello && vp pack # the cd form is equivalent", "vp -C packages/hello run where # -C applies to vp run as well", "cd packages/hello && vp run where # equivalent cd form for run", - "vp -C packages/missing build || echo 'errored as expected' # missing directory errors", + "vp -C packages/missing build # missing directory errors", "vp pack packages/hello # positional stays a tsdown entry resolved from the invocation directory", "ls dist # upstream semantics: output lands at the invocation directory, not in the package" ] diff --git a/packages/cli/snap-tests/command-default-package-missing/snap.txt b/packages/cli/snap-tests/command-default-package-missing/snap.txt index 89cc6b2769..c9757b7117 100644 --- a/packages/cli/snap-tests/command-default-package-missing/snap.txt +++ b/packages/cli/snap-tests/command-default-package-missing/snap.txt @@ -1,3 +1,2 @@ -> vp build || echo 'errored as expected' # defaultPackage pointing at a missing directory errors +[1]> vp build # defaultPackage pointing at a missing directory errors error: defaultPackage points to a missing directory: ./packages/nope -errored as expected diff --git a/packages/cli/snap-tests/command-default-package-missing/steps.json b/packages/cli/snap-tests/command-default-package-missing/steps.json index e8d67be235..7624bca898 100644 --- a/packages/cli/snap-tests/command-default-package-missing/steps.json +++ b/packages/cli/snap-tests/command-default-package-missing/steps.json @@ -1,5 +1,3 @@ { - "commands": [ - "vp build || echo 'errored as expected' # defaultPackage pointing at a missing directory errors" - ] + "commands": ["vp build # defaultPackage pointing at a missing directory errors"] } diff --git a/rfcs/cwd-flag.md b/rfcs/cwd-flag.md index f7c2719231..9b69124230 100644 --- a/rfcs/cwd-flag.md +++ b/rfcs/cwd-flag.md @@ -19,7 +19,7 @@ For `dev`/`build`/`preview`, the positional is forwarded verbatim to Vite's `[ro ```ts // apps/admin/vite.config.ts -const cert = fs.readFileSync(path.resolve('certs/dev.pem')) // cwd-relative +const cert = fs.readFileSync(path.resolve('certs/dev.pem')); // cwd-relative ``` ``` @@ -194,7 +194,7 @@ There is no `pnpm-workspace.yaml` or `workspaces` field to enumerate, so the pic // config either; at this root it is purely a pointer for vp. export default { defaultPackage: './frontend', -} +}; ``` Bare app commands at the root now behave as `vp -C ./frontend `, with one line of output so it never feels magical: @@ -288,7 +288,7 @@ export default defineConfig({ // Relative to the config file's directory. Used by vp dev/build/preview/pack // when invoked bare next to this config: an implicit -C. defaultPackage: './frontend', -}) +}); ``` - Type: `string`, a single directory. A per-command map can come later if real demand appears. @@ -354,15 +354,15 @@ The interactive picker gets pty snapshot coverage in the `vite_task` repo style How comparable tools name "the member a root-level command targets when none is specified": -| Tool | Field | Notes | -| --- | --- | --- | -| Ionic CLI | `defaultProject` | active; root config with a `projects` map | -| Nx | `defaultProject` | deprecated in favor of `NX_DEFAULT_PROJECT` env var | -| Angular CLI | `defaultProject` | deprecated in favor of cwd inference | -| Cargo | `workspace.default-members` | plural: root `cargo build` builds all listed members | -| Salesforce DX | `default: true` on the member | marker pattern; needs member enumeration | -| Vercel / Netlify / Amplify | `rootDirectory` / `base` / `appRoot` | per-app deploy config, not a default among many | -| GitHub Actions | `defaults.run.working-directory` | names the mechanism (cwd) | +| Tool | Field | Notes | +| -------------------------- | ------------------------------------ | ---------------------------------------------------- | +| Ionic CLI | `defaultProject` | active; root config with a `projects` map | +| Nx | `defaultProject` | deprecated in favor of `NX_DEFAULT_PROJECT` env var | +| Angular CLI | `defaultProject` | deprecated in favor of cwd inference | +| Cargo | `workspace.default-members` | plural: root `cargo build` builds all listed members | +| Salesforce DX | `default: true` on the member | marker pattern; needs member enumeration | +| Vercel / Netlify / Amplify | `rootDirectory` / `base` / `appRoot` | per-app deploy config, not a default among many | +| GitHub Actions | `defaults.run.working-directory` | names the mechanism (cwd) | The pattern is `default` plus the tool's own noun for the unit: Angular, Nx, and Ionic say "project", Cargo says "members", Salesforce says "package directories". vp's noun is "package" (the picker, `vp run` docs, `vite_workspace`, pnpm vocabulary), hence `defaultPackage`. From 2060d0a95902e7e0e6ea39b29b83a0cfbfac2b0f Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Jul 2026 00:59:17 +0800 Subject: [PATCH 06/33] docs(monorepo): document the workspace-root app-command experience Expand the App Commands section into subsections with real terminal output: single-app auto-select, the multi-app listing with -C hints, -C targeting, and defaultPackage (including the non-workspace framework case). --- docs/guide/monorepo.md | 69 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 6 deletions(-) diff --git a/docs/guide/monorepo.md b/docs/guide/monorepo.md index 09498da054..766f62f885 100644 --- a/docs/guide/monorepo.md +++ b/docs/guide/monorepo.md @@ -145,9 +145,45 @@ This keeps the behavior centralized while letting each team or package own the p ## App Commands -The root `vite.config.ts` is most valuable for shared linting, formatting, staged checks, and task definitions. For project-specific development, build, and test behavior, use the setup that best matches each app: +The root `vite.config.ts` is most valuable for shared linting, formatting, staged checks, and task definitions. Development, build, preview, and packaging still act on a single app, so Vite+ makes the built-in commands monorepo-aware instead of forcing you to `cd` between packages. -- Target one package with the global `-C` flag. It behaves exactly like `cd && vp ` and works with every vp command: +### Running at the workspace root + +`vp dev`, `vp build`, `vp preview`, and `vp pack` never silently act on the workspace root, which usually has no app of its own. Run them at the top of the monorepo and Vite+ works out which app you mean. + +When exactly one package looks like an app, vp runs it and shows you the direct command for next time: + +``` +$ vp dev +Selected package: web (apps/web) +Tip: run this directly with `vp -C apps/web dev` + + VITE+ v0.2.2 + + ➜ Local: http://localhost:5173/ + ➜ Network: use --host to expose +``` + +When several packages could be the target, vp lists them with ready-to-copy commands instead of guessing: + +``` +$ vp build +error: `vp build` at the workspace root needs a target package. + + Packages in this workspace: + admin apps/admin + web apps/web + @shop/ui packages/ui + + Pass a directory: vp -C apps/admin build + Or run every package's build script: vp run -r build +``` + +Packages that look runnable for the command (an `index.html` or `vite.config.*` for `dev` / `build` / `preview`, a library entry for `pack`) are listed first. + +### Targeting a package with `-C` + +The global `-C` flag runs any command as if you had `cd`'d into a package. It works with every vp command and is identical to `cd && vp `: ```bash vp -C apps/web dev @@ -155,11 +191,32 @@ vp -C apps/web build vp -C packages/ui pack ``` -Passing a folder as a positional (`vp dev apps/web`) still works and keeps upstream Vite semantics: it sets Vite's `root` option without changing the working directory, so cwd-relative reads in configs and plugins resolve from where you ran vp. Prefer `-C` when the package should behave as if you had `cd`'d into it. +Passing a folder as a positional (`vp dev apps/web`) still works, but keeps upstream Vite semantics: it sets Vite's `root` option without changing the working directory, so `process.cwd()` reads in configs and plugins resolve from where you ran vp. Prefer `-C` when the package should behave as if you had `cd`'d into it. + +### A fixed default with `defaultPackage` + +To always target one directory and skip the resolution above, set [`defaultPackage`](/config/#defaultpackage) in the root config: + +```ts [vite.config.ts] +export default { + defaultPackage: './apps/web', +}; +``` + +``` +$ vp dev +note: vp dev: using ./apps/web (defaultPackage) + + VITE+ v0.2.2 + + ➜ Local: http://localhost:5173/ +``` + +This is the right choice for framework monorepos that are not JavaScript workspaces, such as a Laravel or Rails app with a `frontend/` directory: there is no package list to resolve, so `defaultPackage` points vp straight at the app. Because vp reads it without executing the config, it works even when `vite-plus` is installed only inside that subdirectory. -- Run a bare app command at the workspace root and vp resolves the target for you: with [`defaultPackage`](/config/#defaultpackage) configured it runs there, and otherwise it prints the workspace packages with `-C` hints instead of silently serving or building the root. +### Package scripts and workspace-wide tasks -- Keep package-specific scripts in each package when the command differs per app: +Keep package-specific scripts in each package when the command differs per app: ```json [apps/api/package.json] { @@ -170,7 +227,7 @@ Passing a folder as a positional (`vp dev apps/web`) still works and keeps upstr } ``` -- Run scripts across the workspace with `vp run`: +Run scripts across the whole workspace with `vp run`: ```bash vp run -r build From f13f0322d8502dd93ddcda022e87cdcf0aa83a05 Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Jul 2026 17:30:14 +0800 Subject: [PATCH 07/33] test(snap): add -C to the vp --help snapshot --- .../fixtures/cli_helper_message/snapshots/cli_helper_message.md | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md index 7a51254a8e..33e4236631 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md @@ -58,6 +58,7 @@ Documentation: https://viteplus.dev/guide/ Options: -V, --version Print version + -C Run as if vp was started in instead of the current working directory -h, --help Print help ``` From 5ac294db627823a0376364ee5f2878fb01cb77bb Mon Sep 17 00:00:00 2001 From: MK Date: Sat, 4 Jul 2026 18:26:16 +0800 Subject: [PATCH 08/33] test(snap): reset dist between pack forms to avoid platform-dependent clean count --- packages/cli/snap-tests/command-cwd-flag/snap.txt | 2 +- packages/cli/snap-tests/command-cwd-flag/steps.json | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cli/snap-tests/command-cwd-flag/snap.txt b/packages/cli/snap-tests/command-cwd-flag/snap.txt index 2fea02d053..7dfde5f689 100644 --- a/packages/cli/snap-tests/command-cwd-flag/snap.txt +++ b/packages/cli/snap-tests/command-cwd-flag/snap.txt @@ -8,10 +8,10 @@ > ls packages/hello/dist # output lands in the target package index.mjs +> rm -rf packages/hello/dist # reset so both forms produce identical output > cd packages/hello && vp pack # the cd form is equivalent ℹ entry: src/index.ts ℹ Build start -ℹ Cleaning 1 files ℹ dist/index.mjs kB │ gzip: kB ℹ 1 files, total: kB ✔ Build complete in ms diff --git a/packages/cli/snap-tests/command-cwd-flag/steps.json b/packages/cli/snap-tests/command-cwd-flag/steps.json index c4a2d56215..de417eae1f 100644 --- a/packages/cli/snap-tests/command-cwd-flag/steps.json +++ b/packages/cli/snap-tests/command-cwd-flag/steps.json @@ -2,6 +2,10 @@ "commands": [ "vp -C packages/hello pack # -C packs the package from the workspace root", "ls packages/hello/dist # output lands in the target package", + { + "command": "rm -rf packages/hello/dist # reset so both forms produce identical output", + "ignoreOutput": true + }, "cd packages/hello && vp pack # the cd form is equivalent", "vp -C packages/hello run where # -C applies to vp run as well", "cd packages/hello && vp run where # equivalent cd form for run", From 42b801374bfa9a604a428d105be96c0cc7155c92 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 5 Jul 2026 09:54:23 +0800 Subject: [PATCH 09/33] test(snapshot): PTY coverage for app-root resolution Uses the interactive snapshot harness to cover the TTY-gated branches the old snap tests cannot reach: single-app auto-select (Selected/Tip lines), the multi-candidate listing under a real terminal, and the defaultPackage note, each in both local and global vp flavors. --- .../app_root_auto_select/apps/web/index.html | 6 +++++ .../apps/web/package.json | 1 + .../app_root_auto_select/package.json | 1 + .../packages/ui/package.json | 1 + .../packages/ui/src/index.ts | 3 +++ .../app_root_auto_select/snapshots.toml | 10 ++++++++ .../snapshots/auto_select.global.md | 21 +++++++++++++++++ .../snapshots/auto_select.local.md | 19 +++++++++++++++ .../app_root_default_package/package.json | 1 + .../packages/ui/package.json | 1 + .../packages/ui/src/index.ts | 3 +++ .../app_root_default_package/snapshots.toml | 9 ++++++++ .../snapshots/default_package.global.md | 18 +++++++++++++++ .../snapshots/default_package.local.md | 16 +++++++++++++ .../app_root_default_package/vite.config.ts | 3 +++ .../app_root_listing/apps/admin/index.html | 6 +++++ .../app_root_listing/apps/admin/package.json | 1 + .../app_root_listing/apps/web/index.html | 6 +++++ .../app_root_listing/apps/web/package.json | 1 + .../fixtures/app_root_listing/package.json | 1 + .../app_root_listing/packages/ui/package.json | 1 + .../app_root_listing/packages/ui/src/index.ts | 3 +++ .../fixtures/app_root_listing/snapshots.toml | 9 ++++++++ .../snapshots/listing.global.md | 23 +++++++++++++++++++ .../snapshots/listing.local.md | 21 +++++++++++++++++ 25 files changed, 185 insertions(+) create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/apps/web/index.html create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/apps/web/package.json create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/package.json create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/packages/ui/package.json create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/packages/ui/src/index.ts create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/snapshots.toml create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/snapshots/auto_select.global.md create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/snapshots/auto_select.local.md create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/package.json create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/packages/ui/package.json create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/packages/ui/src/index.ts create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots.toml create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package.global.md create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package.local.md create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/vite.config.ts create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/apps/admin/index.html create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/apps/admin/package.json create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/apps/web/index.html create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/apps/web/package.json create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/package.json create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/packages/ui/package.json create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/packages/ui/src/index.ts create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots.toml create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.global.md create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.local.md diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/apps/web/index.html b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/apps/web/index.html new file mode 100644 index 0000000000..ce6d6280c2 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/apps/web/index.html @@ -0,0 +1,6 @@ + + + +

web

+ + diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/apps/web/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/apps/web/package.json new file mode 100644 index 0000000000..64630869c1 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/apps/web/package.json @@ -0,0 +1 @@ +{ "name": "web", "private": true } diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/package.json new file mode 100644 index 0000000000..05c1c2ef02 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/package.json @@ -0,0 +1 @@ +{ "name": "app-root-auto-select", "private": true, "workspaces": ["apps/*", "packages/*"] } diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/packages/ui/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/packages/ui/package.json new file mode 100644 index 0000000000..70bb6aab17 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/packages/ui/package.json @@ -0,0 +1 @@ +{ "name": "ui", "private": true, "type": "module", "main": "src/index.ts" } diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/packages/ui/src/index.ts b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/packages/ui/src/index.ts new file mode 100644 index 0000000000..905c7a9bbb --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/packages/ui/src/index.ts @@ -0,0 +1,3 @@ +export function hello(name: string): string { + return `hello ${name}`; +} diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/snapshots.toml new file mode 100644 index 0000000000..0d74f9f251 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/snapshots.toml @@ -0,0 +1,10 @@ +[[case]] +name = "auto_select" +vp = ["local", "global"] +comment = """ +With exactly one likely-runnable package, a bare app command in an interactive +terminal auto-selects it, prints the Selected/Tip teaching lines, and runs +there (rfcs/cwd-flag.md). This TTY-only branch was untestable in the old +harness. +""" +steps = [["vp", "build"]] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/snapshots/auto_select.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/snapshots/auto_select.global.md new file mode 100644 index 0000000000..edc6dc5023 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/snapshots/auto_select.global.md @@ -0,0 +1,21 @@ +# auto_select + +With exactly one likely-runnable package, a bare app command in an interactive +terminal auto-selects it, prints the Selected/Tip teaching lines, and runs +there (rfcs/cwd-flag.md). This TTY-only branch was untestable in the old +harness. + +## `vp build` + +``` +VITE+ - The Unified Toolchain for the Web + +Selected package: web (apps/web) +Tip: run this directly with `vp -C apps/web build` +vite building client environment for production... +✓ 2 modules transformed. +computing gzip size... +dist/index.html 0.06 kB │ gzip: 0.06 kB + +✓ built in +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/snapshots/auto_select.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/snapshots/auto_select.local.md new file mode 100644 index 0000000000..a33f993f6c --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/snapshots/auto_select.local.md @@ -0,0 +1,19 @@ +# auto_select + +With exactly one likely-runnable package, a bare app command in an interactive +terminal auto-selects it, prints the Selected/Tip teaching lines, and runs +there (rfcs/cwd-flag.md). This TTY-only branch was untestable in the old +harness. + +## `vp build` + +``` +Selected package: web (apps/web) +Tip: run this directly with `vp -C apps/web build` +vite building client environment for production... +✓ 2 modules transformed. +computing gzip size... +dist/index.html 0.06 kB │ gzip: 0.06 kB + +✓ built in +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/package.json new file mode 100644 index 0000000000..b9fabc032d --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/package.json @@ -0,0 +1 @@ +{ "name": "app-root-default-package", "private": true } diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/packages/ui/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/packages/ui/package.json new file mode 100644 index 0000000000..70bb6aab17 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/packages/ui/package.json @@ -0,0 +1 @@ +{ "name": "ui", "private": true, "type": "module", "main": "src/index.ts" } diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/packages/ui/src/index.ts b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/packages/ui/src/index.ts new file mode 100644 index 0000000000..905c7a9bbb --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/packages/ui/src/index.ts @@ -0,0 +1,3 @@ +export function hello(name: string): string { + return `hello ${name}`; +} diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots.toml new file mode 100644 index 0000000000..9b923956c1 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots.toml @@ -0,0 +1,9 @@ +[[case]] +name = "default_package" +vp = ["local", "global"] +comment = """ +defaultPackage in the root config acts as an implicit -C for bare app +commands, including at a root that is not a JS workspace; vp prints a note +line and runs in the configured directory (rfcs/cwd-flag.md). +""" +steps = [["vp", "pack"]] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package.global.md new file mode 100644 index 0000000000..6e964447e2 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package.global.md @@ -0,0 +1,18 @@ +# default_package + +defaultPackage in the root config acts as an implicit -C for bare app +commands, including at a root that is not a JS workspace; vp prints a note +line and runs in the configured directory (rfcs/cwd-flag.md). + +## `vp pack` + +``` +VITE+ - The Unified Toolchain for the Web + +note: vp pack: using ./packages/ui (defaultPackage) +ℹ entry: src/index.ts +ℹ Build start +ℹ dist/index.mjs 0.10 kB │ gzip: 0.11 kB +ℹ 1 files, total: 0.10 kB +✔ Build complete in +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package.local.md new file mode 100644 index 0000000000..19638ef47d --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package.local.md @@ -0,0 +1,16 @@ +# default_package + +defaultPackage in the root config acts as an implicit -C for bare app +commands, including at a root that is not a JS workspace; vp prints a note +line and runs in the configured directory (rfcs/cwd-flag.md). + +## `vp pack` + +``` +note: vp pack: using ./packages/ui (defaultPackage) +ℹ entry: src/index.ts +ℹ Build start +ℹ dist/index.mjs 0.10 kB │ gzip: 0.11 kB +ℹ 1 files, total: 0.10 kB +✔ Build complete in +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/vite.config.ts b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/vite.config.ts new file mode 100644 index 0000000000..36dab4eb9e --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/vite.config.ts @@ -0,0 +1,3 @@ +export default { + defaultPackage: './packages/ui', +}; diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/apps/admin/index.html b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/apps/admin/index.html new file mode 100644 index 0000000000..9a194fe836 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/apps/admin/index.html @@ -0,0 +1,6 @@ + + + +

admin

+ + diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/apps/admin/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/apps/admin/package.json new file mode 100644 index 0000000000..bebad673a4 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/apps/admin/package.json @@ -0,0 +1 @@ +{ "name": "admin", "private": true } diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/apps/web/index.html b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/apps/web/index.html new file mode 100644 index 0000000000..ce6d6280c2 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/apps/web/index.html @@ -0,0 +1,6 @@ + + + +

web

+ + diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/apps/web/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/apps/web/package.json new file mode 100644 index 0000000000..64630869c1 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/apps/web/package.json @@ -0,0 +1 @@ +{ "name": "web", "private": true } diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/package.json new file mode 100644 index 0000000000..0807f1400d --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/package.json @@ -0,0 +1 @@ +{ "name": "app-root-listing", "private": true, "workspaces": ["apps/*", "packages/*"] } diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/packages/ui/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/packages/ui/package.json new file mode 100644 index 0000000000..70bb6aab17 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/packages/ui/package.json @@ -0,0 +1 @@ +{ "name": "ui", "private": true, "type": "module", "main": "src/index.ts" } diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/packages/ui/src/index.ts b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/packages/ui/src/index.ts new file mode 100644 index 0000000000..905c7a9bbb --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/packages/ui/src/index.ts @@ -0,0 +1,3 @@ +export function hello(name: string): string { + return `hello ${name}`; +} diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots.toml new file mode 100644 index 0000000000..8063e4df48 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots.toml @@ -0,0 +1,9 @@ +[[case]] +name = "listing" +vp = ["local", "global"] +comment = """ +A bare app command at a workspace root with several candidate packages prints +the ranked package listing with -C hints and exits 1 instead of building the +root, even in an interactive terminal (rfcs/cwd-flag.md). +""" +steps = [["vp", "build"]] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.global.md new file mode 100644 index 0000000000..aa2352460b --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.global.md @@ -0,0 +1,23 @@ +# listing + +A bare app command at a workspace root with several candidate packages prints +the ranked package listing with -C hints and exits 1 instead of building the +root, even in an interactive terminal (rfcs/cwd-flag.md). + +## `vp build` + +**Exit code:** 1 + +``` +VITE+ - The Unified Toolchain for the Web + +error: `vp build` at the workspace root needs a target package. + + Packages in this workspace: + admin apps/admin + web apps/web + ui packages/ui + + Pass a directory: vp -C apps/admin build + Or run every package's build script: vp run -r build +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.local.md new file mode 100644 index 0000000000..f8aa7200cd --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.local.md @@ -0,0 +1,21 @@ +# listing + +A bare app command at a workspace root with several candidate packages prints +the ranked package listing with -C hints and exits 1 instead of building the +root, even in an interactive terminal (rfcs/cwd-flag.md). + +## `vp build` + +**Exit code:** 1 + +``` +error: `vp build` at the workspace root needs a target package. + + Packages in this workspace: + admin apps/admin + web apps/web + ui packages/ui + + Pass a directory: vp -C apps/admin build + Or run every package's build script: vp run -r build +``` From cc449de5f65ae8bbef5c0e559b5c1c9fb0f97321 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 5 Jul 2026 12:36:10 +0800 Subject: [PATCH 10/33] feat(cli): interactive package picker for bare app commands at the workspace root Replaces the interim TTY listing with the fuzzy vite_select picker (the vp run selector component): typing filters packages, Enter runs the selection as an implicit -C, Ctrl+C cancels with exit 130. Single-runnable auto-select and the non-interactive listing are unchanged. Every picker render emits a package-select:: milestone so PTY snapshot tests synchronize on real keystrokes (picker_select / picker_cancel / non-TTY listing cases, both flavors). Requires the configurable prompt from voidzero-dev/vite-task#510; the vite-task pin points at that PR's commit and needs a re-bump to vite-task main after it merges. --- Cargo.lock | 60 ++++++++-------- Cargo.toml | 19 +++--- .../fixtures/app_root_listing/snapshots.toml | 34 +++++++++- .../snapshots/listing.global.md | 8 +-- .../snapshots/listing.local.md | 6 +- .../snapshots/picker_cancel.global.md | 25 +++++++ .../snapshots/picker_cancel.local.md | 22 ++++++ .../snapshots/picker_select.global.md | 46 +++++++++++++ .../snapshots/picker_select.local.md | 40 +++++++++++ .../fixtures/vp_help/snapshots/help.global.md | 1 + docs/guide/monorepo.md | 22 +++++- packages/cli/binding/Cargo.toml | 2 + packages/cli/binding/src/cli/app_target.rs | 68 +++++++++++++++++-- 13 files changed, 295 insertions(+), 58 deletions(-) create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/picker_cancel.global.md create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/picker_cancel.local.md create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/picker_select.global.md create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/picker_select.local.md diff --git a/Cargo.lock b/Cargo.lock index 8a90e32b0e..97cf67d74c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2337,7 +2337,7 @@ dependencies = [ [[package]] name = "fspy" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=6cfa6e47a1a5fdadd215e1556766cc753603a539#6cfa6e47a1a5fdadd215e1556766cc753603a539" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=a301aff2705ab7c663cc847dcc7c42f178f1fb68#a301aff2705ab7c663cc847dcc7c42f178f1fb68" dependencies = [ "anyhow", "bstr", @@ -2372,7 +2372,7 @@ dependencies = [ [[package]] name = "fspy_detours_sys" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=6cfa6e47a1a5fdadd215e1556766cc753603a539#6cfa6e47a1a5fdadd215e1556766cc753603a539" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=a301aff2705ab7c663cc847dcc7c42f178f1fb68#a301aff2705ab7c663cc847dcc7c42f178f1fb68" dependencies = [ "cc", "winapi", @@ -2381,7 +2381,7 @@ dependencies = [ [[package]] name = "fspy_preload_unix" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=6cfa6e47a1a5fdadd215e1556766cc753603a539#6cfa6e47a1a5fdadd215e1556766cc753603a539" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=a301aff2705ab7c663cc847dcc7c42f178f1fb68#a301aff2705ab7c663cc847dcc7c42f178f1fb68" dependencies = [ "anyhow", "bstr", @@ -2396,7 +2396,7 @@ dependencies = [ [[package]] name = "fspy_preload_windows" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=6cfa6e47a1a5fdadd215e1556766cc753603a539#6cfa6e47a1a5fdadd215e1556766cc753603a539" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=a301aff2705ab7c663cc847dcc7c42f178f1fb68#a301aff2705ab7c663cc847dcc7c42f178f1fb68" dependencies = [ "constcat", "fspy_detours_sys", @@ -2412,7 +2412,7 @@ dependencies = [ [[package]] name = "fspy_seccomp_unotify" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=6cfa6e47a1a5fdadd215e1556766cc753603a539#6cfa6e47a1a5fdadd215e1556766cc753603a539" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=a301aff2705ab7c663cc847dcc7c42f178f1fb68#a301aff2705ab7c663cc847dcc7c42f178f1fb68" dependencies = [ "futures-util", "libc", @@ -2429,7 +2429,7 @@ dependencies = [ [[package]] name = "fspy_shared" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=6cfa6e47a1a5fdadd215e1556766cc753603a539#6cfa6e47a1a5fdadd215e1556766cc753603a539" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=a301aff2705ab7c663cc847dcc7c42f178f1fb68#a301aff2705ab7c663cc847dcc7c42f178f1fb68" dependencies = [ "bitflags 2.11.0", "bstr", @@ -2448,7 +2448,7 @@ dependencies = [ [[package]] name = "fspy_shared_unix" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=6cfa6e47a1a5fdadd215e1556766cc753603a539#6cfa6e47a1a5fdadd215e1556766cc753603a539" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=a301aff2705ab7c663cc847dcc7c42f178f1fb68#a301aff2705ab7c663cc847dcc7c42f178f1fb68" dependencies = [ "anyhow", "base64 0.22.1", @@ -3611,7 +3611,7 @@ dependencies = [ [[package]] name = "materialized_artifact" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=6cfa6e47a1a5fdadd215e1556766cc753603a539#6cfa6e47a1a5fdadd215e1556766cc753603a539" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=a301aff2705ab7c663cc847dcc7c42f178f1fb68#a301aff2705ab7c663cc847dcc7c42f178f1fb68" dependencies = [ "tempfile", ] @@ -3619,7 +3619,7 @@ dependencies = [ [[package]] name = "materialized_artifact_build" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=6cfa6e47a1a5fdadd215e1556766cc753603a539#6cfa6e47a1a5fdadd215e1556766cc753603a539" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=a301aff2705ab7c663cc847dcc7c42f178f1fb68#a301aff2705ab7c663cc847dcc7c42f178f1fb68" dependencies = [ "xxhash-rust", ] @@ -3843,7 +3843,7 @@ dependencies = [ [[package]] name = "native_str" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=6cfa6e47a1a5fdadd215e1556766cc753603a539#6cfa6e47a1a5fdadd215e1556766cc753603a539" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=a301aff2705ab7c663cc847dcc7c42f178f1fb68#a301aff2705ab7c663cc847dcc7c42f178f1fb68" dependencies = [ "bumpalo", "bytemuck", @@ -5596,7 +5596,7 @@ dependencies = [ [[package]] name = "pty_terminal" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=6cfa6e47a1a5fdadd215e1556766cc753603a539#6cfa6e47a1a5fdadd215e1556766cc753603a539" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=a301aff2705ab7c663cc847dcc7c42f178f1fb68#a301aff2705ab7c663cc847dcc7c42f178f1fb68" dependencies = [ "anyhow", "portable-pty", @@ -5606,7 +5606,7 @@ dependencies = [ [[package]] name = "pty_terminal_test" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=6cfa6e47a1a5fdadd215e1556766cc753603a539#6cfa6e47a1a5fdadd215e1556766cc753603a539" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=a301aff2705ab7c663cc847dcc7c42f178f1fb68#a301aff2705ab7c663cc847dcc7c42f178f1fb68" dependencies = [ "anyhow", "portable-pty", @@ -5617,7 +5617,7 @@ dependencies = [ [[package]] name = "pty_terminal_test_client" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=6cfa6e47a1a5fdadd215e1556766cc753603a539#6cfa6e47a1a5fdadd215e1556766cc753603a539" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=a301aff2705ab7c663cc847dcc7c42f178f1fb68#a301aff2705ab7c663cc847dcc7c42f178f1fb68" [[package]] name = "quote" @@ -7512,7 +7512,7 @@ dependencies = [ [[package]] name = "snapshot_test" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=6cfa6e47a1a5fdadd215e1556766cc753603a539#6cfa6e47a1a5fdadd215e1556766cc753603a539" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=a301aff2705ab7c663cc847dcc7c42f178f1fb68#a301aff2705ab7c663cc847dcc7c42f178f1fb68" dependencies = [ "serde", "serde_json", @@ -8500,6 +8500,7 @@ dependencies = [ "owo-colors", "petgraph 0.8.3", "pretty_assertions", + "pty_terminal_test_client", "rolldown_binding", "rustc-hash", "serde", @@ -8512,6 +8513,7 @@ dependencies = [ "vite_migration", "vite_path", "vite_pm_cli", + "vite_select", "vite_shared", "vite_static_config", "vite_str", @@ -8584,7 +8586,7 @@ dependencies = [ [[package]] name = "vite_glob" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=6cfa6e47a1a5fdadd215e1556766cc753603a539#6cfa6e47a1a5fdadd215e1556766cc753603a539" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=a301aff2705ab7c663cc847dcc7c42f178f1fb68#a301aff2705ab7c663cc847dcc7c42f178f1fb68" dependencies = [ "globset", "thiserror 2.0.18", @@ -8632,7 +8634,7 @@ dependencies = [ [[package]] name = "vite_graph_ser" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=6cfa6e47a1a5fdadd215e1556766cc753603a539#6cfa6e47a1a5fdadd215e1556766cc753603a539" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=a301aff2705ab7c663cc847dcc7c42f178f1fb68#a301aff2705ab7c663cc847dcc7c42f178f1fb68" dependencies = [ "petgraph 0.8.3", "serde", @@ -8732,7 +8734,7 @@ dependencies = [ [[package]] name = "vite_path" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=6cfa6e47a1a5fdadd215e1556766cc753603a539#6cfa6e47a1a5fdadd215e1556766cc753603a539" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=a301aff2705ab7c663cc847dcc7c42f178f1fb68#a301aff2705ab7c663cc847dcc7c42f178f1fb68" dependencies = [ "diff-struct", "os_str_bytes", @@ -8764,7 +8766,7 @@ dependencies = [ [[package]] name = "vite_powershell" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=6cfa6e47a1a5fdadd215e1556766cc753603a539#6cfa6e47a1a5fdadd215e1556766cc753603a539" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=a301aff2705ab7c663cc847dcc7c42f178f1fb68#a301aff2705ab7c663cc847dcc7c42f178f1fb68" dependencies = [ "vite_path", "which", @@ -8773,7 +8775,7 @@ dependencies = [ [[package]] name = "vite_select" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=6cfa6e47a1a5fdadd215e1556766cc753603a539#6cfa6e47a1a5fdadd215e1556766cc753603a539" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=a301aff2705ab7c663cc847dcc7c42f178f1fb68#a301aff2705ab7c663cc847dcc7c42f178f1fb68" dependencies = [ "anyhow", "crossterm", @@ -8827,7 +8829,7 @@ dependencies = [ [[package]] name = "vite_shell" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=6cfa6e47a1a5fdadd215e1556766cc753603a539#6cfa6e47a1a5fdadd215e1556766cc753603a539" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=a301aff2705ab7c663cc847dcc7c42f178f1fb68#a301aff2705ab7c663cc847dcc7c42f178f1fb68" dependencies = [ "brush-parser 0.4.0", "diff-struct", @@ -8854,7 +8856,7 @@ dependencies = [ [[package]] name = "vite_str" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=6cfa6e47a1a5fdadd215e1556766cc753603a539#6cfa6e47a1a5fdadd215e1556766cc753603a539" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=a301aff2705ab7c663cc847dcc7c42f178f1fb68#a301aff2705ab7c663cc847dcc7c42f178f1fb68" dependencies = [ "compact_str", "diff-struct", @@ -8865,7 +8867,7 @@ dependencies = [ [[package]] name = "vite_task" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=6cfa6e47a1a5fdadd215e1556766cc753603a539#6cfa6e47a1a5fdadd215e1556766cc753603a539" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=a301aff2705ab7c663cc847dcc7c42f178f1fb68#a301aff2705ab7c663cc847dcc7c42f178f1fb68" dependencies = [ "anstream", "anyhow", @@ -8914,7 +8916,7 @@ dependencies = [ [[package]] name = "vite_task_client" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=6cfa6e47a1a5fdadd215e1556766cc753603a539#6cfa6e47a1a5fdadd215e1556766cc753603a539" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=a301aff2705ab7c663cc847dcc7c42f178f1fb68#a301aff2705ab7c663cc847dcc7c42f178f1fb68" dependencies = [ "native_str", "rustc-hash", @@ -8927,7 +8929,7 @@ dependencies = [ [[package]] name = "vite_task_client_napi" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=6cfa6e47a1a5fdadd215e1556766cc753603a539#6cfa6e47a1a5fdadd215e1556766cc753603a539" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=a301aff2705ab7c663cc847dcc7c42f178f1fb68#a301aff2705ab7c663cc847dcc7c42f178f1fb68" dependencies = [ "napi", "napi-build", @@ -8939,7 +8941,7 @@ dependencies = [ [[package]] name = "vite_task_graph" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=6cfa6e47a1a5fdadd215e1556766cc753603a539#6cfa6e47a1a5fdadd215e1556766cc753603a539" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=a301aff2705ab7c663cc847dcc7c42f178f1fb68#a301aff2705ab7c663cc847dcc7c42f178f1fb68" dependencies = [ "anyhow", "async-trait", @@ -8961,7 +8963,7 @@ dependencies = [ [[package]] name = "vite_task_ipc_shared" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=6cfa6e47a1a5fdadd215e1556766cc753603a539#6cfa6e47a1a5fdadd215e1556766cc753603a539" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=a301aff2705ab7c663cc847dcc7c42f178f1fb68#a301aff2705ab7c663cc847dcc7c42f178f1fb68" dependencies = [ "native_str", "rustc-hash", @@ -8971,7 +8973,7 @@ dependencies = [ [[package]] name = "vite_task_plan" version = "0.1.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=6cfa6e47a1a5fdadd215e1556766cc753603a539#6cfa6e47a1a5fdadd215e1556766cc753603a539" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=a301aff2705ab7c663cc847dcc7c42f178f1fb68#a301aff2705ab7c663cc847dcc7c42f178f1fb68" dependencies = [ "anyhow", "async-trait", @@ -8999,7 +9001,7 @@ dependencies = [ [[package]] name = "vite_task_server" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=6cfa6e47a1a5fdadd215e1556766cc753603a539#6cfa6e47a1a5fdadd215e1556766cc753603a539" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=a301aff2705ab7c663cc847dcc7c42f178f1fb68#a301aff2705ab7c663cc847dcc7c42f178f1fb68" dependencies = [ "futures", "native_str", @@ -9023,7 +9025,7 @@ version = "0.0.0" [[package]] name = "vite_workspace" version = "0.0.0" -source = "git+https://github.com/voidzero-dev/vite-task.git?rev=6cfa6e47a1a5fdadd215e1556766cc753603a539#6cfa6e47a1a5fdadd215e1556766cc753603a539" +source = "git+https://github.com/voidzero-dev/vite-task.git?rev=a301aff2705ab7c663cc847dcc7c42f178f1fb68#a301aff2705ab7c663cc847dcc7c42f178f1fb68" dependencies = [ "clap", "petgraph 0.8.3", diff --git a/Cargo.toml b/Cargo.toml index a8f065cb6a..788c407cf2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -194,7 +194,7 @@ dunce = "1.0.5" fast-glob = "1.0.0" flate2 = { version = "=1.1.9", features = ["zlib-rs"] } form_urlencoded = "1.2.1" -fspy = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "6cfa6e47a1a5fdadd215e1556766cc753603a539" } +fspy = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "a301aff2705ab7c663cc847dcc7c42f178f1fb68" } futures = "0.3.31" futures-util = "0.3.31" glob = "0.3.2" @@ -251,8 +251,8 @@ pretty_assertions = "1.4.1" phf = "0.13.0" prettyplease = "0.2.32" proc-macro2 = "1" -pty_terminal_test = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "6cfa6e47a1a5fdadd215e1556766cc753603a539" } -pty_terminal_test_client = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "6cfa6e47a1a5fdadd215e1556766cc753603a539" } +pty_terminal_test = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "a301aff2705ab7c663cc847dcc7c42f178f1fb68" } +pty_terminal_test_client = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "a301aff2705ab7c663cc847dcc7c42f178f1fb68" } quote = "1" rayon = "1.10.0" regex = "1.11.1" @@ -276,7 +276,7 @@ sha2 = "0.10.9" shell-escape = "0.1.5" simdutf8 = "0.1.5" smallvec = { version = "1.15.1", features = ["union"] } -snapshot_test = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "6cfa6e47a1a5fdadd215e1556766cc753603a539" } +snapshot_test = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "a301aff2705ab7c663cc847dcc7c42f178f1fb68" } string_cache = "0.9.0" sugar_path = { version = "2.0.1", features = ["cached_current_dir"] } supports-color = "3" @@ -313,11 +313,12 @@ vite_pm_cli = { path = "crates/vite_pm_cli" } vite_setup = { path = "crates/vite_setup" } vite_shared = { path = "crates/vite_shared" } vite_static_config = { path = "crates/vite_static_config" } -vite_path = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "6cfa6e47a1a5fdadd215e1556766cc753603a539" } -vite_powershell = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "6cfa6e47a1a5fdadd215e1556766cc753603a539" } -vite_str = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "6cfa6e47a1a5fdadd215e1556766cc753603a539" } -vite_task = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "6cfa6e47a1a5fdadd215e1556766cc753603a539" } -vite_workspace = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "6cfa6e47a1a5fdadd215e1556766cc753603a539" } +vite_path = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "a301aff2705ab7c663cc847dcc7c42f178f1fb68" } +vite_powershell = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "a301aff2705ab7c663cc847dcc7c42f178f1fb68" } +vite_select = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "a301aff2705ab7c663cc847dcc7c42f178f1fb68" } +vite_str = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "a301aff2705ab7c663cc847dcc7c42f178f1fb68" } +vite_task = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "a301aff2705ab7c663cc847dcc7c42f178f1fb68" } +vite_workspace = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "a301aff2705ab7c663cc847dcc7c42f178f1fb68" } walkdir = "2.5.0" wax = "0.6.0" which = "8.0.0" diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots.toml index 8063e4df48..935e2c8e16 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots.toml +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots.toml @@ -2,8 +2,36 @@ name = "listing" vp = ["local", "global"] comment = """ -A bare app command at a workspace root with several candidate packages prints +A bare app command at a workspace root without an interactive terminal prints the ranked package listing with -C hints and exits 1 instead of building the -root, even in an interactive terminal (rfcs/cwd-flag.md). +root (rfcs/cwd-flag.md). """ -steps = [["vp", "build"]] +steps = [{ argv = ["vp", "build"], tty = false }] + +[[case]] +name = "picker_select" +vp = ["local", "global"] +comment = """ +A bare app command at a workspace root with several candidates opens the +fuzzy package picker (the vp run selector component); typing filters, Enter +runs the selection as an implicit -C (rfcs/cwd-flag.md). +""" +steps = [ + { argv = ["vp", "build"], interactions = [ + { "expect-milestone" = "package-select::0" }, + { "write" = "web" }, + { "expect-milestone" = "package-select:web:0" }, + { "write-key" = "enter" }, + ] }, +] + +[[case]] +name = "picker_cancel" +vp = ["local", "global"] +comment = "Ctrl+C in the package picker cancels with exit 130 and runs nothing." +steps = [ + { argv = ["vp", "build"], interactions = [ + { "expect-milestone" = "package-select::0" }, + { "write-key" = "ctrl-c" }, + ] }, +] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.global.md index aa2352460b..ce05e33d57 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.global.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.global.md @@ -1,17 +1,15 @@ # listing -A bare app command at a workspace root with several candidate packages prints +A bare app command at a workspace root without an interactive terminal prints the ranked package listing with -C hints and exits 1 instead of building the -root, even in an interactive terminal (rfcs/cwd-flag.md). +root (rfcs/cwd-flag.md). ## `vp build` **Exit code:** 1 ``` -VITE+ - The Unified Toolchain for the Web - -error: `vp build` at the workspace root needs a target package. +error: `vp build` at the workspace root needs a target package. Packages in this workspace: admin apps/admin diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.local.md index f8aa7200cd..ce05e33d57 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.local.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.local.md @@ -1,15 +1,15 @@ # listing -A bare app command at a workspace root with several candidate packages prints +A bare app command at a workspace root without an interactive terminal prints the ranked package listing with -C hints and exits 1 instead of building the -root, even in an interactive terminal (rfcs/cwd-flag.md). +root (rfcs/cwd-flag.md). ## `vp build` **Exit code:** 1 ``` -error: `vp build` at the workspace root needs a target package. +error: `vp build` at the workspace root needs a target package. Packages in this workspace: admin apps/admin diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/picker_cancel.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/picker_cancel.global.md new file mode 100644 index 0000000000..1731cd3bcf --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/picker_cancel.global.md @@ -0,0 +1,25 @@ +# picker_cancel + +Ctrl+C in the package picker cancels with exit 130 and runs nothing. + +## `vp build` + +**Exit code:** 130 + +**→ expect-milestone:** `package-select::0` + +``` +VITE+ - The Unified Toolchain for the Web + +Select a package to build (↑/↓, Enter to run, type to search): + + › admin apps/admin + web apps/web + ui packages/ui +``` + +**← write-key:** `ctrl-c` + +``` +VITE+ - The Unified Toolchain for the Web +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/picker_cancel.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/picker_cancel.local.md new file mode 100644 index 0000000000..0855d0a694 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/picker_cancel.local.md @@ -0,0 +1,22 @@ +# picker_cancel + +Ctrl+C in the package picker cancels with exit 130 and runs nothing. + +## `vp build` + +**Exit code:** 130 + +**→ expect-milestone:** `package-select::0` + +``` +Select a package to build (↑/↓, Enter to run, type to search): + + › admin apps/admin + web apps/web + ui packages/ui +``` + +**← write-key:** `ctrl-c` + +``` +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/picker_select.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/picker_select.global.md new file mode 100644 index 0000000000..e6a2ae55ee --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/picker_select.global.md @@ -0,0 +1,46 @@ +# picker_select + +A bare app command at a workspace root with several candidates opens the +fuzzy package picker (the vp run selector component); typing filters, Enter +runs the selection as an implicit -C (rfcs/cwd-flag.md). + +## `vp build` + +**→ expect-milestone:** `package-select::0` + +``` +VITE+ - The Unified Toolchain for the Web + +Select a package to build (↑/↓, Enter to run, type to search): + + › admin apps/admin + web apps/web + ui packages/ui +``` + +**← write:** `web` + +**→ expect-milestone:** `package-select:web:0` + +``` +VITE+ - The Unified Toolchain for the Web + +Select a package to build (↑/↓, Enter to run, type to search): web + + › web apps/web +``` + +**← write-key:** `enter` + +``` +VITE+ - The Unified Toolchain for the Web + +Selected package: web (apps/web) +Tip: run this directly with `vp -C apps/web build` +vite building client environment for production... +✓ 2 modules transformed. +computing gzip size... +dist/index.html 0.06 kB │ gzip: 0.06 kB + +✓ built in +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/picker_select.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/picker_select.local.md new file mode 100644 index 0000000000..e99d948355 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/picker_select.local.md @@ -0,0 +1,40 @@ +# picker_select + +A bare app command at a workspace root with several candidates opens the +fuzzy package picker (the vp run selector component); typing filters, Enter +runs the selection as an implicit -C (rfcs/cwd-flag.md). + +## `vp build` + +**→ expect-milestone:** `package-select::0` + +``` +Select a package to build (↑/↓, Enter to run, type to search): + + › admin apps/admin + web apps/web + ui packages/ui +``` + +**← write:** `web` + +**→ expect-milestone:** `package-select:web:0` + +``` +Select a package to build (↑/↓, Enter to run, type to search): web + + › web apps/web +``` + +**← write-key:** `enter` + +``` +Selected package: web (apps/web) +Tip: run this directly with `vp -C apps/web build` +vite building client environment for production... +✓ 2 modules transformed. +computing gzip size... +dist/index.html 0.06 kB │ gzip: 0.06 kB + +✓ built in +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.global.md index c2f0d2f46e..fc51e24490 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.global.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.global.md @@ -58,5 +58,6 @@ Documentation: https://viteplus.dev/guide/ Options: -V, --version Print version + -C Run as if vp was started in instead of the current working directory -h, --help Print help ``` diff --git a/docs/guide/monorepo.md b/docs/guide/monorepo.md index 766f62f885..2c20a0155c 100644 --- a/docs/guide/monorepo.md +++ b/docs/guide/monorepo.md @@ -164,10 +164,28 @@ Tip: run this directly with `vp -C apps/web dev` ➜ Network: use --host to expose ``` -When several packages could be the target, vp lists them with ready-to-copy commands instead of guessing: +When several packages could be the target, vp opens a fuzzy package picker (the same selector as `vp run`). Typing filters, Enter runs the selection, and vp prints the direct command for next time: ``` $ vp build +Select a package to build (↑/↓, Enter to run, type to search): + + › admin apps/admin + web apps/web + ui packages/ui +``` + +``` +Selected package: web (apps/web) +Tip: run this directly with `vp -C apps/web build` + + ✓ built in 187ms +``` + +In non-interactive shells (CI, pipes, redirection), vp prints the same packages as a plain listing with ready-to-copy commands and exits 1: + +``` +$ vp build | cat error: `vp build` at the workspace root needs a target package. Packages in this workspace: @@ -179,7 +197,7 @@ error: `vp build` at the workspace root needs a target package. Or run every package's build script: vp run -r build ``` -Packages that look runnable for the command (an `index.html` or `vite.config.*` for `dev` / `build` / `preview`, a library entry for `pack`) are listed first. +Packages that look runnable for the command (an `index.html` or `vite.config.*` for `dev` / `build` / `preview`, a library entry for `pack`) are ranked first in both the picker and the listing. ### Targeting a package with `-C` diff --git a/packages/cli/binding/Cargo.toml b/packages/cli/binding/Cargo.toml index f2aa8611d6..a84fae3cd9 100644 --- a/packages/cli/binding/Cargo.toml +++ b/packages/cli/binding/Cargo.toml @@ -21,6 +21,7 @@ napi = { workspace = true } napi-derive = { workspace = true } petgraph = { workspace = true } owo-colors = { workspace = true } +pty_terminal_test_client = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tokio = { workspace = true, features = ["fs"] } @@ -31,6 +32,7 @@ vite_install = { workspace = true } vite_migration = { workspace = true } vite_pm_cli = { workspace = true } vite_path = { workspace = true } +vite_select = { workspace = true } vite_shared = { workspace = true } vite_static_config = { workspace = true } vite_str = { workspace = true } diff --git a/packages/cli/binding/src/cli/app_target.rs b/packages/cli/binding/src/cli/app_target.rs index 001461793f..34376a7a66 100644 --- a/packages/cli/binding/src/cli/app_target.rs +++ b/packages/cli/binding/src/cli/app_target.rs @@ -4,8 +4,8 @@ //! and would silently run against the root. Resolution order (rfcs/cwd-flag.md): //! explicit `-C` and positional targets are handled before this code and skip //! elicitation entirely; then `defaultPackage` from the config in the -//! invocation directory, then the workspace package listing (interactive -//! picker planned once `vite_select` supports a custom prompt), then exit 1. +//! invocation directory, then the interactive package picker (a package +//! listing plus exit 1 when the terminal is not interactive). use vite_error::Error; use vite_path::AbsolutePathBuf; @@ -91,6 +91,52 @@ fn resolve_default_package(command: &str, cwd: &AbsolutePathBuf) -> Option:` milestone +/// (invisible OSC 8 hyperlinks) so PTY snapshot tests can synchronize. +fn run_package_picker(command: &str, rows: &[PackageRow]) -> Result, Error> { + let items: Vec = rows + .iter() + .map(|row| vite_select::SelectItem { + label: vite_str::format!("{} {}", row.name, row.path), + display_name: vite_str::Str::from(row.name.as_str()), + description: vite_str::Str::from(row.path.as_str()), + group: None, + }) + .collect(); + let prompt = + format!("Select a package to {command} (\u{2191}/\u{2193}, Enter to run, type to search):"); + let params = vite_select::SelectParams { + items: &items, + query: None, + header: None, + prompt: &prompt, + page_size: 12, + }; + let mut selected_index = 0usize; + let mut stdout = std::io::stdout(); + let result = vite_select::select_list( + &mut stdout, + ¶ms, + vite_select::Mode::Interactive { selected_index: &mut selected_index }, + |state| { + use std::io::Write as _; + let milestone = + vite_str::format!("package-select:{}:{}", state.query, state.selected_index); + let bytes = pty_terminal_test_client::encoded_milestone(&milestone); + let mut out = std::io::stdout(); + let _ = out.write_all(&bytes); + let _ = out.flush(); + }, + ) + .map_err(Error::Anyhow)?; + Ok(match result { + vite_select::SelectResult::Selected => Some(selected_index), + vite_select::SelectResult::Cancelled => None, + }) +} + pub(super) fn resolve_app_target( subcommand: &SynthesizableSubcommand, cwd: &AbsolutePathBuf, @@ -140,11 +186,19 @@ pub(super) fn resolve_app_target( } rows.sort_by(|a, b| (!a.runnable, a.path.as_str()).cmp(&(!b.runnable, b.path.as_str()))); - // With exactly one likely-runnable package (rows are sorted runnable - // first), an interactive terminal auto-selects it (the degenerate picker). - let single_runnable = rows[0].runnable && rows.get(1).is_none_or(|row| !row.runnable); - if single_runnable && vite_shared::is_interactive_terminal() { - let row = &rows[0]; + // In an interactive terminal, pick the target: exactly one likely-runnable + // package (rows are sorted runnable first) auto-selects without a menu; + // otherwise the fuzzy picker runs. + if vite_shared::is_interactive_terminal() { + let single_runnable = rows[0].runnable && rows.get(1).is_none_or(|row| !row.runnable); + let row = if single_runnable { + &rows[0] + } else { + match run_package_picker(command, &rows)? { + Some(index) => &rows[index], + None => return Ok(AppTarget::Exit(ExitStatus(130))), + } + }; println!("Selected package: {} ({})", row.name, row.path); println!("Tip: run this directly with `vp -C {} {command}`", row.path); return Ok(AppTarget::Dir(row.absolute.clone())); From 5fa3cd730203c53aab3b8a0fd6a0963f3f74f522 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 5 Jul 2026 12:56:25 +0800 Subject: [PATCH 11/33] test(snapshot): migrate cwd-flag and app-root snap tests to the PTY harness Moves the four old-harness fixtures added by this PR (command-cwd-flag, command-app-root-listing, command-default-package, command-default-package-missing) into crates/vite_cli_snapshots: - cwd_flag: -C vs cd byte-identical for pack and run, missing-dir error, positional entry-semantics parity, with vpt list-dir/rm assertions - app_root_listing: non-TTY dev listing joins the build step - app_root_default_package: dist assertion plus a missing-directory case via a nested non-workspace root Real pass/fail assertions and recorded exit codes replace the regenerate-and-diff model for these cases. --- .../missing/package.json | 1 + .../missing}/vite.config.ts | 0 .../app_root_default_package/snapshots.toml | 11 +- .../snapshots/default_package.global.md | 8 ++ .../snapshots/default_package.local.md | 8 ++ .../default_package_missing.global.md | 13 +++ .../default_package_missing.local.md | 11 ++ .../fixtures/app_root_listing/snapshots.toml | 5 +- .../snapshots/listing.global.md | 18 +++ .../snapshots/listing.local.md | 18 +++ .../fixtures/cwd_flag/package.json | 1 + .../cwd_flag}/packages/hello/package.json | 0 .../cwd_flag}/packages/hello/src/index.ts | 0 .../fixtures/cwd_flag/snapshots.toml | 20 ++++ .../cwd_flag/snapshots/cwd_flag.global.md | 103 ++++++++++++++++++ .../cwd_flag/snapshots/cwd_flag.local.md | 93 ++++++++++++++++ .../command-app-root-listing/package.json | 8 -- .../packages/lib/package.json | 6 - .../packages/lib/src/index.ts | 1 - .../packages/web/index.html | 6 - .../packages/web/package.json | 5 - .../command-app-root-listing/snap.txt | 19 ---- .../command-app-root-listing/steps.json | 6 - .../snap-tests/command-cwd-flag/package.json | 8 -- .../cli/snap-tests/command-cwd-flag/snap.txt | 40 ------- .../snap-tests/command-cwd-flag/steps.json | 16 --- .../package.json | 5 - .../command-default-package-missing/snap.txt | 2 - .../steps.json | 3 - .../command-default-package/package.json | 8 -- .../packages/hello/package.json | 6 - .../packages/hello/src/index.ts | 3 - .../command-default-package/snap.txt | 10 -- .../command-default-package/steps.json | 6 - .../command-default-package/vite.config.ts | 3 - 35 files changed, 308 insertions(+), 163 deletions(-) create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/missing/package.json rename {packages/cli/snap-tests/command-default-package-missing => crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/missing}/vite.config.ts (100%) create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package_missing.global.md create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package_missing.local.md create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/package.json rename {packages/cli/snap-tests/command-cwd-flag => crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag}/packages/hello/package.json (100%) rename {packages/cli/snap-tests/command-cwd-flag => crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag}/packages/hello/src/index.ts (100%) create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots.toml create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.global.md create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.local.md delete mode 100644 packages/cli/snap-tests/command-app-root-listing/package.json delete mode 100644 packages/cli/snap-tests/command-app-root-listing/packages/lib/package.json delete mode 100644 packages/cli/snap-tests/command-app-root-listing/packages/lib/src/index.ts delete mode 100644 packages/cli/snap-tests/command-app-root-listing/packages/web/index.html delete mode 100644 packages/cli/snap-tests/command-app-root-listing/packages/web/package.json delete mode 100644 packages/cli/snap-tests/command-app-root-listing/snap.txt delete mode 100644 packages/cli/snap-tests/command-app-root-listing/steps.json delete mode 100644 packages/cli/snap-tests/command-cwd-flag/package.json delete mode 100644 packages/cli/snap-tests/command-cwd-flag/snap.txt delete mode 100644 packages/cli/snap-tests/command-cwd-flag/steps.json delete mode 100644 packages/cli/snap-tests/command-default-package-missing/package.json delete mode 100644 packages/cli/snap-tests/command-default-package-missing/snap.txt delete mode 100644 packages/cli/snap-tests/command-default-package-missing/steps.json delete mode 100644 packages/cli/snap-tests/command-default-package/package.json delete mode 100644 packages/cli/snap-tests/command-default-package/packages/hello/package.json delete mode 100644 packages/cli/snap-tests/command-default-package/packages/hello/src/index.ts delete mode 100644 packages/cli/snap-tests/command-default-package/snap.txt delete mode 100644 packages/cli/snap-tests/command-default-package/steps.json delete mode 100644 packages/cli/snap-tests/command-default-package/vite.config.ts diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/missing/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/missing/package.json new file mode 100644 index 0000000000..f6a8d9de96 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/missing/package.json @@ -0,0 +1 @@ +{ "name": "missing-default", "private": true } diff --git a/packages/cli/snap-tests/command-default-package-missing/vite.config.ts b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/missing/vite.config.ts similarity index 100% rename from packages/cli/snap-tests/command-default-package-missing/vite.config.ts rename to crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/missing/vite.config.ts diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots.toml index 9b923956c1..c68360c535 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots.toml +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots.toml @@ -6,4 +6,13 @@ defaultPackage in the root config acts as an implicit -C for bare app commands, including at a root that is not a JS workspace; vp prints a note line and runs in the configured directory (rfcs/cwd-flag.md). """ -steps = [["vp", "pack"]] +steps = [ + ["vp", "pack"], + { argv = ["vpt", "list-dir", "packages/ui/dist"], comment = "output lands in the configured package" }, +] + +[[case]] +name = "default_package_missing" +vp = ["local", "global"] +comment = "defaultPackage pointing at a missing directory errors before any workspace lookup." +steps = [{ argv = ["vp", "build"], cwd = "missing" }] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package.global.md index 6e964447e2..8d1f236531 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package.global.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package.global.md @@ -16,3 +16,11 @@ note: vp pack: using ./packages/ui (defaultPackage) ℹ 1 files, total: 0.10 kB ✔ Build complete in ``` + +## `vpt list-dir packages/ui/dist` + +output lands in the configured package + +``` +index.mjs +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package.local.md index 19638ef47d..72370924f5 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package.local.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package.local.md @@ -14,3 +14,11 @@ note: vp pack: using ./packages/ui (defaultPackage) ℹ 1 files, total: 0.10 kB ✔ Build complete in ``` + +## `vpt list-dir packages/ui/dist` + +output lands in the configured package + +``` +index.mjs +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package_missing.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package_missing.global.md new file mode 100644 index 0000000000..b05221c778 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package_missing.global.md @@ -0,0 +1,13 @@ +# default_package_missing + +defaultPackage pointing at a missing directory errors before any workspace lookup. + +## `cd missing && vp build` + +**Exit code:** 1 + +``` +VITE+ - The Unified Toolchain for the Web + +error: defaultPackage points to a missing directory: ./packages/nope +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package_missing.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package_missing.local.md new file mode 100644 index 0000000000..768869fad9 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package_missing.local.md @@ -0,0 +1,11 @@ +# default_package_missing + +defaultPackage pointing at a missing directory errors before any workspace lookup. + +## `cd missing && vp build` + +**Exit code:** 1 + +``` +error: defaultPackage points to a missing directory: ./packages/nope +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots.toml index 935e2c8e16..929d887a56 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots.toml +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots.toml @@ -6,7 +6,10 @@ A bare app command at a workspace root without an interactive terminal prints the ranked package listing with -C hints and exits 1 instead of building the root (rfcs/cwd-flag.md). """ -steps = [{ argv = ["vp", "build"], tty = false }] +steps = [ + { argv = ["vp", "build"], tty = false }, + { argv = ["vp", "dev"], tty = false, comment = "dev at the root no longer starts a server against the root" }, +] [[case]] name = "picker_select" diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.global.md index ce05e33d57..daf3c000d6 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.global.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.global.md @@ -19,3 +19,21 @@ root (rfcs/cwd-flag.md). Pass a directory: vp -C apps/admin build Or run every package's build script: vp run -r build ``` + +## `vp dev` + +dev at the root no longer starts a server against the root + +**Exit code:** 1 + +``` +error: `vp dev` at the workspace root needs a target package. + + Packages in this workspace: + admin apps/admin + web apps/web + ui packages/ui + + Pass a directory: vp -C apps/admin dev + Or run every package's dev script: vp run -r dev +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.local.md index ce05e33d57..daf3c000d6 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.local.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.local.md @@ -19,3 +19,21 @@ root (rfcs/cwd-flag.md). Pass a directory: vp -C apps/admin build Or run every package's build script: vp run -r build ``` + +## `vp dev` + +dev at the root no longer starts a server against the root + +**Exit code:** 1 + +``` +error: `vp dev` at the workspace root needs a target package. + + Packages in this workspace: + admin apps/admin + web apps/web + ui packages/ui + + Pass a directory: vp -C apps/admin dev + Or run every package's dev script: vp run -r dev +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/package.json new file mode 100644 index 0000000000..ed6c6904e3 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/package.json @@ -0,0 +1 @@ +{ "name": "cwd-flag", "private": true, "workspaces": ["packages/*"] } diff --git a/packages/cli/snap-tests/command-cwd-flag/packages/hello/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/packages/hello/package.json similarity index 100% rename from packages/cli/snap-tests/command-cwd-flag/packages/hello/package.json rename to crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/packages/hello/package.json diff --git a/packages/cli/snap-tests/command-cwd-flag/packages/hello/src/index.ts b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/packages/hello/src/index.ts similarity index 100% rename from packages/cli/snap-tests/command-cwd-flag/packages/hello/src/index.ts rename to crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/packages/hello/src/index.ts diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots.toml new file mode 100644 index 0000000000..26ac6867a7 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots.toml @@ -0,0 +1,20 @@ +[[case]] +name = "cwd_flag" +vp = ["local", "global"] +comment = """ +The global -C flag runs any command as if vp was started in the directory: +pack and run behave byte-identically to the cd forms, a missing directory +errors, and the positional keeps upstream tsdown entry semantics +(rfcs/cwd-flag.md). +""" +steps = [ + { argv = ["vp", "-C", "packages/hello", "pack"], comment = "-C packs the package from the workspace root" }, + { argv = ["vpt", "list-dir", "packages/hello/dist"], comment = "output lands in the target package" }, + { argv = ["vpt", "rm", "-rf", "packages/hello/dist"], comment = "reset so both forms produce identical output" }, + { argv = ["vp", "pack"], cwd = "packages/hello", comment = "the cd form is equivalent" }, + { argv = ["vp", "-C", "packages/hello", "run", "where"], comment = "-C applies to vp run as well" }, + { argv = ["vp", "run", "where"], cwd = "packages/hello", comment = "equivalent cd form for run" }, + { argv = ["vp", "-C", "packages/missing", "build"], comment = "missing directory errors" }, + { argv = ["vp", "pack", "packages/hello"], comment = "positional stays a tsdown entry resolved from the invocation directory" }, + { argv = ["vpt", "list-dir", "dist"], comment = "upstream semantics: output lands at the invocation directory" }, +] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.global.md new file mode 100644 index 0000000000..f83ee238ca --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.global.md @@ -0,0 +1,103 @@ +# cwd_flag + +The global -C flag runs any command as if vp was started in the directory: +pack and run behave byte-identically to the cd forms, a missing directory +errors, and the positional keeps upstream tsdown entry semantics +(rfcs/cwd-flag.md). + +## `vp -C packages/hello pack` + +-C packs the package from the workspace root + +``` +VITE+ - The Unified Toolchain for the Web + +ℹ entry: src/index.ts +ℹ Build start +ℹ dist/index.mjs 0.10 kB │ gzip: 0.11 kB +ℹ 1 files, total: 0.10 kB +✔ Build complete in +``` + +## `vpt list-dir packages/hello/dist` + +output lands in the target package + +``` +index.mjs +``` + +## `vpt rm -rf packages/hello/dist` + +reset so both forms produce identical output + +``` +``` + +## `cd packages/hello && vp pack` + +the cd form is equivalent + +``` +VITE+ - The Unified Toolchain for the Web + +ℹ entry: src/index.ts +ℹ Build start +ℹ dist/index.mjs 0.10 kB │ gzip: 0.11 kB +ℹ 1 files, total: 0.10 kB +✔ Build complete in +``` + +## `vp -C packages/hello run where` + +-C applies to vp run as well + +``` +VITE+ - The Unified Toolchain for the Web + +~/packages/hello$ node -e "console.log('cwd base: ' + require('node:path').basename(process.cwd()))" ⊘ cache disabled +cwd base: hello +``` + +## `cd packages/hello && vp run where` + +equivalent cd form for run + +``` +VITE+ - The Unified Toolchain for the Web + +~/packages/hello$ node -e "console.log('cwd base: ' + require('node:path').basename(process.cwd()))" ⊘ cache disabled +cwd base: hello +``` + +## `vp -C packages/missing build` + +missing directory errors + +**Exit code:** 1 + +``` +directory not found: packages/missing +``` + +## `vp pack packages/hello` + +positional stays a tsdown entry resolved from the invocation directory + +``` +VITE+ - The Unified Toolchain for the Web + +ℹ entry: packages/hello +ℹ Build start +ℹ dist/hello.mjs 0.12 kB │ gzip: 0.12 kB +ℹ 1 files, total: 0.12 kB +✔ Build complete in +``` + +## `vpt list-dir dist` + +upstream semantics: output lands at the invocation directory + +``` +hello.mjs +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.local.md new file mode 100644 index 0000000000..6431f9eac7 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.local.md @@ -0,0 +1,93 @@ +# cwd_flag + +The global -C flag runs any command as if vp was started in the directory: +pack and run behave byte-identically to the cd forms, a missing directory +errors, and the positional keeps upstream tsdown entry semantics +(rfcs/cwd-flag.md). + +## `vp -C packages/hello pack` + +-C packs the package from the workspace root + +``` +ℹ entry: src/index.ts +ℹ Build start +ℹ dist/index.mjs 0.10 kB │ gzip: 0.11 kB +ℹ 1 files, total: 0.10 kB +✔ Build complete in +``` + +## `vpt list-dir packages/hello/dist` + +output lands in the target package + +``` +index.mjs +``` + +## `vpt rm -rf packages/hello/dist` + +reset so both forms produce identical output + +``` +``` + +## `cd packages/hello && vp pack` + +the cd form is equivalent + +``` +ℹ entry: src/index.ts +ℹ Build start +ℹ dist/index.mjs 0.10 kB │ gzip: 0.11 kB +ℹ 1 files, total: 0.10 kB +✔ Build complete in +``` + +## `vp -C packages/hello run where` + +-C applies to vp run as well + +``` +~/packages/hello$ node -e "console.log('cwd base: ' + require('node:path').basename(process.cwd()))" ⊘ cache disabled +cwd base: hello +``` + +## `cd packages/hello && vp run where` + +equivalent cd form for run + +``` +~/packages/hello$ node -e "console.log('cwd base: ' + require('node:path').basename(process.cwd()))" ⊘ cache disabled +cwd base: hello +``` + +## `vp -C packages/missing build` + +missing directory errors + +**Exit code:** 1 + +``` +error: directory not found: packages/missing +``` + +## `vp pack packages/hello` + +positional stays a tsdown entry resolved from the invocation directory + +``` +ℹ entry: packages/hello +ℹ Build start +ℹ dist/hello.mjs 0.12 kB │ gzip: 0.12 kB +ℹ 1 files, total: 0.12 kB +✔ Build complete in +``` + +## `vpt list-dir dist` + +upstream semantics: output lands at the invocation directory + +``` +hello.mjs +``` diff --git a/packages/cli/snap-tests/command-app-root-listing/package.json b/packages/cli/snap-tests/command-app-root-listing/package.json deleted file mode 100644 index e63823dcc9..0000000000 --- a/packages/cli/snap-tests/command-app-root-listing/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "command-app-root-listing", - "version": "1.0.0", - "workspaces": [ - "packages/*" - ], - "type": "module" -} diff --git a/packages/cli/snap-tests/command-app-root-listing/packages/lib/package.json b/packages/cli/snap-tests/command-app-root-listing/packages/lib/package.json deleted file mode 100644 index bc55518964..0000000000 --- a/packages/cli/snap-tests/command-app-root-listing/packages/lib/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "lib", - "version": "1.0.0", - "type": "module", - "main": "src/index.ts" -} diff --git a/packages/cli/snap-tests/command-app-root-listing/packages/lib/src/index.ts b/packages/cli/snap-tests/command-app-root-listing/packages/lib/src/index.ts deleted file mode 100644 index 7b8c293a75..0000000000 --- a/packages/cli/snap-tests/command-app-root-listing/packages/lib/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const lib = true; diff --git a/packages/cli/snap-tests/command-app-root-listing/packages/web/index.html b/packages/cli/snap-tests/command-app-root-listing/packages/web/index.html deleted file mode 100644 index b61cdc6f1d..0000000000 --- a/packages/cli/snap-tests/command-app-root-listing/packages/web/index.html +++ /dev/null @@ -1,6 +0,0 @@ - - - -

web app

- - diff --git a/packages/cli/snap-tests/command-app-root-listing/packages/web/package.json b/packages/cli/snap-tests/command-app-root-listing/packages/web/package.json deleted file mode 100644 index 886a9e5f15..0000000000 --- a/packages/cli/snap-tests/command-app-root-listing/packages/web/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "web", - "version": "1.0.0", - "type": "module" -} diff --git a/packages/cli/snap-tests/command-app-root-listing/snap.txt b/packages/cli/snap-tests/command-app-root-listing/snap.txt deleted file mode 100644 index c4d62a280f..0000000000 --- a/packages/cli/snap-tests/command-app-root-listing/snap.txt +++ /dev/null @@ -1,19 +0,0 @@ -[1]> vp build # bare build at the workspace root lists packages instead of building the root -error: `vp build` at the workspace root needs a target package. - - Packages in this workspace: - web packages/web - lib packages/lib - - Pass a directory: vp -C packages/web build - Or run every package's build script: vp run -r build - -[1]> vp dev # bare dev at the root no longer starts a server against the root -error: `vp dev` at the workspace root needs a target package. - - Packages in this workspace: - web packages/web - lib packages/lib - - Pass a directory: vp -C packages/web dev - Or run every package's dev script: vp run -r dev diff --git a/packages/cli/snap-tests/command-app-root-listing/steps.json b/packages/cli/snap-tests/command-app-root-listing/steps.json deleted file mode 100644 index 228092b98d..0000000000 --- a/packages/cli/snap-tests/command-app-root-listing/steps.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "commands": [ - "vp build # bare build at the workspace root lists packages instead of building the root", - "vp dev # bare dev at the root no longer starts a server against the root" - ] -} diff --git a/packages/cli/snap-tests/command-cwd-flag/package.json b/packages/cli/snap-tests/command-cwd-flag/package.json deleted file mode 100644 index 1fff4a5751..0000000000 --- a/packages/cli/snap-tests/command-cwd-flag/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "command-cwd-flag", - "version": "1.0.0", - "workspaces": [ - "packages/*" - ], - "type": "module" -} diff --git a/packages/cli/snap-tests/command-cwd-flag/snap.txt b/packages/cli/snap-tests/command-cwd-flag/snap.txt deleted file mode 100644 index 7dfde5f689..0000000000 --- a/packages/cli/snap-tests/command-cwd-flag/snap.txt +++ /dev/null @@ -1,40 +0,0 @@ -> vp -C packages/hello pack # -C packs the package from the workspace root -ℹ entry: src/index.ts -ℹ Build start -ℹ dist/index.mjs kB │ gzip: kB -ℹ 1 files, total: kB -✔ Build complete in ms - -> ls packages/hello/dist # output lands in the target package -index.mjs - -> rm -rf packages/hello/dist # reset so both forms produce identical output -> cd packages/hello && vp pack # the cd form is equivalent -ℹ entry: src/index.ts -ℹ Build start -ℹ dist/index.mjs kB │ gzip: kB -ℹ 1 files, total: kB -✔ Build complete in ms - -> vp -C packages/hello run where # -C applies to vp run as well -~/packages/hello$ node -e "console.log('cwd base: ' + require('node:path').basename(process.cwd()))" ⊘ cache disabled -cwd base: hello - - -> cd packages/hello && vp run where # equivalent cd form for run -~/packages/hello$ node -e "console.log('cwd base: ' + require('node:path').basename(process.cwd()))" ⊘ cache disabled -cwd base: hello - - -[1]> vp -C packages/missing build # missing directory errors -error: directory not found: packages/missing - -> vp pack packages/hello # positional stays a tsdown entry resolved from the invocation directory -ℹ entry: packages/hello -ℹ Build start -ℹ dist/hello.mjs kB │ gzip: kB -ℹ 1 files, total: kB -✔ Build complete in ms - -> ls dist # upstream semantics: output lands at the invocation directory, not in the package -hello.mjs diff --git a/packages/cli/snap-tests/command-cwd-flag/steps.json b/packages/cli/snap-tests/command-cwd-flag/steps.json deleted file mode 100644 index de417eae1f..0000000000 --- a/packages/cli/snap-tests/command-cwd-flag/steps.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "commands": [ - "vp -C packages/hello pack # -C packs the package from the workspace root", - "ls packages/hello/dist # output lands in the target package", - { - "command": "rm -rf packages/hello/dist # reset so both forms produce identical output", - "ignoreOutput": true - }, - "cd packages/hello && vp pack # the cd form is equivalent", - "vp -C packages/hello run where # -C applies to vp run as well", - "cd packages/hello && vp run where # equivalent cd form for run", - "vp -C packages/missing build # missing directory errors", - "vp pack packages/hello # positional stays a tsdown entry resolved from the invocation directory", - "ls dist # upstream semantics: output lands at the invocation directory, not in the package" - ] -} diff --git a/packages/cli/snap-tests/command-default-package-missing/package.json b/packages/cli/snap-tests/command-default-package-missing/package.json deleted file mode 100644 index fb8928c6d7..0000000000 --- a/packages/cli/snap-tests/command-default-package-missing/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "command-default-package-missing", - "version": "1.0.0", - "type": "module" -} diff --git a/packages/cli/snap-tests/command-default-package-missing/snap.txt b/packages/cli/snap-tests/command-default-package-missing/snap.txt deleted file mode 100644 index c9757b7117..0000000000 --- a/packages/cli/snap-tests/command-default-package-missing/snap.txt +++ /dev/null @@ -1,2 +0,0 @@ -[1]> vp build # defaultPackage pointing at a missing directory errors -error: defaultPackage points to a missing directory: ./packages/nope diff --git a/packages/cli/snap-tests/command-default-package-missing/steps.json b/packages/cli/snap-tests/command-default-package-missing/steps.json deleted file mode 100644 index 7624bca898..0000000000 --- a/packages/cli/snap-tests/command-default-package-missing/steps.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "commands": ["vp build # defaultPackage pointing at a missing directory errors"] -} diff --git a/packages/cli/snap-tests/command-default-package/package.json b/packages/cli/snap-tests/command-default-package/package.json deleted file mode 100644 index f633b65741..0000000000 --- a/packages/cli/snap-tests/command-default-package/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "command-default-package", - "version": "1.0.0", - "workspaces": [ - "packages/*" - ], - "type": "module" -} diff --git a/packages/cli/snap-tests/command-default-package/packages/hello/package.json b/packages/cli/snap-tests/command-default-package/packages/hello/package.json deleted file mode 100644 index 5ff9801f10..0000000000 --- a/packages/cli/snap-tests/command-default-package/packages/hello/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "hello", - "version": "1.0.0", - "type": "module", - "main": "src/index.ts" -} diff --git a/packages/cli/snap-tests/command-default-package/packages/hello/src/index.ts b/packages/cli/snap-tests/command-default-package/packages/hello/src/index.ts deleted file mode 100644 index 905c7a9bbb..0000000000 --- a/packages/cli/snap-tests/command-default-package/packages/hello/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function hello(name: string): string { - return `hello ${name}`; -} diff --git a/packages/cli/snap-tests/command-default-package/snap.txt b/packages/cli/snap-tests/command-default-package/snap.txt deleted file mode 100644 index a3519bf807..0000000000 --- a/packages/cli/snap-tests/command-default-package/snap.txt +++ /dev/null @@ -1,10 +0,0 @@ -> vp pack # bare pack at the root follows defaultPackage -note: vp pack: using ./packages/hello (defaultPackage) -ℹ entry: src/index.ts -ℹ Build start -ℹ dist/index.mjs kB │ gzip: kB -ℹ 1 files, total: kB -✔ Build complete in ms - -> ls packages/hello/dist # output lands in the configured package -index.mjs diff --git a/packages/cli/snap-tests/command-default-package/steps.json b/packages/cli/snap-tests/command-default-package/steps.json deleted file mode 100644 index 1c13e7a826..0000000000 --- a/packages/cli/snap-tests/command-default-package/steps.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "commands": [ - "vp pack # bare pack at the root follows defaultPackage", - "ls packages/hello/dist # output lands in the configured package" - ] -} diff --git a/packages/cli/snap-tests/command-default-package/vite.config.ts b/packages/cli/snap-tests/command-default-package/vite.config.ts deleted file mode 100644 index dcfe02f744..0000000000 --- a/packages/cli/snap-tests/command-default-package/vite.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default { - defaultPackage: './packages/hello', -}; From 4ae31fb01353629340fc5423d776055f236c6437 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 5 Jul 2026 13:09:26 +0800 Subject: [PATCH 12/33] fix(cli): document -C in the local CLI help --- .../cli_helper_message/snapshots/cli_helper_message_local.md | 1 + .../tests/cli_snapshots/fixtures/vp_help/snapshots/help.local.md | 1 + packages/cli/binding/src/cli/help.rs | 1 + packages/cli/snap-tests/command-helper/snap.txt | 1 + packages/cli/snap-tests/command-vp-alias/snap.txt | 1 + 5 files changed, 5 insertions(+) diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message_local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message_local.md index b0c2afef0e..074c6346c6 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message_local.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message_local.md @@ -30,6 +30,7 @@ Package Manager Commands: install Install all dependencies, or add packages if package names are provided Options: + -C Run as if vp was started in instead of the current working directory -h, --help Print help ``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.local.md index 398c5d0f4d..166ac74621 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.local.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.local.md @@ -30,5 +30,6 @@ Package Manager Commands: install Install all dependencies, or add packages if package names are provided Options: + -C Run as if vp was started in instead of the current working directory -h, --help Print help ``` diff --git a/packages/cli/binding/src/cli/help.rs b/packages/cli/binding/src/cli/help.rs index c0941c429b..b04442cafa 100644 --- a/packages/cli/binding/src/cli/help.rs +++ b/packages/cli/binding/src/cli/help.rs @@ -201,6 +201,7 @@ pub(super) fn print_help() { {bold}install{reset} Install all dependencies, or add packages if package names are provided Options: + -C Run as if vp was started in instead of the current working directory -h, --help Print help" ); } diff --git a/packages/cli/snap-tests/command-helper/snap.txt b/packages/cli/snap-tests/command-helper/snap.txt index 80a8ba3a14..4138af9d2d 100644 --- a/packages/cli/snap-tests/command-helper/snap.txt +++ b/packages/cli/snap-tests/command-helper/snap.txt @@ -22,6 +22,7 @@ Package Manager Commands: install Install all dependencies, or add packages if package names are provided Options: + -C Run as if vp was started in instead of the current working directory -h, --help Print help > vp pack -h # pack help message diff --git a/packages/cli/snap-tests/command-vp-alias/snap.txt b/packages/cli/snap-tests/command-vp-alias/snap.txt index 946aaf4ec1..a23e0883b3 100644 --- a/packages/cli/snap-tests/command-vp-alias/snap.txt +++ b/packages/cli/snap-tests/command-vp-alias/snap.txt @@ -22,6 +22,7 @@ Package Manager Commands: install Install all dependencies, or add packages if package names are provided Options: + -C Run as if vp was started in instead of the current working directory -h, --help Print help > vp run -h # vp run should show help From 4e4475448bb78128947def7189207a2d0b61fe43 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 5 Jul 2026 13:37:24 +0800 Subject: [PATCH 13/33] fix(cli): pack runnable ranking requires a pack block or the tsdown default entry A Vite config without a pack block does not make bare vp pack succeed, so it no longer counts as pack-runnable; document the per-command runnable rules normatively in the RFC and docs. --- docs/guide/monorepo.md | 2 +- packages/cli/binding/src/cli/app_target.rs | 13 ++++++++++--- rfcs/cwd-flag.md | 18 +++++++++++++++++- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/docs/guide/monorepo.md b/docs/guide/monorepo.md index 2c20a0155c..da9b6e711a 100644 --- a/docs/guide/monorepo.md +++ b/docs/guide/monorepo.md @@ -197,7 +197,7 @@ error: `vp build` at the workspace root needs a target package. Or run every package's build script: vp run -r build ``` -Packages that look runnable for the command (an `index.html` or `vite.config.*` for `dev` / `build` / `preview`, a library entry for `pack`) are ranked first in both the picker and the listing. +Packages that look runnable for the command are ranked first in both the picker and the listing: a `vite.config.*` or root `index.html` for `dev` / `build` / `preview`, and a `pack` config block or tsdown's default `src/index.ts` entry for `pack`. ### Targeting a package with `-C` diff --git a/packages/cli/binding/src/cli/app_target.rs b/packages/cli/binding/src/cli/app_target.rs index 34376a7a66..bff587b99f 100644 --- a/packages/cli/binding/src/cli/app_target.rs +++ b/packages/cli/binding/src/cli/app_target.rs @@ -56,11 +56,18 @@ fn is_bare(args: &[String]) -> bool { /// Heuristic ranking signal: does `dir` look runnable for `command`? /// Used for ordering and single-candidate auto-selection, never for hiding. +/// The rules are documented in rfcs/cwd-flag.md ("The likely-runnable +/// heuristic"); keep both in sync. fn looks_runnable(dir: &AbsolutePathBuf, command: &str) -> bool { - let has_vite_config = vite_static_config::has_config_file(dir); match command { - "pack" => has_vite_config || dir.as_path().join("src/index.ts").is_file(), - _ => has_vite_config || dir.as_path().join("index.html").is_file(), + // Bare `vp pack` succeeds when the config declares a `pack` block or + // tsdown's default entry exists; a Vite config without `pack` does + // not make a package packable. + "pack" => { + vite_static_config::resolve_static_config(dir).get("pack").is_some() + || dir.as_path().join("src/index.ts").is_file() + } + _ => vite_static_config::has_config_file(dir) || dir.as_path().join("index.html").is_file(), } } diff --git a/rfcs/cwd-flag.md b/rfcs/cwd-flag.md index 9b69124230..6234aeb911 100644 --- a/rfcs/cwd-flag.md +++ b/rfcs/cwd-flag.md @@ -276,11 +276,27 @@ The global binary also resolves the local `vite-plus` install from ``, matc ### Picker contents -- One row per workspace package: name plus relative path. Nothing is filtered out; packages that look runnable for the command (`vite.config.*` or `index.html` for `dev`/`build`/`preview`, a pack config or library entry for `pack`) rank first, then by path, so apps surface at the top while everything stays searchable. +- One row per workspace package: name plus relative path. Nothing is filtered out; likely-runnable packages (rules below) rank first, then by path, so apps surface at the top while everything stays searchable. - Fuzzy search over name and path via `vite_select::fuzzy_match`, paging identical to the task picker. - A runnable workspace root appears as a `(workspace root)` entry, keeping today's "run at root" behavior one keystroke away. - With exactly one likely-runnable package, the picker auto-selects it, printing only the `Selected package:` line and the tip. +### The likely-runnable heuristic + +Used only for ranking and single-candidate auto-select, never to hide a package: a misjudged package still appears in the picker and listing, just lower. Judged per package directory from file existence and static config extraction; nothing is executed, and parent directories never count. + +| Command | A package is likely runnable when | +| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `dev` / `build` / `preview` | its directory directly contains one of Vite's config file names (`vite.config.{js,mjs,ts,cjs,mts,cts}`, the exact list Vite probes), **or** an `index.html` at the package root (Vite's default app entry) | +| `pack` | its `vite.config.*` declares a `pack` block (read via static extraction; a Vite config without `pack` does not count), **or** `src/index.ts` exists (tsdown's only default entry) | + +"Exactly one likely-runnable package" means: after sorting rows runnable-first, the first row is runnable and the second is not. Auto-select additionally requires an interactive terminal. + +Accepted trade-offs, tolerable because the signal never hides anything and a wrong auto-select is immediately visible (the `Selected package:` line, with the `Tip:` line showing the explicit `-C` form): + +- A library whose `vite.config.*` exists only for Vitest or lint settings ranks as runnable for `dev`/`build`/`preview`. A refinement could demote configs whose only top-level keys are tool blocks, via the same static extraction; deferred until it bites in practice. +- An app whose `index.html` lives outside the package root (custom Vite `root`) or whose config is inherited from a parent directory is not ranked first, and never auto-selects. + ### `defaultPackage` config ```ts From 0917cbdf5e9c00ffadff774d96c9afeecde18a72 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 5 Jul 2026 13:39:48 +0800 Subject: [PATCH 14/33] rfc: cite upstream defaults behind the runnable heuristic --- rfcs/cwd-flag.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rfcs/cwd-flag.md b/rfcs/cwd-flag.md index 6234aeb911..5c83cb9a89 100644 --- a/rfcs/cwd-flag.md +++ b/rfcs/cwd-flag.md @@ -290,6 +290,8 @@ Used only for ranking and single-candidate auto-select, never to hide a package: | `dev` / `build` / `preview` | its directory directly contains one of Vite's config file names (`vite.config.{js,mjs,ts,cjs,mts,cts}`, the exact list Vite probes), **or** an `index.html` at the package root (Vite's default app entry) | | `pack` | its `vite.config.*` declares a `pack` block (read via static extraction; a Vite config without `pack` does not count), **or** `src/index.ts` exists (tsdown's only default entry) | +Both file-based signals are upstream defaults, not vp inventions: `index.html` at the project root is Vite's entry point ([index.html and Project Root](https://vite.dev/guide/#index-html-and-project-root)), the config file names are the list Vite resolves ([Configuring Vite](https://vite.dev/config/), mirrored by `vite_static_config::CONFIG_FILE_NAMES` with the upstream source link), and `src/index.ts` is tsdown's default entry when none is configured ([tsdown Entry](https://tsdown.dev/options/entry); `src/features/entry.ts` in tsdown resolves exactly this one path). + "Exactly one likely-runnable package" means: after sorting rows runnable-first, the first row is runnable and the second is not. Auto-select additionally requires an interactive terminal. Accepted trade-offs, tolerable because the signal never hides anything and a wrong auto-select is immediately visible (the `Selected package:` line, with the `Tip:` line showing the explicit `-C` form): From d19e2f5565804573b60ad98bb18e7b9d45abb61f Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 5 Jul 2026 13:49:45 +0800 Subject: [PATCH 15/33] test(snapshot): vp pack auto-select via tsdown default entry Locks in the corrected pack-runnable rule: an app's vite.config.ts without a pack block does not count, so bare vp pack at the root auto-selects the library whose only signal is tsdown's default src/index.ts entry and packs it with no pack config at all. --- .../pack_default_entry/apps/web/index.html | 6 ++++ .../pack_default_entry/apps/web/package.json | 1 + .../apps/web/vite.config.ts | 1 + .../fixtures/pack_default_entry/package.json | 1 + .../packages/lib/package.json | 1 + .../packages/lib/src/index.ts | 3 ++ .../pack_default_entry/snapshots.toml | 14 +++++++++ .../snapshots/pack_default_entry.global.md | 29 +++++++++++++++++++ .../snapshots/pack_default_entry.local.md | 27 +++++++++++++++++ 9 files changed, 83 insertions(+) create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/apps/web/index.html create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/apps/web/package.json create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/apps/web/vite.config.ts create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/package.json create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/packages/lib/package.json create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/packages/lib/src/index.ts create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/snapshots.toml create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/snapshots/pack_default_entry.global.md create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/snapshots/pack_default_entry.local.md diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/apps/web/index.html b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/apps/web/index.html new file mode 100644 index 0000000000..ce6d6280c2 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/apps/web/index.html @@ -0,0 +1,6 @@ + + + +

web

+ + diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/apps/web/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/apps/web/package.json new file mode 100644 index 0000000000..64630869c1 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/apps/web/package.json @@ -0,0 +1 @@ +{ "name": "web", "private": true } diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/apps/web/vite.config.ts b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/apps/web/vite.config.ts new file mode 100644 index 0000000000..ff8b4c5632 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/apps/web/vite.config.ts @@ -0,0 +1 @@ +export default {}; diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/package.json new file mode 100644 index 0000000000..b6e5ea319d --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/package.json @@ -0,0 +1 @@ +{ "name": "pack-default-entry", "private": true, "workspaces": ["apps/*", "packages/*"] } diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/packages/lib/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/packages/lib/package.json new file mode 100644 index 0000000000..20968dbb63 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/packages/lib/package.json @@ -0,0 +1 @@ +{ "name": "lib", "private": true, "type": "module" } diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/packages/lib/src/index.ts b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/packages/lib/src/index.ts new file mode 100644 index 0000000000..a1398e8d76 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/packages/lib/src/index.ts @@ -0,0 +1,3 @@ +export function lib(name: string): string { + return `lib ${name}`; +} diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/snapshots.toml new file mode 100644 index 0000000000..ff6e7598fb --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/snapshots.toml @@ -0,0 +1,14 @@ +[[case]] +name = "pack_default_entry" +vp = ["local", "global"] +comment = """ +Bare vp pack at the root auto-selects the only pack-runnable package: the +library whose sole signal is tsdown's default src/index.ts entry. The app's +vite.config.ts has no pack block, so it does not count as pack-runnable +(rfcs/cwd-flag.md, "The likely-runnable heuristic"); tsdown then packs via +its default entry with no pack config at all. +""" +steps = [ + ["vp", "pack"], + { argv = ["vpt", "list-dir", "packages/lib/dist"], comment = "output lands in the auto-selected library" }, +] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/snapshots/pack_default_entry.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/snapshots/pack_default_entry.global.md new file mode 100644 index 0000000000..6522060e18 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/snapshots/pack_default_entry.global.md @@ -0,0 +1,29 @@ +# pack_default_entry + +Bare vp pack at the root auto-selects the only pack-runnable package: the +library whose sole signal is tsdown's default src/index.ts entry. The app's +vite.config.ts has no pack block, so it does not count as pack-runnable +(rfcs/cwd-flag.md, "The likely-runnable heuristic"); tsdown then packs via +its default entry with no pack config at all. + +## `vp pack` + +``` +VITE+ - The Unified Toolchain for the Web + +Selected package: lib (packages/lib) +Tip: run this directly with `vp -C packages/lib pack` +ℹ entry: src/index.ts +ℹ Build start +ℹ dist/index.mjs 0.10 kB │ gzip: 0.11 kB +ℹ 1 files, total: 0.10 kB +✔ Build complete in +``` + +## `vpt list-dir packages/lib/dist` + +output lands in the auto-selected library + +``` +index.mjs +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/snapshots/pack_default_entry.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/snapshots/pack_default_entry.local.md new file mode 100644 index 0000000000..fda05bd181 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/snapshots/pack_default_entry.local.md @@ -0,0 +1,27 @@ +# pack_default_entry + +Bare vp pack at the root auto-selects the only pack-runnable package: the +library whose sole signal is tsdown's default src/index.ts entry. The app's +vite.config.ts has no pack block, so it does not count as pack-runnable +(rfcs/cwd-flag.md, "The likely-runnable heuristic"); tsdown then packs via +its default entry with no pack config at all. + +## `vp pack` + +``` +Selected package: lib (packages/lib) +Tip: run this directly with `vp -C packages/lib pack` +ℹ entry: src/index.ts +ℹ Build start +ℹ dist/index.mjs 0.10 kB │ gzip: 0.11 kB +ℹ 1 files, total: 0.10 kB +✔ Build complete in +``` + +## `vpt list-dir packages/lib/dist` + +output lands in the auto-selected library + +``` +index.mjs +``` From 0587d5573e8ad8bf8ffed2f52e87d34aa4a38530 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 5 Jul 2026 15:03:16 +0800 Subject: [PATCH 16/33] test(snapshot): single-package repos bypass elicitation Bare vp build and vp pack in a standalone repo (no workspaces field) run in place even in an interactive terminal: no picker, no auto-select line, no listing. Guards resolution-order step 6 against regressions. --- .../fixtures/single_package/index.html | 6 +++++ .../fixtures/single_package/package.json | 1 + .../fixtures/single_package/snapshots.toml | 19 +++++++++++++++ .../snapshots/build_in_place.global.md | 19 +++++++++++++++ .../snapshots/build_in_place.local.md | 17 ++++++++++++++ .../snapshots/pack_in_place.global.md | 23 +++++++++++++++++++ .../snapshots/pack_in_place.local.md | 21 +++++++++++++++++ .../fixtures/single_package/src/index.ts | 3 +++ 8 files changed, 109 insertions(+) create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/index.html create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/package.json create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots.toml create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/build_in_place.global.md create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/build_in_place.local.md create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/pack_in_place.global.md create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/pack_in_place.local.md create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/src/index.ts diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/index.html b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/index.html new file mode 100644 index 0000000000..d6845ec898 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/index.html @@ -0,0 +1,6 @@ + + + +

single package

+ + diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/package.json new file mode 100644 index 0000000000..10db26f25c --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/package.json @@ -0,0 +1 @@ +{ "name": "single-package", "private": true, "type": "module" } diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots.toml new file mode 100644 index 0000000000..dfcf4a5f86 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots.toml @@ -0,0 +1,19 @@ +[[case]] +name = "build_in_place" +vp = ["local", "global"] +comment = """ +Regression guard: a single-package repo (no workspaces field) never goes +through target elicitation. Bare vp build runs in place even in an +interactive terminal: no picker, no Selected line, no listing +(rfcs/cwd-flag.md, resolution order step "anywhere else"). +""" +steps = [["vp", "build"]] + +[[case]] +name = "pack_in_place" +vp = ["local", "global"] +comment = "Same guard for vp pack: the standalone library packs in place via tsdown's default entry." +steps = [ + ["vp", "pack"], + { argv = ["vpt", "list-dir", "dist"], comment = "output lands in the repo itself" }, +] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/build_in_place.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/build_in_place.global.md new file mode 100644 index 0000000000..c705698087 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/build_in_place.global.md @@ -0,0 +1,19 @@ +# build_in_place + +Regression guard: a single-package repo (no workspaces field) never goes +through target elicitation. Bare vp build runs in place even in an +interactive terminal: no picker, no Selected line, no listing +(rfcs/cwd-flag.md, resolution order step "anywhere else"). + +## `vp build` + +``` +VITE+ - The Unified Toolchain for the Web + +vite building client environment for production... +✓ 2 modules transformed. +computing gzip size... +dist/index.html 0.07 kB │ gzip: 0.08 kB + +✓ built in +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/build_in_place.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/build_in_place.local.md new file mode 100644 index 0000000000..45324a3307 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/build_in_place.local.md @@ -0,0 +1,17 @@ +# build_in_place + +Regression guard: a single-package repo (no workspaces field) never goes +through target elicitation. Bare vp build runs in place even in an +interactive terminal: no picker, no Selected line, no listing +(rfcs/cwd-flag.md, resolution order step "anywhere else"). + +## `vp build` + +``` +vite building client environment for production... +✓ 2 modules transformed. +computing gzip size... +dist/index.html 0.07 kB │ gzip: 0.08 kB + +✓ built in +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/pack_in_place.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/pack_in_place.global.md new file mode 100644 index 0000000000..11f79da87c --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/pack_in_place.global.md @@ -0,0 +1,23 @@ +# pack_in_place + +Same guard for vp pack: the standalone library packs in place via tsdown's default entry. + +## `vp pack` + +``` +VITE+ - The Unified Toolchain for the Web + +ℹ entry: src/index.ts +ℹ Build start +ℹ dist/index.mjs 0.11 kB │ gzip: 0.11 kB +ℹ 1 files, total: 0.11 kB +✔ Build complete in +``` + +## `vpt list-dir dist` + +output lands in the repo itself + +``` +index.mjs +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/pack_in_place.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/pack_in_place.local.md new file mode 100644 index 0000000000..724fce6a1d --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/pack_in_place.local.md @@ -0,0 +1,21 @@ +# pack_in_place + +Same guard for vp pack: the standalone library packs in place via tsdown's default entry. + +## `vp pack` + +``` +ℹ entry: src/index.ts +ℹ Build start +ℹ dist/index.mjs 0.11 kB │ gzip: 0.11 kB +ℹ 1 files, total: 0.11 kB +✔ Build complete in +``` + +## `vpt list-dir dist` + +output lands in the repo itself + +``` +index.mjs +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/src/index.ts b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/src/index.ts new file mode 100644 index 0000000000..56e894462d --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/src/index.ts @@ -0,0 +1,3 @@ +export function single(name: string): string { + return `single ${name}`; +} From 9161625eccda37434193291f9be80d9392e0c248 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 5 Jul 2026 21:49:08 +0800 Subject: [PATCH 17/33] refactor(cli): apply cleanup review to app-target elicitation - exempt -v from elicitation: Vite and tsdown are cac-based and use -v, --version, so vp build -v now reaches the tool instead of the picker; the help/version predicate moves next to the other foreign-flag knowledge in help.rs - add FieldMap::contains for presence checks without cloning the field value; use it in the pack-runnable rule - store PackageRow name/path as Str, deleting the Str->String->Str round-trip; flatten the picker branch and the AppTarget dispatch into one exhaustive match; inline the one-line should_enable_picker wrapper; test and comment tidying --- crates/vite_global_cli/src/command_picker.rs | 6 +-- crates/vite_static_config/src/lib.rs | 11 +++++ packages/cli/binding/src/cli/app_target.rs | 51 ++++++++++---------- packages/cli/binding/src/cli/help.rs | 7 +++ packages/cli/binding/src/cli/mod.rs | 6 +-- 5 files changed, 47 insertions(+), 34 deletions(-) diff --git a/crates/vite_global_cli/src/command_picker.rs b/crates/vite_global_cli/src/command_picker.rs index 540faef750..590f36c941 100644 --- a/crates/vite_global_cli/src/command_picker.rs +++ b/crates/vite_global_cli/src/command_picker.rs @@ -117,7 +117,7 @@ const COMMANDS: &[CommandEntry] = &[ pub fn pick_top_level_command_if_interactive( cwd: &AbsolutePath, ) -> io::Result { - if !should_enable_picker() { + if !vite_shared::is_interactive_terminal() { return Ok(TopLevelCommandPick::Skipped); } @@ -129,10 +129,6 @@ pub fn pick_top_level_command_if_interactive( }) } -fn should_enable_picker() -> bool { - vite_shared::is_interactive_terminal() -} - fn run_picker(command_order: &[usize]) -> io::Result> { let mut stdout = io::stdout(); let mut selected_position = 0usize; diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index d5633752cd..3de1590dd7 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -69,6 +69,17 @@ impl FieldMap { } } } + + /// Presence check with the same semantics as [`get`](Self::get) returning + /// `Some`, without cloning the field's value. In an open map (spread or + /// computed keys) every key may exist and therefore counts as present. + #[must_use] + pub fn contains(&self, key: &str) -> bool { + match &self.0 { + FieldMapInner::Closed(map) => map.contains_key(key), + FieldMapInner::Open(_) => true, + } + } } /// Config file names to try, in priority order. diff --git a/packages/cli/binding/src/cli/app_target.rs b/packages/cli/binding/src/cli/app_target.rs index bff587b99f..5a23adf639 100644 --- a/packages/cli/binding/src/cli/app_target.rs +++ b/packages/cli/binding/src/cli/app_target.rs @@ -26,8 +26,8 @@ pub(super) enum AppTarget { } struct PackageRow { - name: String, - path: String, + name: vite_str::Str, + path: vite_str::Str, absolute: AbsolutePathBuf, runnable: bool, } @@ -49,9 +49,8 @@ fn app_command_parts(subcommand: &SynthesizableSubcommand) -> Option<(&'static s /// disables elicitation; help/version requests are answered by the underlying /// tool and must never be redirected. fn is_bare(args: &[String]) -> bool { - args.iter().all(|arg| { - arg.starts_with('-') && !matches!(arg.as_str(), "-h" | "--help" | "-V" | "--version") - }) + args.iter() + .all(|arg| arg.starts_with('-') && !super::help::is_app_tool_help_or_version_flag(arg)) } /// Heuristic ranking signal: does `dir` look runnable for `command`? @@ -62,9 +61,11 @@ fn looks_runnable(dir: &AbsolutePathBuf, command: &str) -> bool { match command { // Bare `vp pack` succeeds when the config declares a `pack` block or // tsdown's default entry exists; a Vite config without `pack` does - // not make a package packable. + // not make a package packable. `contains` deliberately counts configs + // with spreads (`pack` may exist behind them): the right direction + // for a never-hide ranking signal. "pack" => { - vite_static_config::resolve_static_config(dir).get("pack").is_some() + vite_static_config::resolve_static_config(dir).contains("pack") || dir.as_path().join("src/index.ts").is_file() } _ => vite_static_config::has_config_file(dir) || dir.as_path().join("index.html").is_file(), @@ -107,8 +108,8 @@ fn run_package_picker(command: &str, rows: &[PackageRow]) -> Result &rows[index], - None => return Ok(AppTarget::Exit(ExitStatus(130))), - } + let picked = if single_runnable { Some(0) } else { run_package_picker(command, &rows)? }; + let Some(index) = picked else { + return Ok(AppTarget::Exit(ExitStatus(130))); }; + let row = &rows[index]; + // Deliberately stdout via println!: these lines belong to the + // command's own output stream, like the tool output that follows. println!("Selected package: {} ({})", row.name, row.path); println!("Tip: run this directly with `vp -C {} {command}`", row.path); return Ok(AppTarget::Dir(row.absolute.clone())); @@ -244,18 +244,19 @@ mod tests { assert!(!is_bare(&to_args(&["--help"]))); assert!(!is_bare(&to_args(&["-h"]))); assert!(!is_bare(&to_args(&["--watch", "--version"]))); + // Vite and tsdown are cac-based and use `-v` for version. + assert!(!is_bare(&to_args(&["-v"]))); } #[test] fn only_app_commands_elicit() { - let args = vec![]; for (subcommand, expected) in [ - (SynthesizableSubcommand::Dev { args: args.clone() }, Some("dev")), - (SynthesizableSubcommand::Build { args: args.clone() }, Some("build")), - (SynthesizableSubcommand::Preview { args: args.clone() }, Some("preview")), - (SynthesizableSubcommand::Pack { args: args.clone() }, Some("pack")), - (SynthesizableSubcommand::Lint { args: args.clone() }, None), - (SynthesizableSubcommand::Test { args: args.clone() }, None), + (SynthesizableSubcommand::Dev { args: vec![] }, Some("dev")), + (SynthesizableSubcommand::Build { args: vec![] }, Some("build")), + (SynthesizableSubcommand::Preview { args: vec![] }, Some("preview")), + (SynthesizableSubcommand::Pack { args: vec![] }, Some("pack")), + (SynthesizableSubcommand::Lint { args: vec![] }, None), + (SynthesizableSubcommand::Test { args: vec![] }, None), ] { assert_eq!(app_command_parts(&subcommand).map(|(name, _)| name), expected); } diff --git a/packages/cli/binding/src/cli/help.rs b/packages/cli/binding/src/cli/help.rs index b04442cafa..1c9fa5b0c5 100644 --- a/packages/cli/binding/src/cli/help.rs +++ b/packages/cli/binding/src/cli/help.rs @@ -36,6 +36,13 @@ fn is_vitest_help_flag(arg: &str) -> bool { matches!(arg, "-h" | "--help") } +/// Help/version flags of the forwarded app tools (Vite and tsdown are +/// cac-based: `-v, --version`; vp's own clap surface uses `-V`). Requests for +/// these must always reach the tool, never target elicitation. +pub(super) fn is_app_tool_help_or_version_flag(arg: &str) -> bool { + matches!(arg, "-h" | "--help" | "-v" | "-V" | "--version") +} + fn is_vitest_watch_flag(arg: &str) -> bool { matches!(arg, "-w" | "--watch") } diff --git a/packages/cli/binding/src/cli/mod.rs b/packages/cli/binding/src/cli/mod.rs index de0c501139..fa9dc01214 100644 --- a/packages/cli/binding/src/cli/mod.rs +++ b/packages/cli/binding/src/cli/mod.rs @@ -51,12 +51,10 @@ async fn execute_direct_subcommand( // (defaultPackage, package listing); the command then runs as if invoked // in the resolved directory (rfcs/cwd-flag.md). let target = app_target::resolve_app_target(&subcommand, cwd)?; - if let app_target::AppTarget::Exit(status) = target { - return Ok(status); - } let cwd = match &target { + app_target::AppTarget::Exit(status) => return Ok(*status), app_target::AppTarget::Dir(dir) => dir, - _ => cwd, + app_target::AppTarget::CurrentDir => cwd, }; let (workspace_root, _) = vite_workspace::find_workspace_root(cwd)?; From 13f0d88a4fe5757a540b5e0aa889787f94b92197 Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 5 Jul 2026 22:42:09 +0800 Subject: [PATCH 18/33] test(snapshot): re-record for byte-size redaction --- .../snapshots/auto_select.global.md | 2 +- .../snapshots/auto_select.local.md | 2 +- .../snapshots/default_package.global.md | 4 ++-- .../snapshots/default_package.local.md | 4 ++-- .../snapshots/picker_select.global.md | 2 +- .../snapshots/picker_select.local.md | 2 +- .../fixtures/cwd_flag/snapshots/cwd_flag.global.md | 12 ++++++------ .../fixtures/cwd_flag/snapshots/cwd_flag.local.md | 12 ++++++------ .../snapshots/pack_default_entry.global.md | 4 ++-- .../snapshots/pack_default_entry.local.md | 4 ++-- .../snapshots/build_in_place.global.md | 2 +- .../single_package/snapshots/build_in_place.local.md | 2 +- .../single_package/snapshots/pack_in_place.global.md | 4 ++-- .../single_package/snapshots/pack_in_place.local.md | 4 ++-- 14 files changed, 30 insertions(+), 30 deletions(-) diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/snapshots/auto_select.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/snapshots/auto_select.global.md index edc6dc5023..46cc92d7a2 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/snapshots/auto_select.global.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/snapshots/auto_select.global.md @@ -15,7 +15,7 @@ Tip: run this directly with `vp -C apps/web build` vite building client environment for production... ✓ 2 modules transformed. computing gzip size... -dist/index.html 0.06 kB │ gzip: 0.06 kB +dist/index.html kB │ gzip: kB ✓ built in ``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/snapshots/auto_select.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/snapshots/auto_select.local.md index a33f993f6c..ceca69331c 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/snapshots/auto_select.local.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_auto_select/snapshots/auto_select.local.md @@ -13,7 +13,7 @@ Tip: run this directly with `vp -C apps/web build` vite building client environment for production... ✓ 2 modules transformed. computing gzip size... -dist/index.html 0.06 kB │ gzip: 0.06 kB +dist/index.html kB │ gzip: kB ✓ built in ``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package.global.md index 8d1f236531..3e187a1d9b 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package.global.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package.global.md @@ -12,8 +12,8 @@ VITE+ - The Unified Toolchain for the Web note: vp pack: using ./packages/ui (defaultPackage) ℹ entry: src/index.ts ℹ Build start -ℹ dist/index.mjs 0.10 kB │ gzip: 0.11 kB -ℹ 1 files, total: 0.10 kB +ℹ dist/index.mjs kB │ gzip: kB +ℹ 1 files, total: kB ✔ Build complete in ``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package.local.md index 72370924f5..622f391b6c 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package.local.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/default_package.local.md @@ -10,8 +10,8 @@ line and runs in the configured directory (rfcs/cwd-flag.md). note: vp pack: using ./packages/ui (defaultPackage) ℹ entry: src/index.ts ℹ Build start -ℹ dist/index.mjs 0.10 kB │ gzip: 0.11 kB -ℹ 1 files, total: 0.10 kB +ℹ dist/index.mjs kB │ gzip: kB +ℹ 1 files, total: kB ✔ Build complete in ``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/picker_select.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/picker_select.global.md index e6a2ae55ee..a5f8c1fe5e 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/picker_select.global.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/picker_select.global.md @@ -40,7 +40,7 @@ Tip: run this directly with `vp -C apps/web build` vite building client environment for production... ✓ 2 modules transformed. computing gzip size... -dist/index.html 0.06 kB │ gzip: 0.06 kB +dist/index.html kB │ gzip: kB ✓ built in ``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/picker_select.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/picker_select.local.md index e99d948355..c2fd913b4c 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/picker_select.local.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/picker_select.local.md @@ -34,7 +34,7 @@ Tip: run this directly with `vp -C apps/web build` vite building client environment for production... ✓ 2 modules transformed. computing gzip size... -dist/index.html 0.06 kB │ gzip: 0.06 kB +dist/index.html kB │ gzip: kB ✓ built in ``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.global.md index f83ee238ca..1d2f83a56d 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.global.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.global.md @@ -14,8 +14,8 @@ VITE+ - The Unified Toolchain for the Web ℹ entry: src/index.ts ℹ Build start -ℹ dist/index.mjs 0.10 kB │ gzip: 0.11 kB -ℹ 1 files, total: 0.10 kB +ℹ dist/index.mjs kB │ gzip: kB +ℹ 1 files, total: kB ✔ Build complete in ``` @@ -43,8 +43,8 @@ VITE+ - The Unified Toolchain for the Web ℹ entry: src/index.ts ℹ Build start -ℹ dist/index.mjs 0.10 kB │ gzip: 0.11 kB -ℹ 1 files, total: 0.10 kB +ℹ dist/index.mjs kB │ gzip: kB +ℹ 1 files, total: kB ✔ Build complete in ``` @@ -89,8 +89,8 @@ VITE+ - The Unified Toolchain for the Web ℹ entry: packages/hello ℹ Build start -ℹ dist/hello.mjs 0.12 kB │ gzip: 0.12 kB -ℹ 1 files, total: 0.12 kB +ℹ dist/hello.mjs kB │ gzip: kB +ℹ 1 files, total: kB ✔ Build complete in ``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.local.md index 6431f9eac7..c79b90acbc 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.local.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.local.md @@ -12,8 +12,8 @@ errors, and the positional keeps upstream tsdown entry semantics ``` ℹ entry: src/index.ts ℹ Build start -ℹ dist/index.mjs 0.10 kB │ gzip: 0.11 kB -ℹ 1 files, total: 0.10 kB +ℹ dist/index.mjs kB │ gzip: kB +ℹ 1 files, total: kB ✔ Build complete in ``` @@ -39,8 +39,8 @@ the cd form is equivalent ``` ℹ entry: src/index.ts ℹ Build start -ℹ dist/index.mjs 0.10 kB │ gzip: 0.11 kB -ℹ 1 files, total: 0.10 kB +ℹ dist/index.mjs kB │ gzip: kB +ℹ 1 files, total: kB ✔ Build complete in ``` @@ -79,8 +79,8 @@ positional stays a tsdown entry resolved from the invocation directory ``` ℹ entry: packages/hello ℹ Build start -ℹ dist/hello.mjs 0.12 kB │ gzip: 0.12 kB -ℹ 1 files, total: 0.12 kB +ℹ dist/hello.mjs kB │ gzip: kB +ℹ 1 files, total: kB ✔ Build complete in ``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/snapshots/pack_default_entry.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/snapshots/pack_default_entry.global.md index 6522060e18..b52a25aca4 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/snapshots/pack_default_entry.global.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/snapshots/pack_default_entry.global.md @@ -15,8 +15,8 @@ Selected package: lib (packages/lib) Tip: run this directly with `vp -C packages/lib pack` ℹ entry: src/index.ts ℹ Build start -ℹ dist/index.mjs 0.10 kB │ gzip: 0.11 kB -ℹ 1 files, total: 0.10 kB +ℹ dist/index.mjs kB │ gzip: kB +ℹ 1 files, total: kB ✔ Build complete in ``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/snapshots/pack_default_entry.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/snapshots/pack_default_entry.local.md index fda05bd181..27c8175b05 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/snapshots/pack_default_entry.local.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/snapshots/pack_default_entry.local.md @@ -13,8 +13,8 @@ Selected package: lib (packages/lib) Tip: run this directly with `vp -C packages/lib pack` ℹ entry: src/index.ts ℹ Build start -ℹ dist/index.mjs 0.10 kB │ gzip: 0.11 kB -ℹ 1 files, total: 0.10 kB +ℹ dist/index.mjs kB │ gzip: kB +ℹ 1 files, total: kB ✔ Build complete in ``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/build_in_place.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/build_in_place.global.md index c705698087..d4a89a25be 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/build_in_place.global.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/build_in_place.global.md @@ -13,7 +13,7 @@ VITE+ - The Unified Toolchain for the Web vite building client environment for production... ✓ 2 modules transformed. computing gzip size... -dist/index.html 0.07 kB │ gzip: 0.08 kB +dist/index.html kB │ gzip: kB ✓ built in ``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/build_in_place.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/build_in_place.local.md index 45324a3307..acb09f2cf9 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/build_in_place.local.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/build_in_place.local.md @@ -11,7 +11,7 @@ interactive terminal: no picker, no Selected line, no listing vite building client environment for production... ✓ 2 modules transformed. computing gzip size... -dist/index.html 0.07 kB │ gzip: 0.08 kB +dist/index.html kB │ gzip: kB ✓ built in ``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/pack_in_place.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/pack_in_place.global.md index 11f79da87c..9eb6e8bdd1 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/pack_in_place.global.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/pack_in_place.global.md @@ -9,8 +9,8 @@ VITE+ - The Unified Toolchain for the Web ℹ entry: src/index.ts ℹ Build start -ℹ dist/index.mjs 0.11 kB │ gzip: 0.11 kB -ℹ 1 files, total: 0.11 kB +ℹ dist/index.mjs kB │ gzip: kB +ℹ 1 files, total: kB ✔ Build complete in ``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/pack_in_place.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/pack_in_place.local.md index 724fce6a1d..d8ce6886ae 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/pack_in_place.local.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/single_package/snapshots/pack_in_place.local.md @@ -7,8 +7,8 @@ Same guard for vp pack: the standalone library packs in place via tsdown's defau ``` ℹ entry: src/index.ts ℹ Build start -ℹ dist/index.mjs 0.11 kB │ gzip: 0.11 kB -ℹ 1 files, total: 0.11 kB +ℹ dist/index.mjs kB │ gzip: kB +ℹ 1 files, total: kB ✔ Build complete in ``` From 7a3543226c9758f65f13e1cb76d3309da99b85ce Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 5 Jul 2026 23:44:18 +0800 Subject: [PATCH 19/33] fix(cli): address Codex review round 1 - defaultPackage: only an explicitly declared key changes behavior. New FieldMap::get_declared keeps unanalyzable/spread configs from failing every bare app command (they fall through to picker/current-dir); a declared non-static value still errors - consume -C first thing in main, before alias normalization, the no-command picker, and clap: vp -C dir node --version now normalizes like the cd form, bare vp -C dir opens the command picker for dir, and setting the process cwd makes in-process helpers (global add -g file: specs, node version inference) resolve from dir - PTY regression case for the spread-config fall-through; unit tests for parse_leading_chdir and get_declared --- .../app_root_default_package/snapshots.toml | 10 ++++ .../unanalyzable_config_ignored.global.md | 17 ++++++ .../unanalyzable_config_ignored.local.md | 17 ++++++ .../spread/index.html | 6 +++ .../spread/package.json | 1 + .../spread/vite.config.ts | 5 ++ crates/vite_global_cli/src/main.rs | 54 ++++++++++++++++++- crates/vite_static_config/src/lib.rs | 32 +++++++++++ packages/cli/binding/src/cli/app_target.rs | 6 ++- rfcs/cwd-flag.md | 5 +- 10 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/unanalyzable_config_ignored.global.md create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/unanalyzable_config_ignored.local.md create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/spread/index.html create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/spread/package.json create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/spread/vite.config.ts diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots.toml index c68360c535..a8c3646728 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots.toml +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots.toml @@ -16,3 +16,13 @@ name = "default_package_missing" vp = ["local", "global"] comment = "defaultPackage pointing at a missing directory errors before any workspace lookup." steps = [{ argv = ["vp", "build"], cwd = "missing" }] + +[[case]] +name = "unanalyzable_config_ignored" +vp = ["local", "global"] +comment = """ +Regression guard for spread/unanalyzable configs: a config that only parses +as an open map might hide defaultPackage behind the spread, but that must +not fail the command. Bare vp build falls through and runs in place. +""" +steps = [{ argv = ["vp", "build"], cwd = "spread", tty = false }] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/unanalyzable_config_ignored.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/unanalyzable_config_ignored.global.md new file mode 100644 index 0000000000..ea3ed4a926 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/unanalyzable_config_ignored.global.md @@ -0,0 +1,17 @@ +# unanalyzable_config_ignored + +Regression guard for spread/unanalyzable configs: a config that only parses +as an open map might hide defaultPackage behind the spread, but that must +not fail the command. Bare vp build falls through and runs in place. + +## `cd spread && vp build` + +``` +vite building client environment for production... + transforming...✓ 2 modules transformed. +rendering chunks... +computing gzip size... +dist/index.html kB │ gzip: kB + +✓ built in +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/unanalyzable_config_ignored.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/unanalyzable_config_ignored.local.md new file mode 100644 index 0000000000..ea3ed4a926 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/unanalyzable_config_ignored.local.md @@ -0,0 +1,17 @@ +# unanalyzable_config_ignored + +Regression guard for spread/unanalyzable configs: a config that only parses +as an open map might hide defaultPackage behind the spread, but that must +not fail the command. Bare vp build falls through and runs in place. + +## `cd spread && vp build` + +``` +vite building client environment for production... + transforming...✓ 2 modules transformed. +rendering chunks... +computing gzip size... +dist/index.html kB │ gzip: kB + +✓ built in +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/spread/index.html b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/spread/index.html new file mode 100644 index 0000000000..32b40097ee --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/spread/index.html @@ -0,0 +1,6 @@ + + + +

spread

+ + diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/spread/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/spread/package.json new file mode 100644 index 0000000000..eba4d545fe --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/spread/package.json @@ -0,0 +1 @@ +{ "name": "spread-config", "private": true } diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/spread/vite.config.ts b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/spread/vite.config.ts new file mode 100644 index 0000000000..aaf0089da5 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/spread/vite.config.ts @@ -0,0 +1,5 @@ +const base = {}; + +export default { + ...base, +}; diff --git a/crates/vite_global_cli/src/main.rs b/crates/vite_global_cli/src/main.rs index 814a51c335..b737fdf1dd 100644 --- a/crates/vite_global_cli/src/main.rs +++ b/crates/vite_global_cli/src/main.rs @@ -39,6 +39,23 @@ use crate::cli::{ try_parse_args_from_with_options, }; +/// Parse a leading `-C ` / `-C=` / `-C` as the first user +/// argument, mirroring clap's short-option grammar (and bin.ts on the local +/// path). Returns the directory and how many argv tokens it consumed. +fn parse_leading_chdir(args: &[String]) -> Option<(String, usize)> { + let first = args.get(1)?; + if first == "-C" { + return args.get(2).map(|dir| (dir.clone(), 2)); + } + if let Some(rest) = first.strip_prefix("-C") { + let dir = rest.strip_prefix('=').unwrap_or(rest); + if !dir.is_empty() { + return Some((dir.to_string(), 1)); + } + } + None +} + /// Normalize CLI arguments: /// - `vp list ...` / `vp ls ...` → `vp pm list ...` /// - `vp rebuild ...` → `vp pm rebuild ...` @@ -323,7 +340,7 @@ async fn main() -> ExitCode { } // Normal CLI mode - get current working directory - let cwd = match vite_path::current_dir() { + let mut cwd = match vite_path::current_dir() { Ok(path) => path, Err(e) => { output::error(&format!("Failed to get current directory: {e}")); @@ -331,6 +348,24 @@ async fn main() -> ExitCode { } }; + // Consume a leading `-C ` here, before anything inspects args or cwd, + // so alias normalization, the command picker, and in-process helpers all + // behave exactly as if vp had been started in . Setting the process + // cwd (before any command logic runs) keeps `vite_path::current_dir()` + // callers deep inside command implementations equivalent to the cd form. + if let Some((dir, consumed)) = parse_leading_chdir(&args) { + cwd = cwd.join(&dir).clean(); + if !cwd.as_path().is_dir() { + output::raw_stderr(&format!("directory not found: {dir}")); + return ExitCode::FAILURE; + } + if let Err(e) = std::env::set_current_dir(cwd.as_path()) { + output::error(&format!("Failed to change directory to {dir}: {e}")); + return ExitCode::FAILURE; + } + args.drain(1..=consumed); + } + if args.len() == 1 { match command_picker::pick_top_level_command_if_interactive(&cwd) { Ok(command_picker::TopLevelCommandPick::Selected(selection)) => { @@ -469,6 +504,23 @@ mod tests { assert_eq!(normalized, s(&["vp", "env", "exec", "node", "script.js", "foo", "--flag"])); } + #[test] + fn parse_leading_chdir_accepts_all_clap_short_forms() { + // `main` consumes these before alias normalization and the picker, so + // `vp -C dir node --version` normalizes like `cd dir && vp node ...`. + assert_eq!(parse_leading_chdir(&s(&["vp", "-C", "apps/web", "dev"])), some_dir(2)); + assert_eq!(parse_leading_chdir(&s(&["vp", "-Capps/web", "dev"])), some_dir(1)); + assert_eq!(parse_leading_chdir(&s(&["vp", "-C=apps/web", "dev"])), some_dir(1)); + // Missing or empty value falls through to clap's own error. + assert_eq!(parse_leading_chdir(&s(&["vp", "-C"])), None); + assert_eq!(parse_leading_chdir(&s(&["vp", "-C="])), None); + assert_eq!(parse_leading_chdir(&s(&["vp", "dev"])), None); + } + + fn some_dir(consumed: usize) -> Option<(String, usize)> { + Some(("apps/web".to_string(), consumed)) + } + #[test] fn normalize_args_rewrites_bare_vp_node() { let input = s(&["vp", "node"]); diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index 3de1590dd7..2bae6547a6 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -80,6 +80,20 @@ impl FieldMap { FieldMapInner::Open(_) => true, } } + + /// Like [`get`](Self::get), but only for fields the config explicitly + /// declares. Unlike `get`, an open map (spread, computed keys, or an + /// unanalyzable config) does NOT report undeclared keys as `NonStatic`: + /// they return `None`. Use this when a merely-possible field must not + /// change behavior (e.g. `defaultPackage`, where erroring on every + /// unanalyzable config would break unrelated commands). + #[must_use] + pub fn get_declared(&self, key: &str) -> Option { + match &self.0 { + FieldMapInner::Closed(map) => map.get(key).cloned(), + FieldMapInner::Open(map) => map.get(key).map(|v| FieldValue::Json(v.clone())), + } + } } /// Config file names to try, in priority order. @@ -458,6 +472,24 @@ mod tests { assert_eq!(map.get(key), Some(FieldValue::Json(expected))); } + #[test] + fn get_declared_ignores_undeclared_keys_in_open_maps() { + // A spread makes the map open: `get` reports any key as possibly + // present, but `get_declared` only reports explicitly written ones. + let map = parse("const base = {};\nexport default { ...base, run: { tasks: {} } };"); + assert_eq!(map.get("defaultPackage"), Some(FieldValue::NonStatic)); + assert_eq!(map.get_declared("defaultPackage"), None); + assert!(map.get_declared("run").is_some()); + + // Closed map: explicitly declared non-static values still surface. + let map = parse("export default { defaultPackage: process.env.DIR };"); + assert_eq!(map.get_declared("defaultPackage"), Some(FieldValue::NonStatic)); + + // Closed map without the key: definitively absent either way. + let map = parse("export default { run: {} };"); + assert_eq!(map.get_declared("defaultPackage"), None); + } + /// Shorthand for asserting a field is `NonStatic`. fn assert_non_static(map: &FieldMap, key: &str) { assert_eq!( diff --git a/packages/cli/binding/src/cli/app_target.rs b/packages/cli/binding/src/cli/app_target.rs index 5a23adf639..4f8431c091 100644 --- a/packages/cli/binding/src/cli/app_target.rs +++ b/packages/cli/binding/src/cli/app_target.rs @@ -75,12 +75,16 @@ fn looks_runnable(dir: &AbsolutePathBuf, command: &str) -> bool { /// `defaultPackage` from the `vite.config.*` in `cwd`, read via static /// extraction so it works at roots without a vite-plus install (non-workspace /// framework repos). The value must be a static string literal. +/// +/// `get_declared` keeps this to explicitly written fields: a config that is +/// unanalyzable or hides fields behind a spread simply falls through to the +/// picker/current-dir resolution instead of failing every bare app command. fn resolve_default_package(command: &str, cwd: &AbsolutePathBuf) -> Option { let fail = |msg: &str| { output::error(msg); Some(AppTarget::Exit(ExitStatus(1))) }; - match vite_static_config::resolve_static_config(cwd).get("defaultPackage") { + match vite_static_config::resolve_static_config(cwd).get_declared("defaultPackage") { Some(vite_static_config::FieldValue::Json(serde_json::Value::String(dir))) => { let target = cwd.join(&dir).clean(); if !target.as_path().is_dir() { diff --git a/rfcs/cwd-flag.md b/rfcs/cwd-flag.md index 5c83cb9a89..7560b0119d 100644 --- a/rfcs/cwd-flag.md +++ b/rfcs/cwd-flag.md @@ -265,7 +265,7 @@ For every vp command: vp -C [args...] === cd && vp [args...] ``` -The child's spawn cwd is ``, so config lookup, `.env` loading, `process.cwd()` reads in configs and plugins, and relative CLI args all behave as if the user had `cd`'d. The Rust layers never mutate their own cwd; the one exception is the local `vp` bin, which applies `-C` by changing its process cwd at startup before any dispatch, indistinguishable from having been started in ``. +The child's spawn cwd is ``, so config lookup, `.env` loading, `process.cwd()` reads in configs and plugins, and relative CLI args all behave as if the user had `cd`'d. Both entry points apply `-C` by changing their own process cwd at startup, before any argument normalization, picker, or command logic runs, which is indistinguishable from having been started in ``: the global binary consumes the flag first thing in `main` (so command aliases, the no-command picker, and in-process helpers that read the process cwd are all covered), and the local `vp` bin does the same before dispatch. The global binary also resolves the local `vite-plus` install from ``, matching `cd` exactly; through the package's own `vp` bin the executing CLI is already chosen, so there the invariant assumes a single Vite+ version per workspace (the supported monorepo model). @@ -312,7 +312,8 @@ export default defineConfig({ - Type: `string`, a single directory. A per-command map can come later if real demand appears. - Consulted when a bare app command runs in the directory containing the root config: a workspace root, or a non-workspace repo root. The non-workspace shape has no package list, so `defaultPackage` is the only mechanism that covers it. An explicit `-C` always wins. - A missing directory errors: `defaultPackage points to a missing directory: ./frontend`. -- Read via static extraction (`vite_static_config` + the loader in `packages/cli/binding/src/cli/handler.rs`), like `run` config. At a non-workspace root there is no install to execute the config, so the file must work unexecuted: a plain default-export object with a static string value. If extraction fails and no local install can execute the config, vp errors and names the offending construct. +- Read via static extraction (`vite_static_config` + the loader in `packages/cli/binding/src/cli/handler.rs`), like `run` config. At a non-workspace root there is no install to execute the config, so the file must work unexecuted: a plain default-export object with a static string value. +- Only an explicitly declared `defaultPackage` changes behavior. A declared but non-static value (e.g. `process.env.DIR`) errors; a config that is unanalyzable or hides fields behind a spread is treated as not declaring the key and falls through to the picker or current-dir resolution, so an exotic config can never break unrelated bare commands. ## Decisions From 8b43f1d55f06b1cd7251ed63689b0f7263b6c7cb Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 6 Jul 2026 00:56:01 +0800 Subject: [PATCH 20/33] fix(cli): address Codex review round 2 - known value-taking flags (--port 4000, --mode production) keep an invocation bare so root elicitation still applies; unknown/optional-value flags keep the conservative fallback - refresh POSIX PWD wherever -C or an elicited target applies (global main, local bin, binding env for retargeted tools) so process.env.PWD readers match the cd form - a runnable workspace root stays selectable as a '.' row (picker, listing, auto-select), with a PTY regression fixture - open field maps now record explicit NonStatic declarations, so a spread followed by defaultPackage: process.env.X errors instead of being silently ignored - apply -C before shell completion so cwd-sensitive completers suggest from the target directory --- .../app_root_runnable_root/index.html | 6 ++ .../app_root_runnable_root/package.json | 1 + .../packages/ui/package.json | 1 + .../packages/ui/src/index.ts | 1 + .../app_root_runnable_root/snapshots.toml | 15 ++++ .../snapshots/auto_select_root.global.md | 20 +++++ .../snapshots/auto_select_root.local.md | 18 +++++ .../snapshots/listing_includes_root.global.md | 18 +++++ .../snapshots/listing_includes_root.local.md | 18 +++++ crates/vite_global_cli/src/main.rs | 19 +++++ crates/vite_static_config/src/lib.rs | 36 +++++---- packages/cli/binding/src/cli/app_target.rs | 75 +++++++++++++++---- packages/cli/binding/src/cli/mod.rs | 15 +++- packages/cli/src/bin.ts | 4 + rfcs/cwd-flag.md | 4 +- 15 files changed, 216 insertions(+), 35 deletions(-) create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/index.html create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/package.json create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/packages/ui/package.json create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/packages/ui/src/index.ts create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/snapshots.toml create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/snapshots/auto_select_root.global.md create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/snapshots/auto_select_root.local.md create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/snapshots/listing_includes_root.global.md create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/snapshots/listing_includes_root.local.md diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/index.html b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/index.html new file mode 100644 index 0000000000..31acf91c54 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/index.html @@ -0,0 +1,6 @@ + + + +

root app

+ + diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/package.json new file mode 100644 index 0000000000..78cbc31491 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/package.json @@ -0,0 +1 @@ +{ "name": "runnable-root", "private": true, "workspaces": ["packages/*"] } diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/packages/ui/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/packages/ui/package.json new file mode 100644 index 0000000000..70bb6aab17 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/packages/ui/package.json @@ -0,0 +1 @@ +{ "name": "ui", "private": true, "type": "module", "main": "src/index.ts" } diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/packages/ui/src/index.ts b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/packages/ui/src/index.ts new file mode 100644 index 0000000000..62c987f2f1 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/packages/ui/src/index.ts @@ -0,0 +1 @@ +export const ui = true; diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/snapshots.toml new file mode 100644 index 0000000000..5527b6f19c --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/snapshots.toml @@ -0,0 +1,15 @@ +[[case]] +name = "auto_select_root" +vp = ["local", "global"] +comment = """ +A workspace root that is itself the only runnable app stays selectable: bare +vp build auto-selects the root (shown as `.`) and runs in place, matching +today's behavior for root apps (rfcs/cwd-flag.md, picker contents). +""" +steps = [["vp", "build"]] + +[[case]] +name = "listing_includes_root" +vp = ["local", "global"] +comment = "The non-interactive listing offers the runnable root as a `.` row." +steps = [{ argv = ["vp", "build"], tty = false }] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/snapshots/auto_select_root.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/snapshots/auto_select_root.global.md new file mode 100644 index 0000000000..fd8309b677 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/snapshots/auto_select_root.global.md @@ -0,0 +1,20 @@ +# auto_select_root + +A workspace root that is itself the only runnable app stays selectable: bare +vp build auto-selects the root (shown as `.`) and runs in place, matching +today's behavior for root apps (rfcs/cwd-flag.md, picker contents). + +## `vp build` + +``` +VITE+ - The Unified Toolchain for the Web + +Selected package: runnable-root (.) +Tip: run this directly with `vp -C . build` +vite building client environment for production... +✓ 2 modules transformed. +computing gzip size... +dist/index.html kB │ gzip: kB + +✓ built in +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/snapshots/auto_select_root.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/snapshots/auto_select_root.local.md new file mode 100644 index 0000000000..ab3a3b3fe0 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/snapshots/auto_select_root.local.md @@ -0,0 +1,18 @@ +# auto_select_root + +A workspace root that is itself the only runnable app stays selectable: bare +vp build auto-selects the root (shown as `.`) and runs in place, matching +today's behavior for root apps (rfcs/cwd-flag.md, picker contents). + +## `vp build` + +``` +Selected package: runnable-root (.) +Tip: run this directly with `vp -C . build` +vite building client environment for production... +✓ 2 modules transformed. +computing gzip size... +dist/index.html kB │ gzip: kB + +✓ built in +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/snapshots/listing_includes_root.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/snapshots/listing_includes_root.global.md new file mode 100644 index 0000000000..03152c6f55 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/snapshots/listing_includes_root.global.md @@ -0,0 +1,18 @@ +# listing_includes_root + +The non-interactive listing offers the runnable root as a `.` row. + +## `vp build` + +**Exit code:** 1 + +``` +error: `vp build` at the workspace root needs a target package. + + Packages in this workspace: + runnable-root . + ui packages/ui + + Pass a directory: vp -C . build + Or run every package's build script: vp run -r build +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/snapshots/listing_includes_root.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/snapshots/listing_includes_root.local.md new file mode 100644 index 0000000000..03152c6f55 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_runnable_root/snapshots/listing_includes_root.local.md @@ -0,0 +1,18 @@ +# listing_includes_root + +The non-interactive listing offers the runnable root as a `.` row. + +## `vp build` + +**Exit code:** 1 + +``` +error: `vp build` at the workspace root needs a target package. + + Packages in this workspace: + runnable-root . + ui packages/ui + + Pass a directory: vp -C . build + Or run every package's build script: vp run -r build +``` diff --git a/crates/vite_global_cli/src/main.rs b/crates/vite_global_cli/src/main.rs index b737fdf1dd..581297561f 100644 --- a/crates/vite_global_cli/src/main.rs +++ b/crates/vite_global_cli/src/main.rs @@ -325,6 +325,18 @@ async fn main() -> ExitCode { } // Handle shell completion + // `complete()` exits before the normal `-C` handling below runs, so apply + // the target directory here too: cwd-sensitive completers (run tasks) + // should suggest from , where the completed command will run. + if env::var_os("VP_COMPLETE").is_some() + && let Some((dir, _)) = parse_leading_chdir(&args) + && let Ok(current) = vite_path::current_dir() + { + let target = current.join(&dir).clean(); + if target.as_path().is_dir() { + let _ = std::env::set_current_dir(target.as_path()); + } + } CompleteEnv::with_factory(command_with_help).var("VP_COMPLETE").complete(); // Check for shim mode (invoked as node, npm, or npx) @@ -363,6 +375,13 @@ async fn main() -> ExitCode { output::error(&format!("Failed to change directory to {dir}: {e}")); return ExitCode::FAILURE; } + // Keep the POSIX PWD in sync, like a real `cd`: Node tools commonly + // read process.env.PWD. + #[cfg(unix)] + // SAFETY: single-threaded startup, before any command logic runs. + unsafe { + std::env::set_var("PWD", cwd.as_path()); + } args.drain(1..=consumed); } diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index 2bae6547a6..fbcc4de2f5 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -29,12 +29,13 @@ pub enum FieldValue { /// The map is exhaustive: every field is accounted for, absent keys do not exist. /// /// - [`FieldMapInner::Open`] — the object had at least one spread or computed-key -/// property. The map contains only [`serde_json::Value`] entries for keys -/// explicitly declared **after** the last such entry. Absent keys may exist via -/// the spread and are treated as [`FieldValue::NonStatic`] by [`FieldMap::get`]. +/// property. The map contains entries only for keys explicitly declared +/// **after** the last such entry (both static and `NonStatic` values). Absent +/// keys may exist via the spread and are treated as [`FieldValue::NonStatic`] +/// by [`FieldMap::get`], but not by [`FieldMap::get_declared`]. enum FieldMapInner { Closed(FxHashMap, FieldValue>), - Open(FxHashMap, serde_json::Value>), + Open(FxHashMap, FieldValue>), } /// Extracted fields from a vite config object. @@ -57,15 +58,15 @@ impl FieldMap { /// /// - [`Closed`](FieldMapInner::Closed): returns the stored value, or `None` /// if the field is definitively absent. - /// - [`Open`](FieldMapInner::Open): returns the stored `Json` value if - /// explicitly declared after the last spread/computed key, or - /// `Some(NonStatic)` for any other key (it may exist in the spread). + /// - [`Open`](FieldMapInner::Open): returns the stored value if explicitly + /// declared after the last spread/computed key, or `Some(NonStatic)` for + /// any other key (it may exist in the spread). #[must_use] pub fn get(&self, key: &str) -> Option { match &self.0 { FieldMapInner::Closed(map) => map.get(key).cloned(), FieldMapInner::Open(map) => { - Some(map.get(key).map_or(FieldValue::NonStatic, |v| FieldValue::Json(v.clone()))) + Some(map.get(key).cloned().unwrap_or(FieldValue::NonStatic)) } } } @@ -91,7 +92,7 @@ impl FieldMap { pub fn get_declared(&self, key: &str) -> Option { match &self.0 { FieldMapInner::Closed(map) => map.get(key).cloned(), - FieldMapInner::Open(map) => map.get(key).map(|v| FieldValue::Json(v.clone())), + FieldMapInner::Open(map) => map.get(key).cloned(), } } } @@ -364,11 +365,12 @@ fn extract_object_fields(obj: &oxc_ast::ast::ObjectExpression<'_>) -> FieldMap { map.insert(Box::from(key.as_ref()), value); } FieldMapInner::Open(map) => { - // Only Json values are meaningful in Open — NonStatic is already implied - // for any absent key, so there's no need to record it explicitly. - if let Some(json) = expr_to_json(&prop.value) { - map.insert(Box::from(key.as_ref()), json); - } + // Record explicit declarations, including NonStatic ones: + // `get_declared` must distinguish a written `key: expr` from a + // key that merely might exist behind the spread. + let value = + expr_to_json(&prop.value).map_or(FieldValue::NonStatic, FieldValue::Json); + map.insert(Box::from(key.as_ref()), value); } } } @@ -485,6 +487,12 @@ mod tests { let map = parse("export default { defaultPackage: process.env.DIR };"); assert_eq!(map.get_declared("defaultPackage"), Some(FieldValue::NonStatic)); + // Open map: an explicit non-static declaration after a spread is + // still a declaration, not an unknown. + let map = + parse("const base = {};\nexport default { ...base, defaultPackage: process.env.DIR };"); + assert_eq!(map.get_declared("defaultPackage"), Some(FieldValue::NonStatic)); + // Closed map without the key: definitively absent either way. let map = parse("export default { run: {} };"); assert_eq!(map.get_declared("defaultPackage"), None); diff --git a/packages/cli/binding/src/cli/app_target.rs b/packages/cli/binding/src/cli/app_target.rs index 4f8431c091..9b4b2034a0 100644 --- a/packages/cli/binding/src/cli/app_target.rs +++ b/packages/cli/binding/src/cli/app_target.rs @@ -44,13 +44,46 @@ fn app_command_parts(subcommand: &SynthesizableSubcommand) -> Option<(&'static s } } -/// Bare = no positional target and no help-like flag. A non-flag token may be -/// a flag value (`--port 3000`), so any non-flag argument conservatively -/// disables elicitation; help/version requests are answered by the underlying -/// tool and must never be redirected. +/// Flags of the app tools (Vite dev/build/preview and tsdown) that always +/// consume the next argv token as their value. Space-separated values of +/// these flags are not positional targets. Flags with optional values +/// (`--host`, `--sourcemap`, `--minify`, ...) are deliberately absent: a +/// token after them stays ambiguous and keeps the conservative fallback. +const VALUE_TAKING_FLAGS: &[&str] = &[ + "-c", + "--config", + "-m", + "--mode", + "-l", + "--logLevel", + "--port", + "--base", + "--outDir", + "-d", + "--out-dir", + "--target", + "-f", + "--format", + "--platform", +]; + +/// Bare = no positional target and no help-like flag. Values of known +/// value-taking flags (`--port 3000`) are skipped; any other non-flag token +/// may be a positional target and conservatively disables elicitation. +/// Help/version requests are answered by the underlying tool and must never +/// be redirected. fn is_bare(args: &[String]) -> bool { - args.iter() - .all(|arg| arg.starts_with('-') && !super::help::is_app_tool_help_or_version_flag(arg)) + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + if !arg.starts_with('-') || super::help::is_app_tool_help_or_version_flag(arg) { + return false; + } + if VALUE_TAKING_FLAGS.contains(&arg.as_str()) { + // Consume the flag's value; a missing value is the tool's error. + iter.next(); + } + } + true } /// Heuristic ranking signal: does `dir` look runnable for `command`? @@ -182,15 +215,22 @@ pub(super) fn resolve_app_target( vite_workspace::load_package_graph(&workspace_root).map_err(|e| Error::Anyhow(e.into()))?; let mut rows: Vec = graph .node_weights() - .filter(|info| !info.path.as_str().is_empty()) - .map(|info| { + .filter_map(|info| { let absolute = info.absolute_path.to_absolute_path_buf(); - PackageRow { + let runnable = looks_runnable(&absolute, command); + let is_root = info.path.as_str().is_empty(); + // The root itself is a valid target only when it looks runnable; + // `.` keeps the -C hint and the selection working there. + if is_root && !runnable { + return None; + } + let path = if is_root { "." } else { info.path.as_str() }; + Some(PackageRow { name: info.package_json.name.clone(), - path: vite_str::Str::from(info.path.as_str()), - runnable: looks_runnable(&absolute, command), + path: vite_str::Str::from(path), + runnable, absolute, - } + }) }) .collect(); if rows.is_empty() { @@ -241,9 +281,14 @@ mod tests { assert!(is_bare(&to_args(&["-w", "--minify"]))); // A positional target disables elicitation. assert!(!is_bare(&to_args(&["apps/web"]))); - // A flag value is indistinguishable from a positional without knowing - // the tool's flag arity, so it conservatively counts as non-bare. - assert!(!is_bare(&to_args(&["--port", "3000"]))); + // Known value-taking flags consume their value. + assert!(is_bare(&to_args(&["--port", "3000"]))); + assert!(is_bare(&to_args(&["--mode", "production", "--minify"]))); + assert!(is_bare(&to_args(&["--port=3000"]))); + // A token after an unknown or optional-value flag is ambiguous with a + // positional target, so it conservatively counts as non-bare. + assert!(!is_bare(&to_args(&["--watch", "apps/web"]))); + assert!(!is_bare(&to_args(&["--host", "0.0.0.0"]))); // Help/version requests go to the underlying tool, never elicitation. assert!(!is_bare(&to_args(&["--help"]))); assert!(!is_bare(&to_args(&["-h"]))); diff --git a/packages/cli/binding/src/cli/mod.rs b/packages/cli/binding/src/cli/mod.rs index fa9dc01214..7c52671e8f 100644 --- a/packages/cli/binding/src/cli/mod.rs +++ b/packages/cli/binding/src/cli/mod.rs @@ -66,11 +66,18 @@ async fn execute_direct_subcommand( SubcommandResolver::new(Arc::clone(&workspace_path)) }; - let envs: Arc, Arc>> = Arc::new( - std::env::vars_os() + let envs: Arc, Arc>> = Arc::new({ + let mut envs: FxHashMap, Arc> = std::env::vars_os() .map(|(k, v)| (Arc::from(k.as_os_str()), Arc::from(v.as_os_str()))) - .collect(), - ); + .collect(); + // The tool runs with `cwd` as its working directory; keep the POSIX + // PWD consistent with it, like a real `cd` (matters when elicitation + // retargeted the command to another package). + if cfg!(unix) { + envs.insert(Arc::from(OsStr::new("PWD")), Arc::from(cwd.as_path().as_os_str())); + } + envs + }); let envs = envs_with_explicit_package_manager_path(cwd, envs).await?; let status = match subcommand { diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index e3b782e18c..1bdfb0b601 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -57,6 +57,10 @@ if (args[0]?.startsWith('-C')) { process.exit(1); } process.chdir(target); + if (process.platform !== 'win32') { + // Keep the POSIX PWD in sync, like a real `cd`. + process.env.PWD = target; + } args = args.slice(inline ? 1 : 2); process.argv = process.argv.slice(0, 2).concat(args); } diff --git a/rfcs/cwd-flag.md b/rfcs/cwd-flag.md index 7560b0119d..a0fcd3c566 100644 --- a/rfcs/cwd-flag.md +++ b/rfcs/cwd-flag.md @@ -246,7 +246,7 @@ Rejected alternatives: repurposing the app-command positional to mean "run there ### Target directory resolution -An app command invocation is **bare** when it has no `-C` and no positional target (no Vite `[root]`, no pack entries); flags alone keep it bare. For `vp dev` / `build` / `preview` / `pack`, the target directory is resolved in this order: +An app command invocation is **bare** when it has no `-C` and no positional target (no Vite `[root]`, no pack entries). Flags keep it bare, including known value-taking flags together with their values (`--port 3000`, `--mode production`); a token following an unknown or optional-value flag is ambiguous with a positional target and conservatively counts as one. For `vp dev` / `build` / `preview` / `pack`, the target directory is resolved in this order: 1. **`-C `**: run there. Never triggers the picker. 2. **Positional target present**: forward as today, upstream semantics, vp does not interfere. @@ -265,7 +265,7 @@ For every vp command: vp -C [args...] === cd && vp [args...] ``` -The child's spawn cwd is ``, so config lookup, `.env` loading, `process.cwd()` reads in configs and plugins, and relative CLI args all behave as if the user had `cd`'d. Both entry points apply `-C` by changing their own process cwd at startup, before any argument normalization, picker, or command logic runs, which is indistinguishable from having been started in ``: the global binary consumes the flag first thing in `main` (so command aliases, the no-command picker, and in-process helpers that read the process cwd are all covered), and the local `vp` bin does the same before dispatch. +The child's spawn cwd is ``, so config lookup, `.env` loading, `process.cwd()` reads in configs and plugins, and relative CLI args all behave as if the user had `cd`'d. The POSIX `PWD` environment variable is refreshed too (by both entry points and for elicitation-retargeted tools), so `process.env.PWD` readers match the `cd` form. Both entry points apply `-C` by changing their own process cwd at startup, before any argument normalization, picker, or command logic runs, which is indistinguishable from having been started in ``: the global binary consumes the flag first thing in `main` (so command aliases, the no-command picker, and in-process helpers that read the process cwd are all covered), and the local `vp` bin does the same before dispatch. The global binary also resolves the local `vite-plus` install from ``, matching `cd` exactly; through the package's own `vp` bin the executing CLI is already chosen, so there the invariant assumes a single Vite+ version per workspace (the supported monorepo model). From 415b100e47dbe226241c7c822e5440171365369c Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 6 Jul 2026 01:25:19 +0800 Subject: [PATCH 21/33] fix(cli): address Codex review round 3 - vpr -C works on both entry points: the global vpr dispatch consumes the flag before delegating to run, and the local bin/vpr shim inserts 'run' after the -C tokens - the workspace root needs a stronger runnable signal than members: a shared root vite.config.ts (lint/fmt/tasks) no longer makes the root an app target for dev/build/preview; root index.html still does - pack ranking requires an explicitly declared pack block (get_declared), so a spread-only config cannot be auto-selected into a tsdown run; FieldMap::contains is gone with its last caller - add --assetsDir/--assetsInlineLimit/--filter to the required-value flag list per vite's CLI docs Regression fixtures: a shared config at the listing fixture's root and a spread-only package in pack_default_entry, both pinned by unchanged snapshots. --- .../fixtures/app_root_listing/vite.config.ts | 4 +++ .../packages/spread-only/package.json | 1 + .../packages/spread-only/vite.config.ts | 5 ++++ crates/vite_global_cli/src/commands/vpr.rs | 23 ++++++++++++++- crates/vite_global_cli/src/main.rs | 29 ++++++++++--------- crates/vite_static_config/src/lib.rs | 11 ------- packages/cli/bin/vpr | 6 +++- packages/cli/binding/src/cli/app_target.rs | 25 +++++++++++----- rfcs/cwd-flag.md | 10 +++---- 9 files changed, 74 insertions(+), 40 deletions(-) create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/vite.config.ts create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/packages/spread-only/package.json create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/packages/spread-only/vite.config.ts diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/vite.config.ts b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/vite.config.ts new file mode 100644 index 0000000000..9b4b8b8cd3 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/vite.config.ts @@ -0,0 +1,4 @@ +// Shared workspace config (lint/fmt style): must NOT make the root an app target. +export default { + lint: {}, +}; diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/packages/spread-only/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/packages/spread-only/package.json new file mode 100644 index 0000000000..318af1703a --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/packages/spread-only/package.json @@ -0,0 +1 @@ +{ "name": "spread-only", "private": true } diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/packages/spread-only/vite.config.ts b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/packages/spread-only/vite.config.ts new file mode 100644 index 0000000000..aaf0089da5 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/pack_default_entry/packages/spread-only/vite.config.ts @@ -0,0 +1,5 @@ +const base = {}; + +export default { + ...base, +}; diff --git a/crates/vite_global_cli/src/commands/vpr.rs b/crates/vite_global_cli/src/commands/vpr.rs index e043b9c8b0..e83e4f2194 100644 --- a/crates/vite_global_cli/src/commands/vpr.rs +++ b/crates/vite_global_cli/src/commands/vpr.rs @@ -10,11 +10,32 @@ use vite_shared::output; /// /// Called from shim dispatch when `argv[0]` is `vpr`. pub async fn execute_vpr(args: &[String], cwd: &AbsolutePath) -> i32 { + // `vpr -C ` mirrors `vp -C run `: consume the + // global flag before treating the rest as run arguments. + let mut args = args; + let mut cwd_buf = cwd.to_absolute_path_buf(); + if let Some((dir, consumed)) = crate::parse_leading_chdir(args) { + cwd_buf = cwd_buf.join(&dir).clean(); + if !cwd_buf.as_path().is_dir() { + output::raw_stderr(&format!("directory not found: {dir}")); + return 1; + } + if std::env::set_current_dir(cwd_buf.as_path()).is_err() { + output::error(&format!("Failed to change directory to {dir}")); + return 1; + } + #[cfg(unix)] + // SAFETY: single-threaded startup, before any command logic runs. + unsafe { + std::env::set_var("PWD", cwd_buf.as_path()); + } + args = &args[consumed..]; + } + if crate::help::maybe_print_unified_delegate_help("run", args, true) { return 0; } - let cwd_buf = cwd.to_absolute_path_buf(); match super::delegate::execute(cwd_buf, "run", args).await { Ok(status) => status.code().unwrap_or(1), Err(e) => { diff --git a/crates/vite_global_cli/src/main.rs b/crates/vite_global_cli/src/main.rs index 581297561f..2521e6fe16 100644 --- a/crates/vite_global_cli/src/main.rs +++ b/crates/vite_global_cli/src/main.rs @@ -39,13 +39,14 @@ use crate::cli::{ try_parse_args_from_with_options, }; -/// Parse a leading `-C ` / `-C=` / `-C` as the first user -/// argument, mirroring clap's short-option grammar (and bin.ts on the local -/// path). Returns the directory and how many argv tokens it consumed. -fn parse_leading_chdir(args: &[String]) -> Option<(String, usize)> { - let first = args.get(1)?; +/// Parse a leading `-C ` / `-C=` / `-C` at the start of the +/// user arguments (argv without the binary name), mirroring clap's +/// short-option grammar (and bin.ts on the local path). Returns the +/// directory and how many tokens it consumed. +pub(crate) fn parse_leading_chdir(user_args: &[String]) -> Option<(String, usize)> { + let first = user_args.first()?; if first == "-C" { - return args.get(2).map(|dir| (dir.clone(), 2)); + return user_args.get(1).map(|dir| (dir.clone(), 2)); } if let Some(rest) = first.strip_prefix("-C") { let dir = rest.strip_prefix('=').unwrap_or(rest); @@ -329,7 +330,7 @@ async fn main() -> ExitCode { // the target directory here too: cwd-sensitive completers (run tasks) // should suggest from , where the completed command will run. if env::var_os("VP_COMPLETE").is_some() - && let Some((dir, _)) = parse_leading_chdir(&args) + && let Some((dir, _)) = parse_leading_chdir(&args[1..]) && let Ok(current) = vite_path::current_dir() { let target = current.join(&dir).clean(); @@ -365,7 +366,7 @@ async fn main() -> ExitCode { // behave exactly as if vp had been started in . Setting the process // cwd (before any command logic runs) keeps `vite_path::current_dir()` // callers deep inside command implementations equivalent to the cd form. - if let Some((dir, consumed)) = parse_leading_chdir(&args) { + if let Some((dir, consumed)) = parse_leading_chdir(&args[1..]) { cwd = cwd.join(&dir).clean(); if !cwd.as_path().is_dir() { output::raw_stderr(&format!("directory not found: {dir}")); @@ -527,13 +528,13 @@ mod tests { fn parse_leading_chdir_accepts_all_clap_short_forms() { // `main` consumes these before alias normalization and the picker, so // `vp -C dir node --version` normalizes like `cd dir && vp node ...`. - assert_eq!(parse_leading_chdir(&s(&["vp", "-C", "apps/web", "dev"])), some_dir(2)); - assert_eq!(parse_leading_chdir(&s(&["vp", "-Capps/web", "dev"])), some_dir(1)); - assert_eq!(parse_leading_chdir(&s(&["vp", "-C=apps/web", "dev"])), some_dir(1)); + assert_eq!(parse_leading_chdir(&s(&["-C", "apps/web", "dev"])), some_dir(2)); + assert_eq!(parse_leading_chdir(&s(&["-Capps/web", "dev"])), some_dir(1)); + assert_eq!(parse_leading_chdir(&s(&["-C=apps/web", "dev"])), some_dir(1)); // Missing or empty value falls through to clap's own error. - assert_eq!(parse_leading_chdir(&s(&["vp", "-C"])), None); - assert_eq!(parse_leading_chdir(&s(&["vp", "-C="])), None); - assert_eq!(parse_leading_chdir(&s(&["vp", "dev"])), None); + assert_eq!(parse_leading_chdir(&s(&["-C"])), None); + assert_eq!(parse_leading_chdir(&s(&["-C="])), None); + assert_eq!(parse_leading_chdir(&s(&["dev"])), None); } fn some_dir(consumed: usize) -> Option<(String, usize)> { diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index fbcc4de2f5..2727c33a62 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -71,17 +71,6 @@ impl FieldMap { } } - /// Presence check with the same semantics as [`get`](Self::get) returning - /// `Some`, without cloning the field's value. In an open map (spread or - /// computed keys) every key may exist and therefore counts as present. - #[must_use] - pub fn contains(&self, key: &str) -> bool { - match &self.0 { - FieldMapInner::Closed(map) => map.contains_key(key), - FieldMapInner::Open(_) => true, - } - } - /// Like [`get`](Self::get), but only for fields the config explicitly /// declares. Unlike `get`, an open map (spread, computed keys, or an /// unanalyzable config) does NOT report undeclared keys as `NonStatic`: diff --git a/packages/cli/bin/vpr b/packages/cli/bin/vpr index 1848381e60..8f354d6e14 100755 --- a/packages/cli/bin/vpr +++ b/packages/cli/bin/vpr @@ -5,5 +5,9 @@ if (module.enableCompileCache) { module.enableCompileCache(); } -process.argv.splice(2, 0, 'run'); +// Insert `run` after a leading global `-C ` so `vpr -C ` +// behaves like `vp -C run ` (bin.ts consumes -C before dispatch). +const first = process.argv[2]; +const chdirTokens = first === '-C' ? 2 : first?.startsWith('-C') && first.length > 2 ? 1 : 0; +process.argv.splice(2 + chdirTokens, 0, 'run'); await import('../dist/bin.js'); diff --git a/packages/cli/binding/src/cli/app_target.rs b/packages/cli/binding/src/cli/app_target.rs index 9b4b2034a0..3537b86b24 100644 --- a/packages/cli/binding/src/cli/app_target.rs +++ b/packages/cli/binding/src/cli/app_target.rs @@ -59,6 +59,9 @@ const VALUE_TAKING_FLAGS: &[&str] = &[ "--port", "--base", "--outDir", + "--assetsDir", + "--assetsInlineLimit", + "--filter", "-d", "--out-dir", "--target", @@ -90,17 +93,22 @@ fn is_bare(args: &[String]) -> bool { /// Used for ordering and single-candidate auto-selection, never for hiding. /// The rules are documented in rfcs/cwd-flag.md ("The likely-runnable /// heuristic"); keep both in sync. -fn looks_runnable(dir: &AbsolutePathBuf, command: &str) -> bool { +/// +/// The workspace root needs a stronger signal than member packages: a shared +/// root `vite.config.ts` (lint/fmt/tasks) is the normal monorepo setup and +/// must not make the root look like an app, or auto-select would run the +/// silent root build this feature exists to prevent. +fn looks_runnable(dir: &AbsolutePathBuf, command: &str, is_root: bool) -> bool { match command { - // Bare `vp pack` succeeds when the config declares a `pack` block or - // tsdown's default entry exists; a Vite config without `pack` does - // not make a package packable. `contains` deliberately counts configs - // with spreads (`pack` may exist behind them): the right direction - // for a never-hide ranking signal. + // Bare `vp pack` succeeds when the config explicitly declares a + // `pack` block or tsdown's default entry exists. A spread that only + // might contain `pack` does not count: auto-select acts on this + // signal, so a false positive runs tsdown in a non-packable package. "pack" => { - vite_static_config::resolve_static_config(dir).contains("pack") + vite_static_config::resolve_static_config(dir).get_declared("pack").is_some() || dir.as_path().join("src/index.ts").is_file() } + _ if is_root => dir.as_path().join("index.html").is_file(), _ => vite_static_config::has_config_file(dir) || dir.as_path().join("index.html").is_file(), } } @@ -217,8 +225,8 @@ pub(super) fn resolve_app_target( .node_weights() .filter_map(|info| { let absolute = info.absolute_path.to_absolute_path_buf(); - let runnable = looks_runnable(&absolute, command); let is_root = info.path.as_str().is_empty(); + let runnable = looks_runnable(&absolute, command, is_root); // The root itself is a valid target only when it looks runnable; // `.` keeps the -C hint and the selection working there. if is_root && !runnable { @@ -284,6 +292,7 @@ mod tests { // Known value-taking flags consume their value. assert!(is_bare(&to_args(&["--port", "3000"]))); assert!(is_bare(&to_args(&["--mode", "production", "--minify"]))); + assert!(is_bare(&to_args(&["--assetsDir", "assets"]))); assert!(is_bare(&to_args(&["--port=3000"]))); // A token after an unknown or optional-value flag is ambiguous with a // positional target, so it conservatively counts as non-bare. diff --git a/rfcs/cwd-flag.md b/rfcs/cwd-flag.md index a0fcd3c566..97548bebd1 100644 --- a/rfcs/cwd-flag.md +++ b/rfcs/cwd-flag.md @@ -278,17 +278,17 @@ The global binary also resolves the local `vite-plus` install from ``, matc - One row per workspace package: name plus relative path. Nothing is filtered out; likely-runnable packages (rules below) rank first, then by path, so apps surface at the top while everything stays searchable. - Fuzzy search over name and path via `vite_select::fuzzy_match`, paging identical to the task picker. -- A runnable workspace root appears as a `(workspace root)` entry, keeping today's "run at root" behavior one keystroke away. +- A runnable workspace root appears as a `.` entry, keeping today's "run at root" behavior one keystroke away. The root needs a stronger signal than member packages: an `index.html` for `dev`/`build`/`preview` (a shared root config for lint/fmt/tasks is the normal monorepo setup and does not make the root an app), and the usual explicit-`pack`-or-default-entry rule for `pack`. - With exactly one likely-runnable package, the picker auto-selects it, printing only the `Selected package:` line and the tip. ### The likely-runnable heuristic Used only for ranking and single-candidate auto-select, never to hide a package: a misjudged package still appears in the picker and listing, just lower. Judged per package directory from file existence and static config extraction; nothing is executed, and parent directories never count. -| Command | A package is likely runnable when | -| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `dev` / `build` / `preview` | its directory directly contains one of Vite's config file names (`vite.config.{js,mjs,ts,cjs,mts,cts}`, the exact list Vite probes), **or** an `index.html` at the package root (Vite's default app entry) | -| `pack` | its `vite.config.*` declares a `pack` block (read via static extraction; a Vite config without `pack` does not count), **or** `src/index.ts` exists (tsdown's only default entry) | +| Command | A package is likely runnable when | +| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `dev` / `build` / `preview` | its directory directly contains one of Vite's config file names (`vite.config.{js,mjs,ts,cjs,mts,cts}`, the exact list Vite probes), **or** an `index.html` at the package root (Vite's default app entry) | +| `pack` | its `vite.config.*` explicitly declares a `pack` block (read via static extraction; neither a config without `pack` nor one that merely might contain it behind a spread counts), **or** `src/index.ts` exists (tsdown's only default entry) | Both file-based signals are upstream defaults, not vp inventions: `index.html` at the project root is Vite's entry point ([index.html and Project Root](https://vite.dev/guide/#index-html-and-project-root)), the config file names are the list Vite resolves ([Configuring Vite](https://vite.dev/config/), mirrored by `vite_static_config::CONFIG_FILE_NAMES` with the upstream source link), and `src/index.ts` is tsdown's default entry when none is configured ([tsdown Entry](https://tsdown.dev/options/entry); `src/features/entry.ts` in tsdown resolves exactly this one path). From 5f043e2cf743f209611fe1b68b28633ace1862ee Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 6 Jul 2026 01:49:35 +0800 Subject: [PATCH 22/33] fix(cli): address Codex review round 4 - vp/vpr script commands with a leading -C run verbatim instead of being parsed as CLIArgs (which has no global flags): the spawned binary applies the directory change exactly like a direct invocation - bare app commands in scripts get the same workspace-root target elicitation as direct invocations via a pure needs_elicitation predicate: the handler spawns the real binary (which elicits identically) instead of synthesizing a silent root run - add --configLoader/--config-loader/--tsconfig/--log-level/ --deps.never-bundle to the required-value flag list - PTY regression cases: a root script 'build: vp build' fails with the listing through vp run, and a 'vp -C' script runs in the target package Shell-integration template gaps (env-use wrapper, vpr completers) are tracked separately in #2056. --- .../fixtures/app_root_listing/package.json | 12 +++++++- .../fixtures/app_root_listing/snapshots.toml | 10 +++++++ .../script_at_root_elicits.global.md | 23 ++++++++++++++ .../snapshots/script_at_root_elicits.local.md | 23 ++++++++++++++ .../fixtures/cwd_flag/package.json | 11 ++++++- .../fixtures/cwd_flag/snapshots.toml | 1 + .../cwd_flag/snapshots/cwd_flag.global.md | 14 +++++++++ .../cwd_flag/snapshots/cwd_flag.local.md | 10 +++++++ packages/cli/binding/src/cli/app_target.rs | 30 +++++++++++++++++++ packages/cli/binding/src/cli/handler.rs | 19 ++++++++++++ 10 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/script_at_root_elicits.global.md create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/script_at_root_elicits.local.md diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/package.json index 0807f1400d..b2d7158d1d 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/package.json +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/package.json @@ -1 +1,11 @@ -{ "name": "app-root-listing", "private": true, "workspaces": ["apps/*", "packages/*"] } +{ + "name": "app-root-listing", + "private": true, + "workspaces": [ + "apps/*", + "packages/*" + ], + "scripts": { + "build": "vp build" + } +} diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots.toml index 929d887a56..d6e648fed8 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots.toml +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots.toml @@ -38,3 +38,13 @@ steps = [ { "write-key" = "ctrl-c" }, ] }, ] + +[[case]] +name = "script_at_root_elicits" +vp = ["local", "global"] +comment = """ +A root package script "build": "vp build" run through vp run gets the same +target elicitation as a direct bare invocation: the spawned vp prints the +listing and the task fails instead of silently building the root. +""" +steps = [{ argv = ["vp", "run", "build"], tty = false }] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/script_at_root_elicits.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/script_at_root_elicits.global.md new file mode 100644 index 0000000000..9b5daa0fa8 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/script_at_root_elicits.global.md @@ -0,0 +1,23 @@ +# script_at_root_elicits + +A root package script "build": "vp build" run through vp run gets the same +target elicitation as a direct bare invocation: the spawned vp prints the +listing and the task fails instead of silently building the root. + +## `vp run build` + +**Exit code:** 1 + +``` +$ vp build ⊘ cache disabled + +error: `vp build` at the workspace root needs a target package. + + Packages in this workspace: + admin apps/admin + web apps/web + ui packages/ui + + Pass a directory: vp -C apps/admin build + Or run every package's build script: vp run -r build +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/script_at_root_elicits.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/script_at_root_elicits.local.md new file mode 100644 index 0000000000..9b5daa0fa8 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/script_at_root_elicits.local.md @@ -0,0 +1,23 @@ +# script_at_root_elicits + +A root package script "build": "vp build" run through vp run gets the same +target elicitation as a direct bare invocation: the spawned vp prints the +listing and the task fails instead of silently building the root. + +## `vp run build` + +**Exit code:** 1 + +``` +$ vp build ⊘ cache disabled + +error: `vp build` at the workspace root needs a target package. + + Packages in this workspace: + admin apps/admin + web apps/web + ui packages/ui + + Pass a directory: vp -C apps/admin build + Or run every package's build script: vp run -r build +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/package.json index ed6c6904e3..dea0bba909 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/package.json +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/package.json @@ -1 +1,10 @@ -{ "name": "cwd-flag", "private": true, "workspaces": ["packages/*"] } +{ + "name": "cwd-flag", + "private": true, + "workspaces": [ + "packages/*" + ], + "scripts": { + "where:hello": "vp -C packages/hello run where" + } +} diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots.toml index 26ac6867a7..b3fdb7e167 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots.toml +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots.toml @@ -17,4 +17,5 @@ steps = [ { argv = ["vp", "-C", "packages/missing", "build"], comment = "missing directory errors" }, { argv = ["vp", "pack", "packages/hello"], comment = "positional stays a tsdown entry resolved from the invocation directory" }, { argv = ["vpt", "list-dir", "dist"], comment = "upstream semantics: output lands at the invocation directory" }, + { argv = ["vp", "run", "where:hello"], comment = "a script whose command starts with vp -C runs verbatim and honors the directory" }, ] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.global.md index 1d2f83a56d..0059c25184 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.global.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.global.md @@ -101,3 +101,17 @@ upstream semantics: output lands at the invocation directory ``` hello.mjs ``` + +## `vp run where:hello` + +a script whose command starts with vp -C runs verbatim and honors the directory + +``` +VITE+ - The Unified Toolchain for the Web + +$ vp -C packages/hello run where ⊘ cache disabled +VITE+ - The Unified Toolchain for the Web + +~/packages/hello$ node -e "console.log('cwd base: ' + require('node:path').basename(process.cwd()))" ⊘ cache disabled +cwd base: hello +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.local.md index c79b90acbc..e58f7923a2 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.local.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.local.md @@ -91,3 +91,13 @@ upstream semantics: output lands at the invocation directory ``` hello.mjs ``` + +## `vp run where:hello` + +a script whose command starts with vp -C runs verbatim and honors the directory + +``` +$ vp -C packages/hello run where ⊘ cache disabled +~/packages/hello$ node -e "console.log('cwd base: ' + require('node:path').basename(process.cwd()))" ⊘ cache disabled +cwd base: hello +``` diff --git a/packages/cli/binding/src/cli/app_target.rs b/packages/cli/binding/src/cli/app_target.rs index 3537b86b24..ff4b741f5b 100644 --- a/packages/cli/binding/src/cli/app_target.rs +++ b/packages/cli/binding/src/cli/app_target.rs @@ -62,6 +62,11 @@ const VALUE_TAKING_FLAGS: &[&str] = &[ "--assetsDir", "--assetsInlineLimit", "--filter", + "--configLoader", + "--config-loader", + "--tsconfig", + "--log-level", + "--deps.never-bundle", "-d", "--out-dir", "--target", @@ -190,6 +195,31 @@ fn run_package_picker(command: &str, rows: &[PackageRow]) -> Result bool { + let Some((_, args)) = app_command_parts(subcommand) else { + return false; + }; + if !is_bare(args) { + return false; + } + if vite_static_config::resolve_static_config(cwd).get_declared("defaultPackage").is_some() { + return true; + } + let Ok((workspace_root, rel_from_root)) = vite_workspace::find_workspace_root(cwd) else { + return false; + }; + rel_from_root.as_str().is_empty() + && !matches!(workspace_root.workspace_file, WorkspaceFile::NonWorkspacePackage(_)) +} + pub(super) fn resolve_app_target( subcommand: &SynthesizableSubcommand, cwd: &AbsolutePathBuf, diff --git a/packages/cli/binding/src/cli/handler.rs b/packages/cli/binding/src/cli/handler.rs index 518305ac1a..c80519dc71 100644 --- a/packages/cli/binding/src/cli/handler.rs +++ b/packages/cli/binding/src/cli/handler.rs @@ -44,6 +44,17 @@ impl CommandHandler for VitePlusCommandHandler { if program != "vp" && program != "vpr" { return Ok(HandledCommand::Verbatim); } + // A leading global `-C` runs verbatim: CLIArgs has no global flags, and + // the spawned vp/vpr binary applies the directory change (and target + // elicitation) exactly like a direct invocation. + if command + .args + .first() + .map(Str::as_str) + .is_some_and(|arg| arg == "-C" || (arg.starts_with("-C") && arg.len() > 2)) + { + return Ok(HandledCommand::Verbatim); + } // "vpr " is shorthand for "vp run ", so prepend "run" for parsing. let is_vpr = program == "vpr"; let cli_args = match CLIArgs::try_parse_from( @@ -72,6 +83,14 @@ impl CommandHandler for VitePlusCommandHandler { ))) } CLIArgs::Synthesizable(subcmd) => { + // Bare app commands in scripts get the same workspace-root + // target elicitation as direct invocations: spawn the real + // binary, which elicits (defaultPackage note, listing + + // exit 1) identically instead of silently running the root. + let cwd = command.cwd.to_absolute_path_buf(); + if super::app_target::needs_elicitation(&subcmd, &cwd) { + return Ok(HandledCommand::Verbatim); + } let resolved = self.resolver.resolve(subcmd, None, &command.envs).await?; Ok(HandledCommand::Synthesized(resolved.into_synthetic_plan_request())) } From 6acf34c1a975a81fd925e77efc44a4e078f41e28 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 6 Jul 2026 02:59:18 +0800 Subject: [PATCH 23/33] fix(cli): address Codex review round 5 - split the required-value flag table per forwarded tool: Vite commands and pack have different flags (--env-file/--on-success/--copy/... for pack, and Vite's -d/--debug is optional-value so it no longer consumes), plus pack's --env.NAME pattern flags - bin/vpr: a bare 'vpr -C' with no directory no longer inserts run into the -C value position; bin.ts reports the missing-argument error --- packages/cli/bin/vpr | 13 ++- packages/cli/binding/src/cli/app_target.rs | 113 +++++++++++++-------- rfcs/cwd-flag.md | 2 +- 3 files changed, 84 insertions(+), 44 deletions(-) diff --git a/packages/cli/bin/vpr b/packages/cli/bin/vpr index 8f354d6e14..8f8d4c24ab 100755 --- a/packages/cli/bin/vpr +++ b/packages/cli/bin/vpr @@ -7,7 +7,16 @@ if (module.enableCompileCache) { // Insert `run` after a leading global `-C ` so `vpr -C ` // behaves like `vp -C run ` (bin.ts consumes -C before dispatch). +// A bare `vpr -C` (missing value) inserts nothing: bin.ts then reports the +// missing-argument error instead of misreading `run` as the directory. const first = process.argv[2]; -const chdirTokens = first === '-C' ? 2 : first?.startsWith('-C') && first.length > 2 ? 1 : 0; -process.argv.splice(2 + chdirTokens, 0, 'run'); +if (first === '-C') { + if (process.argv[3] !== undefined) { + process.argv.splice(4, 0, 'run'); + } +} else if (first?.startsWith('-C') && first.length > 2) { + process.argv.splice(3, 0, 'run'); +} else { + process.argv.splice(2, 0, 'run'); +} await import('../dist/bin.js'); diff --git a/packages/cli/binding/src/cli/app_target.rs b/packages/cli/binding/src/cli/app_target.rs index ff4b741f5b..f80997f455 100644 --- a/packages/cli/binding/src/cli/app_target.rs +++ b/packages/cli/binding/src/cli/app_target.rs @@ -44,49 +44,74 @@ fn app_command_parts(subcommand: &SynthesizableSubcommand) -> Option<(&'static s } } -/// Flags of the app tools (Vite dev/build/preview and tsdown) that always -/// consume the next argv token as their value. Space-separated values of -/// these flags are not positional targets. Flags with optional values -/// (`--host`, `--sourcemap`, `--minify`, ...) are deliberately absent: a -/// token after them stays ambiguous and keeps the conservative fallback. -const VALUE_TAKING_FLAGS: &[&str] = &[ +/// Required-value flags of the Vite CLI (dev/build/preview). Space-separated +/// values of these flags are not positional targets. Optional-value flags +/// (`--host`, `--open`, `--debug`, `--ssr`, `--sourcemap`, `--minify`, ...) +/// are deliberately absent: a token after them stays ambiguous and keeps the +/// conservative fallback. +const VITE_VALUE_FLAGS: &[&str] = &[ "-c", "--config", - "-m", - "--mode", + "--base", "-l", "--logLevel", + "-m", + "--mode", + "--configLoader", + "-f", + "--filter", "--port", - "--base", "--outDir", "--assetsDir", "--assetsInlineLimit", - "--filter", - "--configLoader", + "--target", +]; + +/// Required-value flags of the bundled pack CLI (see pack-bin.ts; cac accepts +/// both camelCase and kebab-case spellings). Optional-value flags (`--debug`, +/// `--watch`, `--from-vite`) are deliberately absent, as above. +const PACK_VALUE_FLAGS: &[&str] = &[ "--config-loader", - "--tsconfig", - "--log-level", + "--configLoader", + "-f", + "--format", "--deps.never-bundle", + "--target", + "-l", + "--logLevel", + "--log-level", "-d", "--out-dir", - "--target", - "-f", - "--format", + "--outDir", "--platform", + "--tsconfig", + "--ignore-watch", + "--ignoreWatch", + "--env-file", + "--envFile", + "--env-prefix", + "--envPrefix", + "--on-success", + "--onSuccess", + "--copy", + "--public-dir", + "--publicDir", ]; -/// Bare = no positional target and no help-like flag. Values of known -/// value-taking flags (`--port 3000`) are skipped; any other non-flag token -/// may be a positional target and conservatively disables elicitation. -/// Help/version requests are answered by the underlying tool and must never -/// be redirected. -fn is_bare(args: &[String]) -> bool { +/// Bare = no positional target and no help-like flag. Values of the +/// forwarded tool's known required-value flags (`--port 3000`) are skipped; +/// any other non-flag token may be a positional target and conservatively +/// disables elicitation. Help/version requests are answered by the +/// underlying tool and must never be redirected. +fn is_bare(command: &str, args: &[String]) -> bool { + let value_flags = if command == "pack" { PACK_VALUE_FLAGS } else { VITE_VALUE_FLAGS }; let mut iter = args.iter(); while let Some(arg) = iter.next() { if !arg.starts_with('-') || super::help::is_app_tool_help_or_version_flag(arg) { return false; } - if VALUE_TAKING_FLAGS.contains(&arg.as_str()) { + // `--env.NAME ` defines a compile-time env variable in pack. + if value_flags.contains(&arg.as_str()) || (command == "pack" && arg.starts_with("--env.")) { // Consume the flag's value; a missing value is the tool's error. iter.next(); } @@ -204,10 +229,10 @@ pub(super) fn needs_elicitation( subcommand: &SynthesizableSubcommand, cwd: &AbsolutePathBuf, ) -> bool { - let Some((_, args)) = app_command_parts(subcommand) else { + let Some((command, args)) = app_command_parts(subcommand) else { return false; }; - if !is_bare(args) { + if !is_bare(command, args) { return false; } if vite_static_config::resolve_static_config(cwd).get_declared("defaultPackage").is_some() { @@ -227,7 +252,7 @@ pub(super) fn resolve_app_target( let Some((command, args)) = app_command_parts(subcommand) else { return Ok(AppTarget::CurrentDir); }; - if !is_bare(args) { + if !is_bare(command, args) { return Ok(AppTarget::CurrentDir); } @@ -314,26 +339,32 @@ mod tests { #[test] fn bare_means_no_positional_target_and_no_help() { let to_args = |args: &[&str]| args.iter().map(|s| (*s).to_string()).collect::>(); - assert!(is_bare(&to_args(&[]))); - assert!(is_bare(&to_args(&["--watch"]))); - assert!(is_bare(&to_args(&["-w", "--minify"]))); + assert!(is_bare("dev", &to_args(&[]))); + assert!(is_bare("dev", &to_args(&["--watch"]))); + assert!(is_bare("build", &to_args(&["-w", "--minify"]))); // A positional target disables elicitation. - assert!(!is_bare(&to_args(&["apps/web"]))); - // Known value-taking flags consume their value. - assert!(is_bare(&to_args(&["--port", "3000"]))); - assert!(is_bare(&to_args(&["--mode", "production", "--minify"]))); - assert!(is_bare(&to_args(&["--assetsDir", "assets"]))); - assert!(is_bare(&to_args(&["--port=3000"]))); + assert!(!is_bare("dev", &to_args(&["apps/web"]))); + // Known required-value flags consume their value, per command. + assert!(is_bare("dev", &to_args(&["--port", "3000"]))); + assert!(is_bare("build", &to_args(&["--mode", "production", "--minify"]))); + assert!(is_bare("build", &to_args(&["--assetsDir", "assets"]))); + assert!(is_bare("build", &to_args(&["--port=3000"]))); + assert!(is_bare("pack", &to_args(&["--env-file", ".env"]))); + assert!(is_bare("pack", &to_args(&["-d", "out", "--env.FOO", "1"]))); + // The tables are command-specific: pack's flags mean nothing to Vite, + // and Vite's optional-value `-d, --debug [feat]` must not consume. + assert!(!is_bare("dev", &to_args(&["--env-file", ".env"]))); + assert!(!is_bare("dev", &to_args(&["-d", "apps/web"]))); // A token after an unknown or optional-value flag is ambiguous with a // positional target, so it conservatively counts as non-bare. - assert!(!is_bare(&to_args(&["--watch", "apps/web"]))); - assert!(!is_bare(&to_args(&["--host", "0.0.0.0"]))); + assert!(!is_bare("build", &to_args(&["--watch", "apps/web"]))); + assert!(!is_bare("dev", &to_args(&["--host", "0.0.0.0"]))); // Help/version requests go to the underlying tool, never elicitation. - assert!(!is_bare(&to_args(&["--help"]))); - assert!(!is_bare(&to_args(&["-h"]))); - assert!(!is_bare(&to_args(&["--watch", "--version"]))); + assert!(!is_bare("dev", &to_args(&["--help"]))); + assert!(!is_bare("dev", &to_args(&["-h"]))); + assert!(!is_bare("build", &to_args(&["--watch", "--version"]))); // Vite and tsdown are cac-based and use `-v` for version. - assert!(!is_bare(&to_args(&["-v"]))); + assert!(!is_bare("build", &to_args(&["-v"]))); } #[test] diff --git a/rfcs/cwd-flag.md b/rfcs/cwd-flag.md index 97548bebd1..c8573f884f 100644 --- a/rfcs/cwd-flag.md +++ b/rfcs/cwd-flag.md @@ -246,7 +246,7 @@ Rejected alternatives: repurposing the app-command positional to mean "run there ### Target directory resolution -An app command invocation is **bare** when it has no `-C` and no positional target (no Vite `[root]`, no pack entries). Flags keep it bare, including known value-taking flags together with their values (`--port 3000`, `--mode production`); a token following an unknown or optional-value flag is ambiguous with a positional target and conservatively counts as one. For `vp dev` / `build` / `preview` / `pack`, the target directory is resolved in this order: +An app command invocation is **bare** when it has no `-C` and no positional target (no Vite `[root]`, no pack entries). Flags keep it bare, including the forwarded tool's known required-value flags together with their values (`--port 3000` for Vite commands, `--env-file .env` for pack); a token following an unknown or optional-value flag is ambiguous with a positional target and conservatively counts as one. For `vp dev` / `build` / `preview` / `pack`, the target directory is resolved in this order: 1. **`-C `**: run there. Never triggers the picker. 2. **Positional target present**: forward as today, upstream semantics, vp does not interfere. From b306eb7f5e3c6798fe08babb31e259cfe7876fca Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 6 Jul 2026 03:27:21 +0800 Subject: [PATCH 24/33] fix(cli): address Codex review round 6 - defaultPackage is consulted only at the invocation root: a member package's own config never redirects (or fails) a command already running in that member; regression case pins a member config pointing at a missing directory - inline --env.NAME=value pack flags no longer consume the next token, so an explicit entry after them stays a positional - complete the pack value-flag table from pack-bin.ts: --root, -F/--filter, -c/--config; add cac kebab-case twins to the Vite table - mark the two mid-case expected-failure steps continue-on-failure per the harness's new shell-like && semantics --- .../packages/ui/vite.config.ts | 5 ++ .../app_root_default_package/snapshots.toml | 10 +++ .../snapshots/member_config_ignored.global.md | 13 +++ .../snapshots/member_config_ignored.local.md | 13 +++ .../fixtures/app_root_listing/snapshots.toml | 2 +- .../snapshots/listing.global.md.new | 23 +++++ .../snapshots/listing.local.md.new | 23 +++++ .../fixtures/cwd_flag/snapshots.toml | 2 +- .../cwd_flag/snapshots/cwd_flag.global.md.new | 83 +++++++++++++++++++ .../cwd_flag/snapshots/cwd_flag.local.md.new | 75 +++++++++++++++++ packages/cli/binding/src/cli/app_target.rs | 48 +++++++++-- rfcs/cwd-flag.md | 1 + 12 files changed, 287 insertions(+), 11 deletions(-) create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/packages/ui/vite.config.ts create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/member_config_ignored.global.md create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/member_config_ignored.local.md create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.global.md.new create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.local.md.new create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.global.md.new create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.local.md.new diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/packages/ui/vite.config.ts b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/packages/ui/vite.config.ts new file mode 100644 index 0000000000..a26ae0d6b7 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/packages/ui/vite.config.ts @@ -0,0 +1,5 @@ +// A member package declaring defaultPackage: ignored below the workspace +// root, where the current directory already identifies the target. +export default { + defaultPackage: "./does-not-exist", +}; diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots.toml index a8c3646728..23b7c53c82 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots.toml +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots.toml @@ -26,3 +26,13 @@ as an open map might hide defaultPackage behind the spread, but that must not fail the command. Bare vp build falls through and runs in place. """ steps = [{ argv = ["vp", "build"], cwd = "spread", tty = false }] + +[[case]] +name = "member_config_ignored" +vp = ["local", "global"] +comment = """ +defaultPackage is a root-pointer concept: a member package's own config +declaring it (here pointing at a missing directory) must not redirect or +fail a command already running in that member. +""" +steps = [{ argv = ["vp", "pack"], cwd = "packages/ui", tty = false }] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/member_config_ignored.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/member_config_ignored.global.md new file mode 100644 index 0000000000..37f5a9ae4c --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/member_config_ignored.global.md @@ -0,0 +1,13 @@ +# member_config_ignored + +defaultPackage is a root-pointer concept: a member package's own config +declaring it (here pointing at a missing directory) must not redirect or +fail a command already running in that member. + +## `cd packages/ui && vp pack` + +**Exit code:** 1 + +``` +error: defaultPackage points to a missing directory: ./does-not-exist +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/member_config_ignored.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/member_config_ignored.local.md new file mode 100644 index 0000000000..37f5a9ae4c --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/member_config_ignored.local.md @@ -0,0 +1,13 @@ +# member_config_ignored + +defaultPackage is a root-pointer concept: a member package's own config +declaring it (here pointing at a missing directory) must not redirect or +fail a command already running in that member. + +## `cd packages/ui && vp pack` + +**Exit code:** 1 + +``` +error: defaultPackage points to a missing directory: ./does-not-exist +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots.toml index d6e648fed8..953b61ac07 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots.toml +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots.toml @@ -7,7 +7,7 @@ the ranked package listing with -C hints and exits 1 instead of building the root (rfcs/cwd-flag.md). """ steps = [ - { argv = ["vp", "build"], tty = false }, + { argv = ["vp", "build"], tty = false, continue-on-failure = true }, { argv = ["vp", "dev"], tty = false, comment = "dev at the root no longer starts a server against the root" }, ] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.global.md.new b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.global.md.new new file mode 100644 index 0000000000..a0eaa57550 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.global.md.new @@ -0,0 +1,23 @@ +# listing + +A bare app command at a workspace root without an interactive terminal prints +the ranked package listing with -C hints and exits 1 instead of building the +root (rfcs/cwd-flag.md). + +## `vp build` + +**Exit code:** 1 + +``` +error: `vp build` at the workspace root needs a target package. + + Packages in this workspace: + admin apps/admin + web apps/web + ui packages/ui + + Pass a directory: vp -C apps/admin build + Or run every package's build script: vp run -r build +``` + +*(remaining steps skipped: step failed)* diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.local.md.new b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.local.md.new new file mode 100644 index 0000000000..a0eaa57550 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.local.md.new @@ -0,0 +1,23 @@ +# listing + +A bare app command at a workspace root without an interactive terminal prints +the ranked package listing with -C hints and exits 1 instead of building the +root (rfcs/cwd-flag.md). + +## `vp build` + +**Exit code:** 1 + +``` +error: `vp build` at the workspace root needs a target package. + + Packages in this workspace: + admin apps/admin + web apps/web + ui packages/ui + + Pass a directory: vp -C apps/admin build + Or run every package's build script: vp run -r build +``` + +*(remaining steps skipped: step failed)* diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots.toml index b3fdb7e167..1be84e1398 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots.toml +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots.toml @@ -14,7 +14,7 @@ steps = [ { argv = ["vp", "pack"], cwd = "packages/hello", comment = "the cd form is equivalent" }, { argv = ["vp", "-C", "packages/hello", "run", "where"], comment = "-C applies to vp run as well" }, { argv = ["vp", "run", "where"], cwd = "packages/hello", comment = "equivalent cd form for run" }, - { argv = ["vp", "-C", "packages/missing", "build"], comment = "missing directory errors" }, + { argv = ["vp", "-C", "packages/missing", "build"], comment = "missing directory errors", continue-on-failure = true }, { argv = ["vp", "pack", "packages/hello"], comment = "positional stays a tsdown entry resolved from the invocation directory" }, { argv = ["vpt", "list-dir", "dist"], comment = "upstream semantics: output lands at the invocation directory" }, { argv = ["vp", "run", "where:hello"], comment = "a script whose command starts with vp -C runs verbatim and honors the directory" }, diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.global.md.new b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.global.md.new new file mode 100644 index 0000000000..e3a9007f26 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.global.md.new @@ -0,0 +1,83 @@ +# cwd_flag + +The global -C flag runs any command as if vp was started in the directory: +pack and run behave byte-identically to the cd forms, a missing directory +errors, and the positional keeps upstream tsdown entry semantics +(rfcs/cwd-flag.md). + +## `vp -C packages/hello pack` + +-C packs the package from the workspace root + +``` +VITE+ - The Unified Toolchain for the Web + +ℹ entry: src/index.ts +ℹ Build start +ℹ dist/index.mjs kB │ gzip: kB +ℹ 1 files, total: kB +✔ Build complete in +``` + +## `vpt list-dir packages/hello/dist` + +output lands in the target package + +``` +index.mjs +``` + +## `vpt rm -rf packages/hello/dist` + +reset so both forms produce identical output + +``` +``` + +## `cd packages/hello && vp pack` + +the cd form is equivalent + +``` +VITE+ - The Unified Toolchain for the Web + +ℹ entry: src/index.ts +ℹ Build start +ℹ dist/index.mjs kB │ gzip: kB +ℹ 1 files, total: kB +✔ Build complete in +``` + +## `vp -C packages/hello run where` + +-C applies to vp run as well + +``` +VITE+ - The Unified Toolchain for the Web + +~/packages/hello$ node -e "console.log('cwd base: ' + require('node:path').basename(process.cwd()))" ⊘ cache disabled +cwd base: hello +``` + +## `cd packages/hello && vp run where` + +equivalent cd form for run + +``` +VITE+ - The Unified Toolchain for the Web + +~/packages/hello$ node -e "console.log('cwd base: ' + require('node:path').basename(process.cwd()))" ⊘ cache disabled +cwd base: hello +``` + +## `vp -C packages/missing build` + +missing directory errors + +**Exit code:** 1 + +``` +directory not found: packages/missing +``` + +*(remaining steps skipped: step failed)* diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.local.md.new b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.local.md.new new file mode 100644 index 0000000000..8cb9de3f5f --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.local.md.new @@ -0,0 +1,75 @@ +# cwd_flag + +The global -C flag runs any command as if vp was started in the directory: +pack and run behave byte-identically to the cd forms, a missing directory +errors, and the positional keeps upstream tsdown entry semantics +(rfcs/cwd-flag.md). + +## `vp -C packages/hello pack` + +-C packs the package from the workspace root + +``` +ℹ entry: src/index.ts +ℹ Build start +ℹ dist/index.mjs kB │ gzip: kB +ℹ 1 files, total: kB +✔ Build complete in +``` + +## `vpt list-dir packages/hello/dist` + +output lands in the target package + +``` +index.mjs +``` + +## `vpt rm -rf packages/hello/dist` + +reset so both forms produce identical output + +``` +``` + +## `cd packages/hello && vp pack` + +the cd form is equivalent + +``` +ℹ entry: src/index.ts +ℹ Build start +ℹ dist/index.mjs kB │ gzip: kB +ℹ 1 files, total: kB +✔ Build complete in +``` + +## `vp -C packages/hello run where` + +-C applies to vp run as well + +``` +~/packages/hello$ node -e "console.log('cwd base: ' + require('node:path').basename(process.cwd()))" ⊘ cache disabled +cwd base: hello +``` + +## `cd packages/hello && vp run where` + +equivalent cd form for run + +``` +~/packages/hello$ node -e "console.log('cwd base: ' + require('node:path').basename(process.cwd()))" ⊘ cache disabled +cwd base: hello +``` + +## `vp -C packages/missing build` + +missing directory errors + +**Exit code:** 1 + +``` +error: directory not found: packages/missing +``` + +*(remaining steps skipped: step failed)* diff --git a/packages/cli/binding/src/cli/app_target.rs b/packages/cli/binding/src/cli/app_target.rs index f80997f455..7128c47cdc 100644 --- a/packages/cli/binding/src/cli/app_target.rs +++ b/packages/cli/binding/src/cli/app_target.rs @@ -52,6 +52,11 @@ fn app_command_parts(subcommand: &SynthesizableSubcommand) -> Option<(&'static s const VITE_VALUE_FLAGS: &[&str] = &[ "-c", "--config", + "--config-loader", + "--log-level", + "--out-dir", + "--assets-dir", + "--assets-inline-limit", "--base", "-l", "--logLevel", @@ -71,6 +76,11 @@ const VITE_VALUE_FLAGS: &[&str] = &[ /// both camelCase and kebab-case spellings). Optional-value flags (`--debug`, /// `--watch`, `--from-vite`) are deliberately absent, as above. const PACK_VALUE_FLAGS: &[&str] = &[ + "--root", + "-F", + "--filter", + "-c", + "--config", "--config-loader", "--configLoader", "-f", @@ -110,8 +120,11 @@ fn is_bare(command: &str, args: &[String]) -> bool { if !arg.starts_with('-') || super::help::is_app_tool_help_or_version_flag(arg) { return false; } - // `--env.NAME ` defines a compile-time env variable in pack. - if value_flags.contains(&arg.as_str()) || (command == "pack" && arg.starts_with("--env.")) { + // `--env.NAME ` defines a compile-time env variable in pack; + // the inline `--env.NAME=value` form already carries its value. + if value_flags.contains(&arg.as_str()) + || (command == "pack" && arg.starts_with("--env.") && !arg.contains('=')) + { // Consume the flag's value; a missing value is the tool's error. iter.next(); } @@ -235,16 +248,29 @@ pub(super) fn needs_elicitation( if !is_bare(command, args) { return false; } - if vite_static_config::resolve_static_config(cwd).get_declared("defaultPackage").is_some() { + let workspace = vite_workspace::find_workspace_root(cwd); + if at_invocation_root(workspace.as_ref().ok().map(|(_, rel)| rel.as_str())) + && vite_static_config::resolve_static_config(cwd).get_declared("defaultPackage").is_some() + { return true; } - let Ok((workspace_root, rel_from_root)) = vite_workspace::find_workspace_root(cwd) else { + let Ok((workspace_root, rel_from_root)) = workspace else { return false; }; rel_from_root.as_str().is_empty() && !matches!(workspace_root.workspace_file, WorkspaceFile::NonWorkspacePackage(_)) } +/// `defaultPackage` is a root-pointer concept: it applies where the +/// invocation directory is its own root (a workspace root, a standalone +/// package, or a framework directory with no package.json ancestry — pass +/// the workspace lookup's `rel_from_root`, or `None` when the lookup +/// failed). Below a workspace root the current directory already identifies +/// the target package, so a member's own config must not redirect. +fn at_invocation_root(rel_from_root: Option<&str>) -> bool { + rel_from_root.is_none_or(str::is_empty) +} + pub(super) fn resolve_app_target( subcommand: &SynthesizableSubcommand, cwd: &AbsolutePathBuf, @@ -256,16 +282,20 @@ pub(super) fn resolve_app_target( return Ok(AppTarget::CurrentDir); } - // `defaultPackage` comes before any workspace lookup: the non-workspace - // framework shape (a Laravel-style root with a vite.config.ts pointer and - // no package.json up-tree) has no workspace metadata at all. - if let Some(target) = resolve_default_package(command, cwd) { + // `defaultPackage` is consulted before the workspace-shape dispatch (the + // non-workspace framework shape has no workspace metadata at all), but + // only at the invocation root: a member package's config must not + // redirect a command already running in that member. + let workspace = vite_workspace::find_workspace_root(cwd); + if at_invocation_root(workspace.as_ref().ok().map(|(_, rel)| rel.as_str())) + && let Some(target) = resolve_default_package(command, cwd) + { return Ok(target); } // The package listing needs workspace metadata; anything unresolvable // keeps today's behavior (the caller surfaces its own workspace errors). - let Ok((workspace_root, rel_from_root)) = vite_workspace::find_workspace_root(cwd) else { + let Ok((workspace_root, rel_from_root)) = workspace else { return Ok(AppTarget::CurrentDir); }; if !rel_from_root.as_str().is_empty() diff --git a/rfcs/cwd-flag.md b/rfcs/cwd-flag.md index c8573f884f..328c17262c 100644 --- a/rfcs/cwd-flag.md +++ b/rfcs/cwd-flag.md @@ -314,6 +314,7 @@ export default defineConfig({ - A missing directory errors: `defaultPackage points to a missing directory: ./frontend`. - Read via static extraction (`vite_static_config` + the loader in `packages/cli/binding/src/cli/handler.rs`), like `run` config. At a non-workspace root there is no install to execute the config, so the file must work unexecuted: a plain default-export object with a static string value. - Only an explicitly declared `defaultPackage` changes behavior. A declared but non-static value (e.g. `process.env.DIR`) errors; a config that is unanalyzable or hides fields behind a spread is treated as not declaring the key and falls through to the picker or current-dir resolution, so an exotic config can never break unrelated bare commands. +- Consulted only at the invocation root (a workspace root, a standalone package root, or a directory with no `package.json` ancestry). Below a workspace root the current directory already identifies the target, so a member package's own config never redirects. ## Decisions From 51ca0f02af9a7bd0b5afb7c88f97a15af95476ce Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 6 Jul 2026 03:30:57 +0800 Subject: [PATCH 25/33] test(snapshot): pin the member defaultPackage regression on a real workspace In a non-workspace layout the member IS its own invocation root (standalone package), where honoring defaultPackage is by design; the finding is about workspace members, so the case moves to a fixture with a workspaces field and asserts the pack runs in place. --- .../app_root_default_package/snapshots.toml | 10 --- .../snapshots/member_config_ignored.global.md | 13 --- .../snapshots/member_config_ignored.local.md | 13 --- .../snapshots/listing.global.md.new | 23 ----- .../snapshots/listing.local.md.new | 23 ----- .../cwd_flag/snapshots/cwd_flag.global.md.new | 83 ------------------- .../cwd_flag/snapshots/cwd_flag.local.md.new | 75 ----------------- .../member_default_package/package.json | 1 + .../packages/ui/package.json | 1 + .../packages/ui/src/index.ts | 1 + .../packages/ui/vite.config.ts | 2 +- .../member_default_package/snapshots.toml | 9 ++ .../snapshots/member_config_ignored.global.md | 15 ++++ .../snapshots/member_config_ignored.local.md | 15 ++++ 14 files changed, 43 insertions(+), 241 deletions(-) delete mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/member_config_ignored.global.md delete mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/member_config_ignored.local.md delete mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.global.md.new delete mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.local.md.new delete mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.global.md.new delete mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.local.md.new create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/member_default_package/package.json create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/member_default_package/packages/ui/package.json create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/member_default_package/packages/ui/src/index.ts rename crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/{app_root_default_package => member_default_package}/packages/ui/vite.config.ts (62%) create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/member_default_package/snapshots.toml create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/member_default_package/snapshots/member_config_ignored.global.md create mode 100644 crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/member_default_package/snapshots/member_config_ignored.local.md diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots.toml index 23b7c53c82..a8c3646728 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots.toml +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots.toml @@ -26,13 +26,3 @@ as an open map might hide defaultPackage behind the spread, but that must not fail the command. Bare vp build falls through and runs in place. """ steps = [{ argv = ["vp", "build"], cwd = "spread", tty = false }] - -[[case]] -name = "member_config_ignored" -vp = ["local", "global"] -comment = """ -defaultPackage is a root-pointer concept: a member package's own config -declaring it (here pointing at a missing directory) must not redirect or -fail a command already running in that member. -""" -steps = [{ argv = ["vp", "pack"], cwd = "packages/ui", tty = false }] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/member_config_ignored.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/member_config_ignored.global.md deleted file mode 100644 index 37f5a9ae4c..0000000000 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/member_config_ignored.global.md +++ /dev/null @@ -1,13 +0,0 @@ -# member_config_ignored - -defaultPackage is a root-pointer concept: a member package's own config -declaring it (here pointing at a missing directory) must not redirect or -fail a command already running in that member. - -## `cd packages/ui && vp pack` - -**Exit code:** 1 - -``` -error: defaultPackage points to a missing directory: ./does-not-exist -``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/member_config_ignored.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/member_config_ignored.local.md deleted file mode 100644 index 37f5a9ae4c..0000000000 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/member_config_ignored.local.md +++ /dev/null @@ -1,13 +0,0 @@ -# member_config_ignored - -defaultPackage is a root-pointer concept: a member package's own config -declaring it (here pointing at a missing directory) must not redirect or -fail a command already running in that member. - -## `cd packages/ui && vp pack` - -**Exit code:** 1 - -``` -error: defaultPackage points to a missing directory: ./does-not-exist -``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.global.md.new b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.global.md.new deleted file mode 100644 index a0eaa57550..0000000000 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.global.md.new +++ /dev/null @@ -1,23 +0,0 @@ -# listing - -A bare app command at a workspace root without an interactive terminal prints -the ranked package listing with -C hints and exits 1 instead of building the -root (rfcs/cwd-flag.md). - -## `vp build` - -**Exit code:** 1 - -``` -error: `vp build` at the workspace root needs a target package. - - Packages in this workspace: - admin apps/admin - web apps/web - ui packages/ui - - Pass a directory: vp -C apps/admin build - Or run every package's build script: vp run -r build -``` - -*(remaining steps skipped: step failed)* diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.local.md.new b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.local.md.new deleted file mode 100644 index a0eaa57550..0000000000 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_listing/snapshots/listing.local.md.new +++ /dev/null @@ -1,23 +0,0 @@ -# listing - -A bare app command at a workspace root without an interactive terminal prints -the ranked package listing with -C hints and exits 1 instead of building the -root (rfcs/cwd-flag.md). - -## `vp build` - -**Exit code:** 1 - -``` -error: `vp build` at the workspace root needs a target package. - - Packages in this workspace: - admin apps/admin - web apps/web - ui packages/ui - - Pass a directory: vp -C apps/admin build - Or run every package's build script: vp run -r build -``` - -*(remaining steps skipped: step failed)* diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.global.md.new b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.global.md.new deleted file mode 100644 index e3a9007f26..0000000000 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.global.md.new +++ /dev/null @@ -1,83 +0,0 @@ -# cwd_flag - -The global -C flag runs any command as if vp was started in the directory: -pack and run behave byte-identically to the cd forms, a missing directory -errors, and the positional keeps upstream tsdown entry semantics -(rfcs/cwd-flag.md). - -## `vp -C packages/hello pack` - --C packs the package from the workspace root - -``` -VITE+ - The Unified Toolchain for the Web - -ℹ entry: src/index.ts -ℹ Build start -ℹ dist/index.mjs kB │ gzip: kB -ℹ 1 files, total: kB -✔ Build complete in -``` - -## `vpt list-dir packages/hello/dist` - -output lands in the target package - -``` -index.mjs -``` - -## `vpt rm -rf packages/hello/dist` - -reset so both forms produce identical output - -``` -``` - -## `cd packages/hello && vp pack` - -the cd form is equivalent - -``` -VITE+ - The Unified Toolchain for the Web - -ℹ entry: src/index.ts -ℹ Build start -ℹ dist/index.mjs kB │ gzip: kB -ℹ 1 files, total: kB -✔ Build complete in -``` - -## `vp -C packages/hello run where` - --C applies to vp run as well - -``` -VITE+ - The Unified Toolchain for the Web - -~/packages/hello$ node -e "console.log('cwd base: ' + require('node:path').basename(process.cwd()))" ⊘ cache disabled -cwd base: hello -``` - -## `cd packages/hello && vp run where` - -equivalent cd form for run - -``` -VITE+ - The Unified Toolchain for the Web - -~/packages/hello$ node -e "console.log('cwd base: ' + require('node:path').basename(process.cwd()))" ⊘ cache disabled -cwd base: hello -``` - -## `vp -C packages/missing build` - -missing directory errors - -**Exit code:** 1 - -``` -directory not found: packages/missing -``` - -*(remaining steps skipped: step failed)* diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.local.md.new b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.local.md.new deleted file mode 100644 index 8cb9de3f5f..0000000000 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cwd_flag/snapshots/cwd_flag.local.md.new +++ /dev/null @@ -1,75 +0,0 @@ -# cwd_flag - -The global -C flag runs any command as if vp was started in the directory: -pack and run behave byte-identically to the cd forms, a missing directory -errors, and the positional keeps upstream tsdown entry semantics -(rfcs/cwd-flag.md). - -## `vp -C packages/hello pack` - --C packs the package from the workspace root - -``` -ℹ entry: src/index.ts -ℹ Build start -ℹ dist/index.mjs kB │ gzip: kB -ℹ 1 files, total: kB -✔ Build complete in -``` - -## `vpt list-dir packages/hello/dist` - -output lands in the target package - -``` -index.mjs -``` - -## `vpt rm -rf packages/hello/dist` - -reset so both forms produce identical output - -``` -``` - -## `cd packages/hello && vp pack` - -the cd form is equivalent - -``` -ℹ entry: src/index.ts -ℹ Build start -ℹ dist/index.mjs kB │ gzip: kB -ℹ 1 files, total: kB -✔ Build complete in -``` - -## `vp -C packages/hello run where` - --C applies to vp run as well - -``` -~/packages/hello$ node -e "console.log('cwd base: ' + require('node:path').basename(process.cwd()))" ⊘ cache disabled -cwd base: hello -``` - -## `cd packages/hello && vp run where` - -equivalent cd form for run - -``` -~/packages/hello$ node -e "console.log('cwd base: ' + require('node:path').basename(process.cwd()))" ⊘ cache disabled -cwd base: hello -``` - -## `vp -C packages/missing build` - -missing directory errors - -**Exit code:** 1 - -``` -error: directory not found: packages/missing -``` - -*(remaining steps skipped: step failed)* diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/member_default_package/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/member_default_package/package.json new file mode 100644 index 0000000000..ed5c75cb8f --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/member_default_package/package.json @@ -0,0 +1 @@ +{ "name": "member-default-package", "private": true, "workspaces": ["packages/*"] } diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/member_default_package/packages/ui/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/member_default_package/packages/ui/package.json new file mode 100644 index 0000000000..70bb6aab17 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/member_default_package/packages/ui/package.json @@ -0,0 +1 @@ +{ "name": "ui", "private": true, "type": "module", "main": "src/index.ts" } diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/member_default_package/packages/ui/src/index.ts b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/member_default_package/packages/ui/src/index.ts new file mode 100644 index 0000000000..62c987f2f1 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/member_default_package/packages/ui/src/index.ts @@ -0,0 +1 @@ +export const ui = true; diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/packages/ui/vite.config.ts b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/member_default_package/packages/ui/vite.config.ts similarity index 62% rename from crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/packages/ui/vite.config.ts rename to crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/member_default_package/packages/ui/vite.config.ts index a26ae0d6b7..6021f747da 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/packages/ui/vite.config.ts +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/member_default_package/packages/ui/vite.config.ts @@ -1,4 +1,4 @@ -// A member package declaring defaultPackage: ignored below the workspace +// A workspace member declaring defaultPackage: ignored below the workspace // root, where the current directory already identifies the target. export default { defaultPackage: "./does-not-exist", diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/member_default_package/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/member_default_package/snapshots.toml new file mode 100644 index 0000000000..f96ff0faf0 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/member_default_package/snapshots.toml @@ -0,0 +1,9 @@ +[[case]] +name = "member_config_ignored" +vp = ["local", "global"] +comment = """ +defaultPackage is a root-pointer concept: a workspace member's own config +declaring it (here pointing at a missing directory) must not redirect or +fail a command already running in that member; the pack runs in place. +""" +steps = [{ argv = ["vp", "pack"], cwd = "packages/ui", tty = false }] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/member_default_package/snapshots/member_config_ignored.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/member_default_package/snapshots/member_config_ignored.global.md new file mode 100644 index 0000000000..da45246d53 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/member_default_package/snapshots/member_config_ignored.global.md @@ -0,0 +1,15 @@ +# member_config_ignored + +defaultPackage is a root-pointer concept: a workspace member's own config +declaring it (here pointing at a missing directory) must not redirect or +fail a command already running in that member; the pack runs in place. + +## `cd packages/ui && vp pack` + +``` +ℹ entry: src/index.ts +ℹ Build start +ℹ dist/index.mjs kB │ gzip: kB +ℹ 1 files, total: kB +✔ Build complete in +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/member_default_package/snapshots/member_config_ignored.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/member_default_package/snapshots/member_config_ignored.local.md new file mode 100644 index 0000000000..da45246d53 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/member_default_package/snapshots/member_config_ignored.local.md @@ -0,0 +1,15 @@ +# member_config_ignored + +defaultPackage is a root-pointer concept: a workspace member's own config +declaring it (here pointing at a missing directory) must not redirect or +fail a command already running in that member; the pack runs in place. + +## `cd packages/ui && vp pack` + +``` +ℹ entry: src/index.ts +ℹ Build start +ℹ dist/index.mjs kB │ gzip: kB +ℹ 1 files, total: kB +✔ Build complete in +``` From 0f65b17d259f61b96088777ad052772ffbd7f37f Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 6 Jul 2026 03:56:11 +0800 Subject: [PATCH 26/33] fix(cli): address Codex review round 7 - rewrite is_bare to mirror cac/mri parsing: a non-flag token after any non-boolean flag is that flag's value (required and optional alike, so vp dev --host 0.0.0.0 and --open /foo elicit again), and only tokens no flag consumes are positionals; the per-tool required-value tables are replaced by smaller boolean tables derived from the shipped --help - pack workspace selectors (-W/--workspace, -F/--filter) define their own target set and always bypass elicitation - document in the RFC that implicit -C runs inside the already-chosen CLI (single-version workspace model); full re-delegation for defaultPackage targets with their own install/runtime pin is tracked in #2057 --- packages/cli/binding/src/cli/app_target.rs | 148 ++++++++++----------- rfcs/cwd-flag.md | 4 +- 2 files changed, 69 insertions(+), 83 deletions(-) diff --git a/packages/cli/binding/src/cli/app_target.rs b/packages/cli/binding/src/cli/app_target.rs index 7128c47cdc..4040701804 100644 --- a/packages/cli/binding/src/cli/app_target.rs +++ b/packages/cli/binding/src/cli/app_target.rs @@ -44,88 +44,69 @@ fn app_command_parts(subcommand: &SynthesizableSubcommand) -> Option<(&'static s } } -/// Required-value flags of the Vite CLI (dev/build/preview). Space-separated -/// values of these flags are not positional targets. Optional-value flags -/// (`--host`, `--open`, `--debug`, `--ssr`, `--sourcemap`, `--minify`, ...) -/// are deliberately absent: a token after them stays ambiguous and keeps the -/// conservative fallback. -const VITE_VALUE_FLAGS: &[&str] = &[ - "-c", - "--config", - "--config-loader", - "--log-level", - "--out-dir", - "--assets-dir", - "--assets-inline-limit", - "--base", - "-l", - "--logLevel", - "-m", - "--mode", - "--configLoader", - "-f", - "--filter", - "--port", - "--outDir", - "--assetsDir", - "--assetsInlineLimit", - "--target", +/// Boolean flags of the Vite CLI (dev/build/preview), from the shipped +/// `vp --help` (snap-tests/command-helper); keep in sync. Under +/// cac/mri parsing every OTHER flag — required-value, optional-value +/// (`--host [host]`), or unknown — consumes a following non-flag token as +/// its value, so only tokens no flag consumes are positional targets. +const VITE_BOOLEAN_FLAGS: &[&str] = &[ + "-w", + "--watch", + "--app", + "--clearScreen", + "--cors", + "--emptyOutDir", + "--experimentalBundle", + "--force", + "--profile", + "--strictPort", ]; -/// Required-value flags of the bundled pack CLI (see pack-bin.ts; cac accepts -/// both camelCase and kebab-case spellings). Optional-value flags (`--debug`, -/// `--watch`, `--from-vite`) are deliberately absent, as above. -const PACK_VALUE_FLAGS: &[&str] = &[ - "--root", - "-F", - "--filter", - "-c", - "--config", - "--config-loader", - "--configLoader", - "-f", - "--format", - "--deps.never-bundle", - "--target", - "-l", - "--logLevel", - "--log-level", - "-d", - "--out-dir", - "--outDir", - "--platform", - "--tsconfig", - "--ignore-watch", - "--ignoreWatch", - "--env-file", - "--envFile", - "--env-prefix", - "--envPrefix", - "--on-success", - "--onSuccess", - "--copy", - "--public-dir", - "--publicDir", +/// Boolean flags of the bundled pack CLI (tsdown), from `vp pack --help`. +const PACK_BOOLEAN_FLAGS: &[&str] = &[ + "--attw", + "--clean", + "--devtools", + "--dts", + "--exe", + "--exports", + "--fail-on-warn", + "--failOnWarn", + "--minify", + "--no-write", + "--publint", + "--report", + "--shims", + "--sourcemap", + "--treeshake", + "--unbundle", + "--unused", ]; -/// Bare = no positional target and no help-like flag. Values of the -/// forwarded tool's known required-value flags (`--port 3000`) are skipped; -/// any other non-flag token may be a positional target and conservatively -/// disables elicitation. Help/version requests are answered by the +/// Bare = no positional target and no help-like flag. Mirrors the tools' +/// own cac/mri parsing: a non-flag token after any non-boolean flag is that +/// flag's value (the tool would never see it as a positional), while a token +/// after a boolean flag is a positional target and disables elicitation. +/// pack's workspace selectors already define their own target set and +/// disable elicitation outright. Help/version requests are answered by the /// underlying tool and must never be redirected. fn is_bare(command: &str, args: &[String]) -> bool { - let value_flags = if command == "pack" { PACK_VALUE_FLAGS } else { VITE_VALUE_FLAGS }; - let mut iter = args.iter(); + let is_pack = command == "pack"; + let booleans = if is_pack { PACK_BOOLEAN_FLAGS } else { VITE_BOOLEAN_FLAGS }; + let mut iter = args.iter().peekable(); while let Some(arg) = iter.next() { if !arg.starts_with('-') || super::help::is_app_tool_help_or_version_flag(arg) { return false; } - // `--env.NAME ` defines a compile-time env variable in pack; - // the inline `--env.NAME=value` form already carries its value. - if value_flags.contains(&arg.as_str()) - || (command == "pack" && arg.starts_with("--env.") && !arg.contains('=')) + if is_pack && matches!(arg.as_str(), "-W" | "--workspace" | "-F" | "--filter") { + return false; + } + let is_boolean = booleans.contains(&arg.as_str()) || arg.starts_with("--no-"); + // An inline `=` already carries the value (`--port=3000`, `--env.FOO=bar`). + if !is_boolean + && !arg.contains('=') + && iter.peek().is_some_and(|next| !next.starts_with('-')) { - // Consume the flag's value; a missing value is the tool's error. iter.next(); } } @@ -374,21 +355,26 @@ mod tests { assert!(is_bare("build", &to_args(&["-w", "--minify"]))); // A positional target disables elicitation. assert!(!is_bare("dev", &to_args(&["apps/web"]))); - // Known required-value flags consume their value, per command. + // Like cac, any non-boolean flag consumes a following non-flag token + // as its value — required and optional values alike. assert!(is_bare("dev", &to_args(&["--port", "3000"]))); + assert!(is_bare("dev", &to_args(&["--host", "0.0.0.0"]))); + assert!(is_bare("dev", &to_args(&["--open", "/foo"]))); assert!(is_bare("build", &to_args(&["--mode", "production", "--minify"]))); - assert!(is_bare("build", &to_args(&["--assetsDir", "assets"]))); assert!(is_bare("build", &to_args(&["--port=3000"]))); assert!(is_bare("pack", &to_args(&["--env-file", ".env"]))); - assert!(is_bare("pack", &to_args(&["-d", "out", "--env.FOO", "1"]))); - // The tables are command-specific: pack's flags mean nothing to Vite, - // and Vite's optional-value `-d, --debug [feat]` must not consume. - assert!(!is_bare("dev", &to_args(&["--env-file", ".env"]))); - assert!(!is_bare("dev", &to_args(&["-d", "apps/web"]))); - // A token after an unknown or optional-value flag is ambiguous with a - // positional target, so it conservatively counts as non-bare. + assert!(is_bare("pack", &to_args(&["--env.FOO=bar", "--minify"]))); + // A token after a boolean flag is a positional; the tables are + // command-specific (--minify is optional-value for Vite build, + // boolean for pack). assert!(!is_bare("build", &to_args(&["--watch", "apps/web"]))); - assert!(!is_bare("dev", &to_args(&["--host", "0.0.0.0"]))); + assert!(!is_bare("pack", &to_args(&["--minify", "src/index.ts"]))); + assert!(!is_bare("pack", &to_args(&["--env.FOO", "bar", "src/cli.ts"]))); + assert!(is_bare("build", &to_args(&["--minify", "esbuild"]))); + // pack workspace selectors define their own target set. + assert!(!is_bare("pack", &to_args(&["-W"]))); + assert!(!is_bare("pack", &to_args(&["--workspace", "packages/a"]))); + assert!(!is_bare("pack", &to_args(&["-F", "ui"]))); // Help/version requests go to the underlying tool, never elicitation. assert!(!is_bare("dev", &to_args(&["--help"]))); assert!(!is_bare("dev", &to_args(&["-h"]))); diff --git a/rfcs/cwd-flag.md b/rfcs/cwd-flag.md index 328c17262c..981eed75ae 100644 --- a/rfcs/cwd-flag.md +++ b/rfcs/cwd-flag.md @@ -246,7 +246,7 @@ Rejected alternatives: repurposing the app-command positional to mean "run there ### Target directory resolution -An app command invocation is **bare** when it has no `-C` and no positional target (no Vite `[root]`, no pack entries). Flags keep it bare, including the forwarded tool's known required-value flags together with their values (`--port 3000` for Vite commands, `--env-file .env` for pack); a token following an unknown or optional-value flag is ambiguous with a positional target and conservatively counts as one. For `vp dev` / `build` / `preview` / `pack`, the target directory is resolved in this order: +An app command invocation is **bare** when it has no `-C` and no positional target (no Vite `[root]`, no pack entries). The classification mirrors the tools' own cac parsing: a non-flag token following any non-boolean flag is that flag's value — required and optional values alike (`--port 3000`, `--host 0.0.0.0`) — because the tool itself would never treat it as a positional; only a token no flag consumes is a positional target, and any positional disables elicitation. The boolean-flag tables come from the shipped `--help` of each tool and are command-specific (`--minify` is optional-value for Vite build but boolean for pack). pack's workspace selectors (`-W`/`--workspace`, `-F`/`--filter`) already define their own target set and always disable elicitation. For `vp dev` / `build` / `preview` / `pack`, the target directory is resolved in this order: 1. **`-C `**: run there. Never triggers the picker. 2. **Positional target present**: forward as today, upstream semantics, vp does not interfere. @@ -267,7 +267,7 @@ vp -C [args...] === cd && vp [args...] The child's spawn cwd is ``, so config lookup, `.env` loading, `process.cwd()` reads in configs and plugins, and relative CLI args all behave as if the user had `cd`'d. The POSIX `PWD` environment variable is refreshed too (by both entry points and for elicitation-retargeted tools), so `process.env.PWD` readers match the `cd` form. Both entry points apply `-C` by changing their own process cwd at startup, before any argument normalization, picker, or command logic runs, which is indistinguishable from having been started in ``: the global binary consumes the flag first thing in `main` (so command aliases, the no-command picker, and in-process helpers that read the process cwd are all covered), and the local `vp` bin does the same before dispatch. -The global binary also resolves the local `vite-plus` install from ``, matching `cd` exactly; through the package's own `vp` bin the executing CLI is already chosen, so there the invariant assumes a single Vite+ version per workspace (the supported monorepo model). +The global binary also resolves the local `vite-plus` install from ``, matching `cd` exactly; through the package's own `vp` bin the executing CLI is already chosen, so there the invariant assumes a single Vite+ version per workspace (the supported monorepo model). The implicit `-C` forms (picker, auto-select, `defaultPackage`) run inside the already-chosen CLI under the same assumption: the spawned tool gets the target's cwd and `PWD`, but the executing CLI and its runtime were resolved at the invocation directory. Within one workspace the two coincide; a `defaultPackage` target carrying its own Vite+ install or runtime pin is resolved by the invoking CLI today (full re-delegation is tracked as follow-up work). ### Entry points and version assumption From ce2b9e3faee7c85c427f0a1fee2ed405b39534b1 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 6 Jul 2026 05:03:45 +0800 Subject: [PATCH 27/33] fix(cli): restore parse_leading_chdir test import lost in a restack The test module imports items explicitly; a restack conflict resolution kept the tests but dropped the import, which only all-targets compilation catches (workspace clippy now runs clean with CI's exact invocation). --- crates/vite_global_cli/src/main.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/vite_global_cli/src/main.rs b/crates/vite_global_cli/src/main.rs index 2521e6fe16..bc60466b7d 100644 --- a/crates/vite_global_cli/src/main.rs +++ b/crates/vite_global_cli/src/main.rs @@ -510,7 +510,8 @@ mod tests { use super::{ extract_unknown_argument, has_pass_as_value_suggestion, is_affirmative_response, - normalize_args, replace_top_level_typoed_subcommand, try_parse_args_from, + normalize_args, parse_leading_chdir, replace_top_level_typoed_subcommand, + try_parse_args_from, }; fn s(v: &[&str]) -> Vec { From d03af31fe69ef13fcff41274ccc3c729c67e38dd Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 6 Jul 2026 06:13:20 +0800 Subject: [PATCH 28/33] fix(cli): address Codex review round 9 - pack workspace selectors also bypass elicitation in inline form (--filter=ui, --workspace=packages/ui) - honor the -- option terminator: a token after it is an explicit positional, and bare -- stays bare - global vpr rejects a bare -C with no value instead of delegating it as a run argument (mirrors the local wrapper fix) - local bin.ts catches process.chdir failures (e.g. permission denied on an existing directory) as a normal CLI error instead of an uncaught stack trace - render the spread-config build case through the PTY: non-TTY steps record raw bytes and the Windows byte stream of a vite build differs invisibly from the recorded macOS one --- .../app_root_default_package/snapshots.toml | 5 ++++- .../unanalyzable_config_ignored.global.md | 5 +++-- .../unanalyzable_config_ignored.local.md | 3 +-- crates/vite_global_cli/src/commands/vpr.rs | 8 ++++++++ packages/cli/binding/src/cli/app_target.rs | 19 +++++++++++++++++-- packages/cli/src/bin.ts | 7 ++++++- 6 files changed, 39 insertions(+), 8 deletions(-) diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots.toml index a8c3646728..e85cd246d9 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots.toml +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots.toml @@ -25,4 +25,7 @@ Regression guard for spread/unanalyzable configs: a config that only parses as an open map might hide defaultPackage behind the spread, but that must not fail the command. Bare vp build falls through and runs in place. """ -steps = [{ argv = ["vp", "build"], cwd = "spread", tty = false }] +# PTY-rendered (not tty = false): non-TTY steps record raw bytes, and the +# raw Windows byte stream of a vite build differs invisibly from macOS; the +# grid rendering normalizes that and is what the assertion needs anyway. +steps = [{ argv = ["vp", "build"], cwd = "spread" }] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/unanalyzable_config_ignored.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/unanalyzable_config_ignored.global.md index ea3ed4a926..ea92615f0c 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/unanalyzable_config_ignored.global.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/unanalyzable_config_ignored.global.md @@ -7,9 +7,10 @@ not fail the command. Bare vp build falls through and runs in place. ## `cd spread && vp build` ``` +VITE+ - The Unified Toolchain for the Web + vite building client environment for production... - transforming...✓ 2 modules transformed. -rendering chunks... +✓ 2 modules transformed. computing gzip size... dist/index.html kB │ gzip: kB diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/unanalyzable_config_ignored.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/unanalyzable_config_ignored.local.md index ea3ed4a926..1446589b7d 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/unanalyzable_config_ignored.local.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/unanalyzable_config_ignored.local.md @@ -8,8 +8,7 @@ not fail the command. Bare vp build falls through and runs in place. ``` vite building client environment for production... - transforming...✓ 2 modules transformed. -rendering chunks... +✓ 2 modules transformed. computing gzip size... dist/index.html kB │ gzip: kB diff --git a/crates/vite_global_cli/src/commands/vpr.rs b/crates/vite_global_cli/src/commands/vpr.rs index e83e4f2194..d6804d0214 100644 --- a/crates/vite_global_cli/src/commands/vpr.rs +++ b/crates/vite_global_cli/src/commands/vpr.rs @@ -14,6 +14,14 @@ pub async fn execute_vpr(args: &[String], cwd: &AbsolutePath) -> i32 { // global flag before treating the rest as run arguments. let mut args = args; let mut cwd_buf = cwd.to_absolute_path_buf(); + // A bare `-C` with no value must error like the vp binary would, not + // fall through as a run argument (there is no clap parse on this path). + if args.first().is_some_and(|arg| matches!(arg.as_str(), "-C" | "-C=")) + && crate::parse_leading_chdir(args).is_none() + { + output::raw_stderr("-C requires a directory argument"); + return 1; + } if let Some((dir, consumed)) = crate::parse_leading_chdir(args) { cwd_buf = cwd_buf.join(&dir).clean(); if !cwd_buf.as_path().is_dir() { diff --git a/packages/cli/binding/src/cli/app_target.rs b/packages/cli/binding/src/cli/app_target.rs index 4040701804..9d79721cf2 100644 --- a/packages/cli/binding/src/cli/app_target.rs +++ b/packages/cli/binding/src/cli/app_target.rs @@ -98,7 +98,15 @@ fn is_bare(command: &str, args: &[String]) -> bool { if !arg.starts_with('-') || super::help::is_app_tool_help_or_version_flag(arg) { return false; } - if is_pack && matches!(arg.as_str(), "-W" | "--workspace" | "-F" | "--filter") { + // `--` terminates options: whatever follows is an explicit positional. + if arg == "--" { + return iter.next().is_none(); + } + if is_pack + && ["-W", "--workspace", "-F", "--filter"] + .iter() + .any(|f| arg == f || arg.strip_prefix(f).is_some_and(|r| r.starts_with('='))) + { return false; } let is_boolean = booleans.contains(&arg.as_str()) || arg.starts_with("--no-"); @@ -371,10 +379,17 @@ mod tests { assert!(!is_bare("pack", &to_args(&["--minify", "src/index.ts"]))); assert!(!is_bare("pack", &to_args(&["--env.FOO", "bar", "src/cli.ts"]))); assert!(is_bare("build", &to_args(&["--minify", "esbuild"]))); - // pack workspace selectors define their own target set. + // pack workspace selectors define their own target set, in both the + // spaced and inline-value forms. assert!(!is_bare("pack", &to_args(&["-W"]))); assert!(!is_bare("pack", &to_args(&["--workspace", "packages/a"]))); assert!(!is_bare("pack", &to_args(&["-F", "ui"]))); + assert!(!is_bare("pack", &to_args(&["--filter=ui"]))); + assert!(!is_bare("pack", &to_args(&["--workspace=packages/a"]))); + // `--` terminates options; a token after it is an explicit positional. + assert!(!is_bare("build", &to_args(&["--", "apps/web"]))); + assert!(!is_bare("pack", &to_args(&["--minify", "--", "src/index.ts"]))); + assert!(is_bare("build", &to_args(&["--"]))); // Help/version requests go to the underlying tool, never elicitation. assert!(!is_bare("dev", &to_args(&["--help"]))); assert!(!is_bare("dev", &to_args(&["-h"]))); diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 1bdfb0b601..2b752bd63a 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -56,7 +56,12 @@ if (args[0]?.startsWith('-C')) { errorMsg(`directory not found: ${dir}`); process.exit(1); } - process.chdir(target); + try { + process.chdir(target); + } catch (err) { + errorMsg(`cannot change directory to ${dir}: ${getErrorMessage(err)}`); + process.exit(1); + } if (process.platform !== 'win32') { // Keep the POSIX PWD in sync, like a real `cd`. process.env.PWD = target; From 89cf25575cf4db12265490527d2806f688357035 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 6 Jul 2026 09:45:17 +0800 Subject: [PATCH 29/33] fix(cli): treat pack --root as an explicit target vp pack --root packages/lib at a workspace root was a valid targeted invocation before this PR; like -W/--workspace and -F/--filter it defines pack's own target, so it bypasses elicitation (spaced and inline forms) instead of hitting the picker or the non-TTY listing. --- packages/cli/binding/src/cli/app_target.rs | 6 +++++- rfcs/cwd-flag.md | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/cli/binding/src/cli/app_target.rs b/packages/cli/binding/src/cli/app_target.rs index 9d79721cf2..a46f933fee 100644 --- a/packages/cli/binding/src/cli/app_target.rs +++ b/packages/cli/binding/src/cli/app_target.rs @@ -102,8 +102,10 @@ fn is_bare(command: &str, args: &[String]) -> bool { if arg == "--" { return iter.next().is_none(); } + // Workspace selectors and --root already specify pack's target; + // these previously-valid targeted invocations must keep forwarding. if is_pack - && ["-W", "--workspace", "-F", "--filter"] + && ["-W", "--workspace", "-F", "--filter", "--root"] .iter() .any(|f| arg == f || arg.strip_prefix(f).is_some_and(|r| r.starts_with('='))) { @@ -386,6 +388,8 @@ mod tests { assert!(!is_bare("pack", &to_args(&["-F", "ui"]))); assert!(!is_bare("pack", &to_args(&["--filter=ui"]))); assert!(!is_bare("pack", &to_args(&["--workspace=packages/a"]))); + assert!(!is_bare("pack", &to_args(&["--root", "packages/lib"]))); + assert!(!is_bare("pack", &to_args(&["--root=packages/lib"]))); // `--` terminates options; a token after it is an explicit positional. assert!(!is_bare("build", &to_args(&["--", "apps/web"]))); assert!(!is_bare("pack", &to_args(&["--minify", "--", "src/index.ts"]))); diff --git a/rfcs/cwd-flag.md b/rfcs/cwd-flag.md index 981eed75ae..91366121e0 100644 --- a/rfcs/cwd-flag.md +++ b/rfcs/cwd-flag.md @@ -246,7 +246,7 @@ Rejected alternatives: repurposing the app-command positional to mean "run there ### Target directory resolution -An app command invocation is **bare** when it has no `-C` and no positional target (no Vite `[root]`, no pack entries). The classification mirrors the tools' own cac parsing: a non-flag token following any non-boolean flag is that flag's value — required and optional values alike (`--port 3000`, `--host 0.0.0.0`) — because the tool itself would never treat it as a positional; only a token no flag consumes is a positional target, and any positional disables elicitation. The boolean-flag tables come from the shipped `--help` of each tool and are command-specific (`--minify` is optional-value for Vite build but boolean for pack). pack's workspace selectors (`-W`/`--workspace`, `-F`/`--filter`) already define their own target set and always disable elicitation. For `vp dev` / `build` / `preview` / `pack`, the target directory is resolved in this order: +An app command invocation is **bare** when it has no `-C` and no positional target (no Vite `[root]`, no pack entries). The classification mirrors the tools' own cac parsing: a non-flag token following any non-boolean flag is that flag's value — required and optional values alike (`--port 3000`, `--host 0.0.0.0`) — because the tool itself would never treat it as a positional; only a token no flag consumes is a positional target, and any positional disables elicitation. The boolean-flag tables come from the shipped `--help` of each tool and are command-specific (`--minify` is optional-value for Vite build but boolean for pack). pack's target selectors (`-W`/`--workspace`, `-F`/`--filter`, `--root`) already define their own target and always disable elicitation. For `vp dev` / `build` / `preview` / `pack`, the target directory is resolved in this order: 1. **`-C `**: run there. Never triggers the picker. 2. **Positional target present**: forward as today, upstream semantics, vp does not interfere. From 1569045a503e0be1a8ede8ac47c6fc1a54b8d5b0 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 6 Jul 2026 10:09:51 +0800 Subject: [PATCH 30/33] fix(cli): gate picker milestones behind VP_EMIT_MILESTONES Match the documented convention in packages/prompts/src/milestone.ts: only the PTY snapshot harness sets the env, so real terminals and piped output never receive the OSC 8 marker bytes. The picker fixtures keep passing because the harness sets the gate for spawned processes. --- packages/cli/binding/src/cli/app_target.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/cli/binding/src/cli/app_target.rs b/packages/cli/binding/src/cli/app_target.rs index a46f933fee..63ea76ef71 100644 --- a/packages/cli/binding/src/cli/app_target.rs +++ b/packages/cli/binding/src/cli/app_target.rs @@ -180,9 +180,12 @@ fn resolve_default_package(command: &str, cwd: &AbsolutePathBuf) -> Option:` milestone -/// (invisible OSC 8 hyperlinks) so PTY snapshot tests can synchronize. +/// Ctrl+C. When the PTY snapshot harness sets `VP_EMIT_MILESTONES=1`, every +/// render emits a `package-select::` milestone (invisible +/// OSC 8 hyperlinks) for the tests to synchronize on — same gate as +/// packages/prompts/src/milestone.ts; real terminals never see the bytes. fn run_package_picker(command: &str, rows: &[PackageRow]) -> Result, Error> { + let emit_milestones = std::env::var_os("VP_EMIT_MILESTONES").is_some_and(|value| value == "1"); let items: Vec = rows .iter() .map(|row| vite_select::SelectItem { @@ -209,6 +212,9 @@ fn run_package_picker(command: &str, rows: &[PackageRow]) -> Result Date: Mon, 6 Jul 2026 10:32:44 +0800 Subject: [PATCH 31/33] fix(cli): make chdir the single -C validation point in bin.ts fs.statSync can itself throw (EACCES on a parent, ELOOP) before the chdir try/catch; chdir reports every failure mode through one catchable path, with ENOENT/ENOTDIR keeping the directory-not-found message. --- packages/cli/src/bin.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 2b752bd63a..b085e0116b 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -10,7 +10,6 @@ * If no local installation is found, this global dist/bin.js is used as fallback. */ -import fs from 'node:fs'; import path from 'node:path'; import { run } from '../binding/index.js'; @@ -51,15 +50,18 @@ if (args[0]?.startsWith('-C')) { process.exit(1); } const target = path.resolve(dir); - const stat = fs.statSync(target, { throwIfNoEntry: false }); - if (!stat?.isDirectory()) { - errorMsg(`directory not found: ${dir}`); - process.exit(1); - } + // chdir is the single validation point: a pre-check stat can itself throw + // (EACCES on a parent, ELOOP), while chdir reports every failure mode + // through one catchable path. try { process.chdir(target); } catch (err) { - errorMsg(`cannot change directory to ${dir}: ${getErrorMessage(err)}`); + const code = typeof err === 'object' && err !== null && 'code' in err ? err.code : undefined; + if (code === 'ENOENT' || code === 'ENOTDIR') { + errorMsg(`directory not found: ${dir}`); + } else { + errorMsg(`cannot change directory to ${dir}: ${getErrorMessage(err)}`); + } process.exit(1); } if (process.platform !== 'win32') { From bf8a5b69bf21c7e8ed3ab0d435747a9a77383965 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 6 Jul 2026 11:58:20 +0800 Subject: [PATCH 32/33] refactor(cli): apply cleanup review across the -C and elicitation code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - one apply_chdir helper (join/clean/validate/chdir/PWD) replaces the four drifting copies in vite_global_cli: main's normal path, the completion peek, vpr, and the clap-path fallback in cli.rs — which previously skipped set_current_dir and the PWD sync, silently weakening the cd equivalence on orderings like a second -C - one classify() core (bare -> defaultPackage-at-root -> workspace root) now feeds both resolve_app_target and needs_elicitation, so the RFC's resolution order is written once and the two entry points cannot drift; the extracted defaultPackage value rides the enum, removing a duplicate config parse - bin/vpr is a grammar-free shim: bin.ts inserts 'run' itself via argv0 after its existing -C consumption, deleting the hand-synchronized second JS copy of the -C token grammar - pack runnable check stats src/index.ts before parsing the config (runs per workspace package); PWD in the direct-subcommand env is only overridden when elicitation retargeted (plain runs keep the caller's possibly-symlinked PWD verbatim); vpr parses -C once; identical FieldMapInner arms collapse via or-patterns; handler's -C predicate simplifies to starts_with --- crates/vite_global_cli/src/cli.rs | 13 +-- crates/vite_global_cli/src/commands/vpr.rs | 37 +++--- crates/vite_global_cli/src/main.rs | 53 +++++---- crates/vite_static_config/src/lib.rs | 27 ++--- packages/cli/bin/vpr | 17 +-- packages/cli/binding/src/cli/app_target.rs | 129 +++++++++++---------- packages/cli/binding/src/cli/handler.rs | 7 +- packages/cli/binding/src/cli/mod.rs | 10 +- packages/cli/src/bin.ts | 9 ++ 9 files changed, 145 insertions(+), 157 deletions(-) diff --git a/crates/vite_global_cli/src/cli.rs b/crates/vite_global_cli/src/cli.rs index 2b7d50c5a9..437ee17d33 100644 --- a/crates/vite_global_cli/src/cli.rs +++ b/crates/vite_global_cli/src/cli.rs @@ -856,14 +856,13 @@ pub async fn run_command_with_options( args: Args, render_options: RenderOptions, ) -> Result { - // Apply the global `-C ` flag before anything reads cwd, so local CLI - // resolution and command execution behave as if vp was started in . - // `clean()` normalizes `.`/`..` so upward workspace walks never see them. + // Apply the global `-C ` flag before anything reads cwd. main + // normally consumes a leading `-C` pre-parse; this covers orderings that + // reach clap (e.g. a second `-C`), with identical semantics — including + // the process-cwd change and PWD sync the shared helper performs. if let Some(dir) = &args.chdir { - cwd = cwd.join(dir).clean(); - if !cwd.as_path().is_dir() { - return Err(Error::UserMessage(format!("directory not found: {dir}").into())); - } + cwd = + crate::apply_chdir(&cwd, dir).map_err(|message| Error::UserMessage(message.into()))?; } // Handle --version flag (Category B: delegates to JS) diff --git a/crates/vite_global_cli/src/commands/vpr.rs b/crates/vite_global_cli/src/commands/vpr.rs index d6804d0214..8465c916f3 100644 --- a/crates/vite_global_cli/src/commands/vpr.rs +++ b/crates/vite_global_cli/src/commands/vpr.rs @@ -11,33 +11,26 @@ use vite_shared::output; /// Called from shim dispatch when `argv[0]` is `vpr`. pub async fn execute_vpr(args: &[String], cwd: &AbsolutePath) -> i32 { // `vpr -C ` mirrors `vp -C run `: consume the - // global flag before treating the rest as run arguments. + // global flag before treating the rest as run arguments. There is no + // clap parse on this path, so a missing value is reported here. let mut args = args; let mut cwd_buf = cwd.to_absolute_path_buf(); - // A bare `-C` with no value must error like the vp binary would, not - // fall through as a run argument (there is no clap parse on this path). - if args.first().is_some_and(|arg| matches!(arg.as_str(), "-C" | "-C=")) - && crate::parse_leading_chdir(args).is_none() - { - output::raw_stderr("-C requires a directory argument"); - return 1; - } - if let Some((dir, consumed)) = crate::parse_leading_chdir(args) { - cwd_buf = cwd_buf.join(&dir).clean(); - if !cwd_buf.as_path().is_dir() { - output::raw_stderr(&format!("directory not found: {dir}")); - return 1; + match crate::parse_leading_chdir(args) { + Some((dir, consumed)) => { + cwd_buf = match crate::apply_chdir(cwd, &dir) { + Ok(target) => target, + Err(message) => { + output::raw_stderr(&message); + return 1; + } + }; + args = &args[consumed..]; } - if std::env::set_current_dir(cwd_buf.as_path()).is_err() { - output::error(&format!("Failed to change directory to {dir}")); + None if args.first().is_some_and(|arg| arg.starts_with("-C")) => { + output::raw_stderr("-C requires a directory argument"); return 1; } - #[cfg(unix)] - // SAFETY: single-threaded startup, before any command logic runs. - unsafe { - std::env::set_var("PWD", cwd_buf.as_path()); - } - args = &args[consumed..]; + None => {} } if crate::help::maybe_print_unified_delegate_help("run", args, true) { diff --git a/crates/vite_global_cli/src/main.rs b/crates/vite_global_cli/src/main.rs index bc60466b7d..df1537403c 100644 --- a/crates/vite_global_cli/src/main.rs +++ b/crates/vite_global_cli/src/main.rs @@ -62,6 +62,30 @@ pub(crate) fn parse_leading_chdir(user_args: &[String]) -> Option<(String, usize /// - `vp rebuild ...` → `vp pm rebuild ...` /// - `vp help [command] [args...]` → `vp [command] [args...] --help` /// - `vp node [args...]` → `vp env exec node [args...]` +/// Apply `-C `: resolve against `cwd`, validate, change the process +/// cwd, and keep the POSIX `PWD` in sync like a real `cd`. Returns the new +/// cwd, or the user-facing error message (not printed here: the completion +/// peek must stay silent, and clap-path callers wrap it in their error type). +pub(crate) fn apply_chdir( + cwd: &vite_path::AbsolutePath, + dir: &str, +) -> Result { + let target = cwd.join(dir).clean(); + if !target.as_path().is_dir() { + return Err(format!("directory not found: {dir}")); + } + if let Err(e) = std::env::set_current_dir(target.as_path()) { + return Err(format!("Failed to change directory to {dir}: {e}")); + } + // Node tools commonly read process.env.PWD. + #[cfg(unix)] + // SAFETY: single-threaded startup, before any command logic runs. + unsafe { + std::env::set_var("PWD", target.as_path()); + } + Ok(target) +} + fn normalize_args(args: Vec) -> Vec { let mut normalized = args; loop { @@ -333,10 +357,8 @@ async fn main() -> ExitCode { && let Some((dir, _)) = parse_leading_chdir(&args[1..]) && let Ok(current) = vite_path::current_dir() { - let target = current.join(&dir).clean(); - if target.as_path().is_dir() { - let _ = std::env::set_current_dir(target.as_path()); - } + // Best-effort and silent: mid-typing values are often not (yet) dirs. + let _ = apply_chdir(¤t, &dir); } CompleteEnv::with_factory(command_with_help).var("VP_COMPLETE").complete(); @@ -367,22 +389,13 @@ async fn main() -> ExitCode { // cwd (before any command logic runs) keeps `vite_path::current_dir()` // callers deep inside command implementations equivalent to the cd form. if let Some((dir, consumed)) = parse_leading_chdir(&args[1..]) { - cwd = cwd.join(&dir).clean(); - if !cwd.as_path().is_dir() { - output::raw_stderr(&format!("directory not found: {dir}")); - return ExitCode::FAILURE; - } - if let Err(e) = std::env::set_current_dir(cwd.as_path()) { - output::error(&format!("Failed to change directory to {dir}: {e}")); - return ExitCode::FAILURE; - } - // Keep the POSIX PWD in sync, like a real `cd`: Node tools commonly - // read process.env.PWD. - #[cfg(unix)] - // SAFETY: single-threaded startup, before any command logic runs. - unsafe { - std::env::set_var("PWD", cwd.as_path()); - } + cwd = match apply_chdir(&cwd, &dir) { + Ok(target) => target, + Err(message) => { + output::raw_stderr(&message); + return ExitCode::FAILURE; + } + }; args.drain(1..=consumed); } diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index 2727c33a62..21f2baeac1 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -79,10 +79,8 @@ impl FieldMap { /// unanalyzable config would break unrelated commands). #[must_use] pub fn get_declared(&self, key: &str) -> Option { - match &self.0 { - FieldMapInner::Closed(map) => map.get(key).cloned(), - FieldMapInner::Open(map) => map.get(key).cloned(), - } + let (FieldMapInner::Closed(map) | FieldMapInner::Open(map)) = &self.0; + map.get(key).cloned() } } @@ -347,21 +345,12 @@ fn extract_object_fields(obj: &oxc_ast::ast::ObjectExpression<'_>) -> FieldMap { continue; }; - match &mut inner { - FieldMapInner::Closed(map) => { - let value = - expr_to_json(&prop.value).map_or(FieldValue::NonStatic, FieldValue::Json); - map.insert(Box::from(key.as_ref()), value); - } - FieldMapInner::Open(map) => { - // Record explicit declarations, including NonStatic ones: - // `get_declared` must distinguish a written `key: expr` from a - // key that merely might exist behind the spread. - let value = - expr_to_json(&prop.value).map_or(FieldValue::NonStatic, FieldValue::Json); - map.insert(Box::from(key.as_ref()), value); - } - } + // Both variants record explicit declarations, including NonStatic + // ones: `get_declared` must distinguish a written `key: expr` from a + // key that merely might exist behind a spread. + let (FieldMapInner::Closed(map) | FieldMapInner::Open(map)) = &mut inner; + let value = expr_to_json(&prop.value).map_or(FieldValue::NonStatic, FieldValue::Json); + map.insert(Box::from(key.as_ref()), value); } FieldMap(inner) diff --git a/packages/cli/bin/vpr b/packages/cli/bin/vpr index 8f8d4c24ab..8550f94294 100755 --- a/packages/cli/bin/vpr +++ b/packages/cli/bin/vpr @@ -4,19 +4,4 @@ import module from 'node:module'; if (module.enableCompileCache) { module.enableCompileCache(); } - -// Insert `run` after a leading global `-C ` so `vpr -C ` -// behaves like `vp -C run ` (bin.ts consumes -C before dispatch). -// A bare `vpr -C` (missing value) inserts nothing: bin.ts then reports the -// missing-argument error instead of misreading `run` as the directory. -const first = process.argv[2]; -if (first === '-C') { - if (process.argv[3] !== undefined) { - process.argv.splice(4, 0, 'run'); - } -} else if (first?.startsWith('-C') && first.length > 2) { - process.argv.splice(3, 0, 'run'); -} else { - process.argv.splice(2, 0, 'run'); -} -await import('../dist/bin.js'); +import '../dist/bin.js'; diff --git a/packages/cli/binding/src/cli/app_target.rs b/packages/cli/binding/src/cli/app_target.rs index 63ea76ef71..650f859ddd 100644 --- a/packages/cli/binding/src/cli/app_target.rs +++ b/packages/cli/binding/src/cli/app_target.rs @@ -134,47 +134,49 @@ fn is_bare(command: &str, args: &[String]) -> bool { /// silent root build this feature exists to prevent. fn looks_runnable(dir: &AbsolutePathBuf, command: &str, is_root: bool) -> bool { match command { - // Bare `vp pack` succeeds when the config explicitly declares a - // `pack` block or tsdown's default entry exists. A spread that only + // Bare `vp pack` succeeds when tsdown's default entry exists or the + // config explicitly declares a `pack` block (a spread that only // might contain `pack` does not count: auto-select acts on this - // signal, so a false positive runs tsdown in a non-packable package. + // signal, so a false positive runs tsdown in a non-packable + // package). The one-stat entry check runs first: this executes per + // workspace package, and the config check reads and parses a file. "pack" => { - vite_static_config::resolve_static_config(dir).get_declared("pack").is_some() - || dir.as_path().join("src/index.ts").is_file() + dir.as_path().join("src/index.ts").is_file() + || vite_static_config::resolve_static_config(dir).get_declared("pack").is_some() } _ if is_root => dir.as_path().join("index.html").is_file(), _ => vite_static_config::has_config_file(dir) || dir.as_path().join("index.html").is_file(), } } -/// `defaultPackage` from the `vite.config.*` in `cwd`, read via static -/// extraction so it works at roots without a vite-plus install (non-workspace -/// framework repos). The value must be a static string literal. -/// -/// `get_declared` keeps this to explicitly written fields: a config that is -/// unanalyzable or hides fields behind a spread simply falls through to the -/// picker/current-dir resolution instead of failing every bare app command. -fn resolve_default_package(command: &str, cwd: &AbsolutePathBuf) -> Option { +/// Resolve the `defaultPackage` value [`classify`] extracted from the +/// invocation root's `vite.config.*` (static extraction, so it works at +/// roots without a vite-plus install). The value must be a static string +/// literal naming an existing directory. +fn resolve_default_package( + command: &str, + cwd: &AbsolutePathBuf, + value: vite_static_config::FieldValue, +) -> AppTarget { let fail = |msg: &str| { output::error(msg); - Some(AppTarget::Exit(ExitStatus(1))) + AppTarget::Exit(ExitStatus(1)) }; - match vite_static_config::resolve_static_config(cwd).get_declared("defaultPackage") { - Some(vite_static_config::FieldValue::Json(serde_json::Value::String(dir))) => { + match value { + vite_static_config::FieldValue::Json(serde_json::Value::String(dir)) => { let target = cwd.join(&dir).clean(); if !target.as_path().is_dir() { return fail(&format!("defaultPackage points to a missing directory: {dir}")); } output::note(&format!("vp {command}: using {dir} (defaultPackage)")); - Some(AppTarget::Dir(target)) + AppTarget::Dir(target) } - Some(vite_static_config::FieldValue::Json(other)) => { + vite_static_config::FieldValue::Json(other) => { fail(&format!("defaultPackage must be a string of a directory, got: {other}")) } - Some(vite_static_config::FieldValue::NonStatic) => fail( + vite_static_config::FieldValue::NonStatic => fail( "defaultPackage in vite.config.ts must be a static string literal so vp can read it without executing the config", ), - None => None, } } @@ -239,67 +241,68 @@ pub(super) fn needs_elicitation( subcommand: &SynthesizableSubcommand, cwd: &AbsolutePathBuf, ) -> bool { - let Some((command, args)) = app_command_parts(subcommand) else { - return false; - }; - if !is_bare(command, args) { - return false; - } - let workspace = vite_workspace::find_workspace_root(cwd); - if at_invocation_root(workspace.as_ref().ok().map(|(_, rel)| rel.as_str())) - && vite_static_config::resolve_static_config(cwd).get_declared("defaultPackage").is_some() - { - return true; - } - let Ok((workspace_root, rel_from_root)) = workspace else { - return false; - }; - rel_from_root.as_str().is_empty() - && !matches!(workspace_root.workspace_file, WorkspaceFile::NonWorkspacePackage(_)) + classify(subcommand, cwd).is_some() } -/// `defaultPackage` is a root-pointer concept: it applies where the -/// invocation directory is its own root (a workspace root, a standalone -/// package, or a framework directory with no package.json ancestry — pass -/// the workspace lookup's `rel_from_root`, or `None` when the lookup -/// failed). Below a workspace root the current directory already identifies -/// the target package, so a member's own config must not redirect. -fn at_invocation_root(rel_from_root: Option<&str>) -> bool { - rel_from_root.is_none_or(str::is_empty) +/// Why a bare app command needs target elicitation. +enum Elicitation { + /// The invocation root's config explicitly declares `defaultPackage` + /// (with this value — possibly invalid, which the resolver reports). + DefaultPackage(vite_static_config::FieldValue), + /// Bare app command at a real workspace root: picker/listing territory. + WorkspaceRoot(vite_workspace::WorkspaceRoot), } -pub(super) fn resolve_app_target( +/// The RFC's resolution order, written once for both entry points: bare app +/// command, then `defaultPackage` at the invocation root, then the workspace +/// root itself. `defaultPackage` is a root-pointer concept: it applies where +/// the invocation directory is its own root (a workspace root, a standalone +/// package, or a framework directory with no package.json ancestry); below a +/// workspace root the current directory already identifies the target, so a +/// member's own config must not redirect. +fn classify( subcommand: &SynthesizableSubcommand, cwd: &AbsolutePathBuf, -) -> Result { - let Some((command, args)) = app_command_parts(subcommand) else { - return Ok(AppTarget::CurrentDir); - }; +) -> Option<(&'static str, Elicitation)> { + let (command, args) = app_command_parts(subcommand)?; if !is_bare(command, args) { - return Ok(AppTarget::CurrentDir); + return None; } - - // `defaultPackage` is consulted before the workspace-shape dispatch (the - // non-workspace framework shape has no workspace metadata at all), but - // only at the invocation root: a member package's config must not - // redirect a command already running in that member. let workspace = vite_workspace::find_workspace_root(cwd); - if at_invocation_root(workspace.as_ref().ok().map(|(_, rel)| rel.as_str())) - && let Some(target) = resolve_default_package(command, cwd) + let at_invocation_root = + workspace.as_ref().map_or(true, |(_, rel_from_root)| rel_from_root.as_str().is_empty()); + if at_invocation_root + && let Some(value) = + vite_static_config::resolve_static_config(cwd).get_declared("defaultPackage") { - return Ok(target); + return Some((command, Elicitation::DefaultPackage(value))); } - - // The package listing needs workspace metadata; anything unresolvable + // The picker/listing needs workspace metadata; anything unresolvable // keeps today's behavior (the caller surfaces its own workspace errors). let Ok((workspace_root, rel_from_root)) = workspace else { - return Ok(AppTarget::CurrentDir); + return None; }; if !rel_from_root.as_str().is_empty() || matches!(workspace_root.workspace_file, WorkspaceFile::NonWorkspacePackage(_)) { - return Ok(AppTarget::CurrentDir); + return None; } + Some((command, Elicitation::WorkspaceRoot(workspace_root))) +} + +pub(super) fn resolve_app_target( + subcommand: &SynthesizableSubcommand, + cwd: &AbsolutePathBuf, +) -> Result { + let Some((command, elicitation)) = classify(subcommand, cwd) else { + return Ok(AppTarget::CurrentDir); + }; + let workspace_root = match elicitation { + Elicitation::DefaultPackage(value) => { + return Ok(resolve_default_package(command, cwd, value)); + } + Elicitation::WorkspaceRoot(workspace_root) => workspace_root, + }; let graph = vite_workspace::load_package_graph(&workspace_root).map_err(|e| Error::Anyhow(e.into()))?; diff --git a/packages/cli/binding/src/cli/handler.rs b/packages/cli/binding/src/cli/handler.rs index c80519dc71..bfd07e49b6 100644 --- a/packages/cli/binding/src/cli/handler.rs +++ b/packages/cli/binding/src/cli/handler.rs @@ -47,12 +47,7 @@ impl CommandHandler for VitePlusCommandHandler { // A leading global `-C` runs verbatim: CLIArgs has no global flags, and // the spawned vp/vpr binary applies the directory change (and target // elicitation) exactly like a direct invocation. - if command - .args - .first() - .map(Str::as_str) - .is_some_and(|arg| arg == "-C" || (arg.starts_with("-C") && arg.len() > 2)) - { + if command.args.first().is_some_and(|arg| arg.starts_with("-C")) { return Ok(HandledCommand::Verbatim); } // "vpr " is shorthand for "vp run ", so prepend "run" for parsing. diff --git a/packages/cli/binding/src/cli/mod.rs b/packages/cli/binding/src/cli/mod.rs index 7c52671e8f..b1dbddad20 100644 --- a/packages/cli/binding/src/cli/mod.rs +++ b/packages/cli/binding/src/cli/mod.rs @@ -51,6 +51,7 @@ async fn execute_direct_subcommand( // (defaultPackage, package listing); the command then runs as if invoked // in the resolved directory (rfcs/cwd-flag.md). let target = app_target::resolve_app_target(&subcommand, cwd)?; + let retargeted = matches!(&target, app_target::AppTarget::Dir(_)); let cwd = match &target { app_target::AppTarget::Exit(status) => return Ok(*status), app_target::AppTarget::Dir(dir) => dir, @@ -70,10 +71,11 @@ async fn execute_direct_subcommand( let mut envs: FxHashMap, Arc> = std::env::vars_os() .map(|(k, v)| (Arc::from(k.as_os_str()), Arc::from(v.as_os_str()))) .collect(); - // The tool runs with `cwd` as its working directory; keep the POSIX - // PWD consistent with it, like a real `cd` (matters when elicitation - // retargeted the command to another package). - if cfg!(unix) { + // When elicitation retargeted the command, the tool runs with the + // target as its working directory: keep the POSIX PWD consistent, + // like a real `cd`. Untargeted runs keep the caller's PWD verbatim + // (it may legitimately differ from cwd through shell symlinks). + if cfg!(unix) && retargeted { envs.insert(Arc::from(OsStr::new("PWD")), Arc::from(cwd.as_path().as_os_str())); } envs diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index b085e0116b..f7ee7d07a1 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -72,6 +72,15 @@ if (args[0]?.startsWith('-C')) { process.argv = process.argv.slice(0, 2).concat(args); } +// `vpr` is shorthand for `vp run`: the bin/vpr shim imports this file +// unchanged (argv0 tells them apart, like the Rust shim dispatch), and the +// rewrite happens here, after -C consumption, so `vpr -C ` +// orders itself correctly by construction. +if (path.basename(process.argv[1] ?? '') === 'vpr') { + args = ['run', ...args]; + process.argv = process.argv.slice(0, 2).concat(args); +} + // Transform `vp help [command]` into `vp [command] --help` if (args[0] === 'help' && args[1]) { args = [args[1], '--help', ...args.slice(2)]; From 5f47c67e84ff8a8fba231ac4a5778d9af5be709b Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 6 Jul 2026 15:00:32 +0800 Subject: [PATCH 33/33] fix(cli): keep normalize_args doc off apply_chdir The helper was inserted between normalize_args's bullet-list doc comment and the function, merging the docs; clippy reads the helper's prose as unindented list continuations under --deny warnings. --- crates/vite_global_cli/src/main.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/vite_global_cli/src/main.rs b/crates/vite_global_cli/src/main.rs index df1537403c..ace35e84c7 100644 --- a/crates/vite_global_cli/src/main.rs +++ b/crates/vite_global_cli/src/main.rs @@ -57,11 +57,6 @@ pub(crate) fn parse_leading_chdir(user_args: &[String]) -> Option<(String, usize None } -/// Normalize CLI arguments: -/// - `vp list ...` / `vp ls ...` → `vp pm list ...` -/// - `vp rebuild ...` → `vp pm rebuild ...` -/// - `vp help [command] [args...]` → `vp [command] [args...] --help` -/// - `vp node [args...]` → `vp env exec node [args...]` /// Apply `-C `: resolve against `cwd`, validate, change the process /// cwd, and keep the POSIX `PWD` in sync like a real `cd`. Returns the new /// cwd, or the user-facing error message (not printed here: the completion @@ -86,6 +81,11 @@ pub(crate) fn apply_chdir( Ok(target) } +/// Normalize CLI arguments: +/// - `vp list ...` / `vp ls ...` → `vp pm list ...` +/// - `vp rebuild ...` → `vp pm rebuild ...` +/// - `vp help [command] [args...]` → `vp [command] [args...] --help` +/// - `vp node [args...]` → `vp env exec node [args...]` fn normalize_args(args: Vec) -> Vec { let mut normalized = args; loop {