Skip to content

Commit f108438

Browse files
committed
feat(cli): global -C flag and workspace-root target elicitation for app commands
Implements rfcs/cwd-flag.md (RFC #2022): - vp -C <dir> <cmd> runs any command as if started in <dir> (git/make/pnpm convention), parsed by both the global binary and the local bin; the spawned tool gets <dir> 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
1 parent df5abf7 commit f108438

27 files changed

Lines changed: 442 additions & 1 deletion

File tree

crates/vite_global_cli/src/cli.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ pub struct Args {
5353
#[arg(short = 'V', long = "version")]
5454
pub version: bool,
5555

56+
/// Run as if vp was started in <DIR> instead of the current working directory
57+
#[arg(short = 'C', value_name = "DIR")]
58+
pub chdir: Option<String>,
59+
5660
#[clap(subcommand)]
5761
pub command: Option<Commands>,
5862
}
@@ -848,10 +852,19 @@ pub async fn run_command(cwd: AbsolutePathBuf, args: Args) -> Result<ExitStatus,
848852

849853
/// Run the CLI command with rendering options.
850854
pub async fn run_command_with_options(
851-
cwd: AbsolutePathBuf,
855+
mut cwd: AbsolutePathBuf,
852856
args: Args,
853857
render_options: RenderOptions,
854858
) -> Result<ExitStatus, Error> {
859+
// Apply the global `-C <dir>` flag before anything reads cwd, so local CLI
860+
// resolution and command execution behave as if vp was started in <dir>.
861+
if let Some(dir) = &args.chdir {
862+
cwd.push(dir);
863+
if !cwd.as_path().is_dir() {
864+
return Err(Error::UserMessage(format!("directory not found: {dir}").into()));
865+
}
866+
}
867+
855868
// Handle --version flag (Category B: delegates to JS)
856869
if args.version {
857870
return commands::version::execute(cwd).await;
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
//! Target elicitation for bare app commands at a workspace root.
2+
//!
3+
//! A bare `vp dev`/`build`/`preview`/`pack` at a workspace root has no target
4+
//! and would silently run against the root. Resolution order (rfcs/cwd-flag.md):
5+
//! explicit `-C` and positional targets are handled before this code and skip
6+
//! elicitation entirely; then `defaultPackage` from the root config, then the
7+
//! workspace package listing (interactive picker planned once `vite_select`
8+
//! supports a custom prompt), then exit 1.
9+
10+
use std::io::IsTerminal;
11+
12+
use vite_error::Error;
13+
use vite_path::AbsolutePathBuf;
14+
use vite_shared::output;
15+
use vite_task::ExitStatus;
16+
use vite_workspace::WorkspaceFile;
17+
18+
use super::types::SynthesizableSubcommand;
19+
20+
/// Where a bare app command should run.
21+
pub(super) enum AppTarget {
22+
/// No elicitation applies; run in the invocation directory as today.
23+
CurrentDir,
24+
/// Run as if invoked in this directory (implicit `-C`).
25+
Dir(AbsolutePathBuf),
26+
/// Elicitation printed its output and decided the exit code.
27+
Exit(ExitStatus),
28+
}
29+
30+
struct PackageRow {
31+
name: String,
32+
path: String,
33+
absolute: AbsolutePathBuf,
34+
runnable: bool,
35+
}
36+
37+
/// App commands are the single-target subcommands; everything else never
38+
/// goes through elicitation.
39+
fn app_command_parts(subcommand: &SynthesizableSubcommand) -> Option<(&'static str, &[String])> {
40+
match subcommand {
41+
SynthesizableSubcommand::Dev { args } => Some(("dev", args)),
42+
SynthesizableSubcommand::Build { args } => Some(("build", args)),
43+
SynthesizableSubcommand::Preview { args } => Some(("preview", args)),
44+
SynthesizableSubcommand::Pack { args } => Some(("pack", args)),
45+
_ => None,
46+
}
47+
}
48+
49+
/// Bare = no positional target. A non-flag token may be a flag value
50+
/// (`--port 3000`), so any non-flag argument conservatively disables
51+
/// elicitation and forwards the invocation unchanged.
52+
fn is_bare(args: &[String]) -> bool {
53+
args.iter().all(|arg| arg.starts_with('-'))
54+
}
55+
56+
/// Mirror of the global command picker's interactivity gate.
57+
fn is_interactive() -> bool {
58+
const CI_ENV_VARS: &[&str] =
59+
&["CI", "CONTINUOUS_INTEGRATION", "GITHUB_ACTIONS", "GITLAB_CI", "BUILDKITE", "JENKINS_URL"];
60+
std::io::stdin().is_terminal()
61+
&& std::io::stdout().is_terminal()
62+
&& std::env::var("TERM").map_or(true, |term| term != "dumb")
63+
&& !CI_ENV_VARS.iter().any(|key| std::env::var_os(key).is_some())
64+
}
65+
66+
/// Heuristic ranking signal: does `dir` look runnable for `command`?
67+
/// Used for ordering and single-candidate auto-selection, never for hiding.
68+
fn looks_runnable(dir: &AbsolutePathBuf, command: &str) -> bool {
69+
const VITE_CONFIGS: &[&str] =
70+
&["vite.config.ts", "vite.config.js", "vite.config.mts", "vite.config.mjs"];
71+
let has_vite_config = VITE_CONFIGS.iter().any(|name| dir.as_path().join(name).is_file());
72+
match command {
73+
"pack" => has_vite_config || dir.as_path().join("src/index.ts").is_file(),
74+
_ => has_vite_config || dir.as_path().join("index.html").is_file(),
75+
}
76+
}
77+
78+
/// `defaultPackage` from the root `vite.config.*`, read via static extraction
79+
/// so it works at roots without a vite-plus install (non-workspace framework
80+
/// repos). The value must be a static string literal.
81+
fn resolve_default_package(command: &str, cwd: &AbsolutePathBuf) -> Option<AppTarget> {
82+
let fields = vite_static_config::resolve_static_config(cwd);
83+
match fields.get("defaultPackage") {
84+
Some(vite_static_config::FieldValue::Json(serde_json::Value::String(dir))) => {
85+
let mut target = cwd.clone();
86+
target.push(dir.trim_start_matches("./"));
87+
if !target.as_path().is_dir() {
88+
output::error(&format!("defaultPackage points to a missing directory: {dir}"));
89+
return Some(AppTarget::Exit(ExitStatus(1)));
90+
}
91+
output::note(&format!("vp {command}: using {dir} (defaultPackage)"));
92+
Some(AppTarget::Dir(target))
93+
}
94+
Some(vite_static_config::FieldValue::Json(other)) => {
95+
output::error(&format!("defaultPackage must be a string of a directory, got: {other}"));
96+
Some(AppTarget::Exit(ExitStatus(1)))
97+
}
98+
Some(vite_static_config::FieldValue::NonStatic) => {
99+
output::error(
100+
"defaultPackage in vite.config.ts must be a static string literal so vp can read it without executing the config",
101+
);
102+
Some(AppTarget::Exit(ExitStatus(1)))
103+
}
104+
None => None,
105+
}
106+
}
107+
108+
pub(super) fn resolve_app_target(
109+
subcommand: &SynthesizableSubcommand,
110+
cwd: &AbsolutePathBuf,
111+
) -> Result<AppTarget, Error> {
112+
let Some((command, args)) = app_command_parts(subcommand) else {
113+
return Ok(AppTarget::CurrentDir);
114+
};
115+
if !is_bare(args) {
116+
return Ok(AppTarget::CurrentDir);
117+
}
118+
119+
let (workspace_root, rel_from_root) = vite_workspace::find_workspace_root(cwd)?;
120+
if !rel_from_root.as_str().is_empty() {
121+
return Ok(AppTarget::CurrentDir);
122+
}
123+
124+
if let Some(target) = resolve_default_package(command, cwd) {
125+
return Ok(target);
126+
}
127+
128+
// Only real workspaces have a package list to offer; a standalone package
129+
// root keeps today's behavior.
130+
if matches!(workspace_root.workspace_file, WorkspaceFile::NonWorkspacePackage(_)) {
131+
return Ok(AppTarget::CurrentDir);
132+
}
133+
134+
let graph = vite_workspace::load_package_graph(&workspace_root)
135+
.map_err(|e| Error::Anyhow(e.into()))?;
136+
let mut rows: Vec<PackageRow> = graph
137+
.node_weights()
138+
.filter(|info| !info.path.as_str().is_empty())
139+
.map(|info| PackageRow {
140+
name: info.package_json.name.to_string(),
141+
path: info.path.as_str().to_string(),
142+
absolute: info.absolute_path.to_absolute_path_buf(),
143+
runnable: false,
144+
})
145+
.collect();
146+
if rows.is_empty() {
147+
return Ok(AppTarget::CurrentDir);
148+
}
149+
for row in &mut rows {
150+
row.runnable = looks_runnable(&row.absolute, command);
151+
}
152+
rows.sort_by(|a, b| (!a.runnable, a.path.as_str()).cmp(&(!b.runnable, b.path.as_str())));
153+
154+
// With exactly one likely-runnable package, an interactive terminal
155+
// auto-selects it (the degenerate picker).
156+
if is_interactive() && rows.iter().filter(|row| row.runnable).count() == 1 {
157+
let row = rows.iter().find(|row| row.runnable).expect("one runnable row exists");
158+
println!("Selected package: {} ({})", row.name, row.path);
159+
println!("Tip: run this directly with `vp -C {} {command}`", row.path);
160+
return Ok(AppTarget::Dir(row.absolute.clone()));
161+
}
162+
163+
output::error(&format!("`vp {command}` at the workspace root needs a target package."));
164+
output::raw_stderr("");
165+
output::raw_stderr(" Packages in this workspace:");
166+
let name_width = rows.iter().map(|row| row.name.len()).max().unwrap_or(0);
167+
for row in &rows {
168+
output::raw_stderr(&format!(" {:<name_width$} {}", row.name, row.path));
169+
}
170+
output::raw_stderr("");
171+
let example = &rows.first().expect("rows is non-empty").path;
172+
output::raw_stderr(&format!(" Pass a directory: vp -C {example} {command}"));
173+
output::raw_stderr(&format!(" Or run every package's {command} script: vp run -r {command}"));
174+
Ok(AppTarget::Exit(ExitStatus(1)))
175+
}
176+
177+
#[cfg(test)]
178+
mod tests {
179+
use super::*;
180+
181+
#[test]
182+
fn bare_means_no_positional_target() {
183+
let to_args = |args: &[&str]| args.iter().map(|s| (*s).to_string()).collect::<Vec<_>>();
184+
assert!(is_bare(&to_args(&[])));
185+
assert!(is_bare(&to_args(&["--watch"])));
186+
assert!(is_bare(&to_args(&["-w", "--minify"])));
187+
// A positional target disables elicitation.
188+
assert!(!is_bare(&to_args(&["apps/web"])));
189+
// A flag value is indistinguishable from a positional without knowing
190+
// the tool's flag arity, so it conservatively counts as non-bare.
191+
assert!(!is_bare(&to_args(&["--port", "3000"])));
192+
}
193+
194+
#[test]
195+
fn only_app_commands_elicit() {
196+
let args = vec![];
197+
for (subcommand, expected) in [
198+
(SynthesizableSubcommand::Dev { args: args.clone() }, Some("dev")),
199+
(SynthesizableSubcommand::Build { args: args.clone() }, Some("build")),
200+
(SynthesizableSubcommand::Preview { args: args.clone() }, Some("preview")),
201+
(SynthesizableSubcommand::Pack { args: args.clone() }, Some("pack")),
202+
(SynthesizableSubcommand::Lint { args: args.clone() }, None),
203+
(SynthesizableSubcommand::Test { args: args.clone() }, None),
204+
] {
205+
assert_eq!(app_command_parts(&subcommand).map(|(name, _)| name), expected);
206+
}
207+
}
208+
}

packages/cli/binding/src/cli/mod.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
//! This module contains all the CLI-related code.
44
//! It handles argument parsing, command dispatching, and orchestration of the task execution.
55
6+
mod app_target;
67
mod execution;
78
mod handler;
89
mod help;
@@ -46,6 +47,16 @@ async fn execute_direct_subcommand(
4647
cwd: &AbsolutePathBuf,
4748
options: Option<CliOptions>,
4849
) -> Result<ExitStatus, Error> {
50+
// A bare app command at a workspace root resolves its target first
51+
// (defaultPackage, package listing); the command then runs as if invoked
52+
// in the resolved directory (rfcs/cwd-flag.md).
53+
let elicited_cwd = match app_target::resolve_app_target(&subcommand, cwd)? {
54+
app_target::AppTarget::CurrentDir => None,
55+
app_target::AppTarget::Dir(dir) => Some(dir),
56+
app_target::AppTarget::Exit(status) => return Ok(status),
57+
};
58+
let cwd = elicited_cwd.as_ref().unwrap_or(cwd);
59+
4960
let (workspace_root, _) = vite_workspace::find_workspace_root(cwd)?;
5061
let workspace_path: Arc<AbsolutePath> = workspace_root.path.into();
5162

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "command-app-root-listing",
3+
"version": "1.0.0",
4+
"workspaces": [
5+
"packages/*"
6+
],
7+
"type": "module"
8+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"name": "lib",
3+
"version": "1.0.0",
4+
"type": "module",
5+
"main": "src/index.ts"
6+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const lib = true;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<!doctype html>
2+
<html>
3+
<body>
4+
<h1>web app</h1>
5+
</body>
6+
</html>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "web",
3+
"version": "1.0.0",
4+
"type": "module"
5+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
> vp build || echo 'exited non-zero' # bare build at the workspace root lists packages instead of building the root
2+
error: `vp build` at the workspace root needs a target package.
3+
4+
Packages in this workspace:
5+
web packages/web
6+
lib packages/lib
7+
8+
Pass a directory: vp -C packages/web build
9+
Or run every package's build script: vp run -r build
10+
exited non-zero
11+
12+
> vp dev || echo 'exited non-zero' # bare dev at the root no longer starts a server against the root
13+
error: `vp dev` at the workspace root needs a target package.
14+
15+
Packages in this workspace:
16+
web packages/web
17+
lib packages/lib
18+
19+
Pass a directory: vp -C packages/web dev
20+
Or run every package's dev script: vp run -r dev
21+
exited non-zero
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"commands": [
3+
"vp build || echo 'exited non-zero' # bare build at the workspace root lists packages instead of building the root",
4+
"vp dev || echo 'exited non-zero' # bare dev at the root no longer starts a server against the root"
5+
]
6+
}

0 commit comments

Comments
 (0)