Skip to content

Commit d131ea9

Browse files
committed
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
1 parent 641bd2a commit d131ea9

9 files changed

Lines changed: 110 additions & 113 deletions

File tree

crates/vite_global_cli/src/cli.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -858,8 +858,9 @@ pub async fn run_command_with_options(
858858
) -> Result<ExitStatus, Error> {
859859
// Apply the global `-C <dir>` flag before anything reads cwd, so local CLI
860860
// resolution and command execution behave as if vp was started in <dir>.
861+
// `clean()` normalizes `.`/`..` so upward workspace walks never see them.
861862
if let Some(dir) = &args.chdir {
862-
cwd.push(dir);
863+
cwd = cwd.join(dir).clean();
863864
if !cwd.as_path().is_dir() {
864865
return Err(Error::UserMessage(format!("directory not found: {dir}").into()));
865866
}

crates/vite_global_cli/src/command_picker.rs

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
//! Interactive top-level command picker for `vp`.
22
33
use std::{
4-
io::{self, IsTerminal, Write},
4+
io::{self, Write},
55
ops::ControlFlow,
66
};
77

@@ -114,20 +114,6 @@ const COMMANDS: &[CommandEntry] = &[
114114
},
115115
];
116116

117-
const CI_ENV_VARS: &[&str] = &[
118-
"CI",
119-
"CONTINUOUS_INTEGRATION",
120-
"GITHUB_ACTIONS",
121-
"GITLAB_CI",
122-
"CIRCLECI",
123-
"TRAVIS",
124-
"JENKINS_URL",
125-
"BUILDKITE",
126-
"DRONE",
127-
"CODEBUILD_BUILD_ID",
128-
"TF_BUILD",
129-
];
130-
131117
pub fn pick_top_level_command_if_interactive(
132118
cwd: &AbsolutePath,
133119
) -> io::Result<TopLevelCommandPick> {
@@ -144,14 +130,7 @@ pub fn pick_top_level_command_if_interactive(
144130
}
145131

146132
fn should_enable_picker() -> bool {
147-
std::io::stdin().is_terminal()
148-
&& std::io::stdout().is_terminal()
149-
&& std::env::var("TERM").map_or(true, |term| term != "dumb")
150-
&& !is_ci_environment()
151-
}
152-
153-
fn is_ci_environment() -> bool {
154-
CI_ENV_VARS.iter().any(|key| std::env::var_os(key).is_some())
133+
vite_shared::is_interactive_terminal()
155134
}
156135

157136
fn run_picker(command_order: &[usize]) -> io::Result<Option<PickedCommand>> {

crates/vite_install/src/package_manager.rs

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1229,26 +1229,7 @@ async fn set_dev_engines_package_manager_field(
12291229
}
12301230

12311231
pub(crate) use vite_shared::format_path_prepended as format_path_env;
1232-
1233-
/// Common CI environment variables
1234-
const CI_ENV_VARS: &[&str] = &[
1235-
"CI",
1236-
"CONTINUOUS_INTEGRATION",
1237-
"GITHUB_ACTIONS",
1238-
"GITLAB_CI",
1239-
"CIRCLECI",
1240-
"TRAVIS",
1241-
"JENKINS_URL",
1242-
"BUILDKITE",
1243-
"DRONE",
1244-
"CODEBUILD_BUILD_ID", // AWS CodeBuild
1245-
"TF_BUILD", // Azure Pipelines
1246-
];
1247-
1248-
/// Check if running in a CI environment
1249-
fn is_ci_environment() -> bool {
1250-
CI_ENV_VARS.iter().any(|key| env::var(key).is_ok())
1251-
}
1232+
use vite_shared::is_ci_environment;
12521233

12531234
/// Interactive menu for selecting a package manager with keyboard navigation
12541235
fn interactive_package_manager_menu() -> Result<PackageManagerType, Error> {
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
//! Shared CI-environment and interactive-terminal detection.
2+
3+
use std::io::IsTerminal;
4+
5+
/// Common CI environment variables.
6+
const CI_ENV_VARS: &[&str] = &[
7+
"CI",
8+
"CONTINUOUS_INTEGRATION",
9+
"GITHUB_ACTIONS",
10+
"GITLAB_CI",
11+
"CIRCLECI",
12+
"TRAVIS",
13+
"JENKINS_URL",
14+
"BUILDKITE",
15+
"DRONE",
16+
"CODEBUILD_BUILD_ID", // AWS CodeBuild
17+
"TF_BUILD", // Azure Pipelines
18+
];
19+
20+
/// Check if running in a CI environment.
21+
pub fn is_ci_environment() -> bool {
22+
CI_ENV_VARS.iter().any(|key| std::env::var_os(key).is_some())
23+
}
24+
25+
/// True when vp can show interactive prompts: stdin and stdout are terminals,
26+
/// the terminal is capable, and this is not a CI environment.
27+
pub fn is_interactive_terminal() -> bool {
28+
std::io::stdin().is_terminal()
29+
&& std::io::stdout().is_terminal()
30+
&& std::env::var("TERM").map_or(true, |term| term != "dumb")
31+
&& !is_ci_environment()
32+
}

crates/vite_shared/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ mod env_config;
1111
pub mod env_vars;
1212
mod error;
1313
pub mod header;
14+
mod interactivity;
1415
mod home;
1516
mod http;
1617
mod json_edit;
@@ -24,6 +25,7 @@ mod tracing;
2425
pub use env_config::{EnvConfig, TestEnvGuard};
2526
pub use error::format_error_chain;
2627
pub use home::get_vp_home;
28+
pub use interactivity::{is_ci_environment, is_interactive_terminal};
2729
pub use http::shared_http_client;
2830
pub use json_edit::{JsonStyle, edit_json_object, insert_after};
2931
pub use package_json::{

crates/vite_static_config/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ const CONFIG_FILE_NAMES: &[&str] = &[
8787
"vite.config.cts",
8888
];
8989

90+
/// Returns true when `dir` directly contains a Vite config file.
91+
pub fn has_config_file(dir: &AbsolutePath) -> bool {
92+
resolve_config_path(dir).is_some()
93+
}
94+
9095
/// Resolve the vite config file path in the given directory.
9196
///
9297
/// Tries each config file name in priority order and returns the first one that exists.

packages/cli/binding/src/cli/app_target.rs

Lines changed: 55 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,9 @@
33
//! A bare `vp dev`/`build`/`preview`/`pack` at a workspace root has no target
44
//! and would silently run against the root. Resolution order (rfcs/cwd-flag.md):
55
//! 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;
6+
//! elicitation entirely; then `defaultPackage` from the config in the
7+
//! invocation directory, then the workspace package listing (interactive
8+
//! picker planned once `vite_select` supports a custom prompt), then exit 1.
119
1210
use vite_error::Error;
1311
use vite_path::AbsolutePathBuf;
@@ -46,61 +44,49 @@ fn app_command_parts(subcommand: &SynthesizableSubcommand) -> Option<(&'static s
4644
}
4745
}
4846

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.
47+
/// Bare = no positional target and no help-like flag. A non-flag token may be
48+
/// a flag value (`--port 3000`), so any non-flag argument conservatively
49+
/// disables elicitation; help/version requests are answered by the underlying
50+
/// tool and must never be redirected.
5251
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())
52+
args.iter().all(|arg| {
53+
arg.starts_with('-') && !matches!(arg.as_str(), "-h" | "--help" | "-V" | "--version")
54+
})
6455
}
6556

6657
/// Heuristic ranking signal: does `dir` look runnable for `command`?
6758
/// Used for ordering and single-candidate auto-selection, never for hiding.
6859
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());
60+
let has_vite_config = vite_static_config::has_config_file(dir);
7261
match command {
7362
"pack" => has_vite_config || dir.as_path().join("src/index.ts").is_file(),
7463
_ => has_vite_config || dir.as_path().join("index.html").is_file(),
7564
}
7665
}
7766

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.
67+
/// `defaultPackage` from the `vite.config.*` in `cwd`, read via static
68+
/// extraction so it works at roots without a vite-plus install (non-workspace
69+
/// framework repos). The value must be a static string literal.
8170
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") {
71+
let fail = |msg: &str| {
72+
output::error(msg);
73+
Some(AppTarget::Exit(ExitStatus(1)))
74+
};
75+
match vite_static_config::resolve_static_config(cwd).get("defaultPackage") {
8476
Some(vite_static_config::FieldValue::Json(serde_json::Value::String(dir))) => {
85-
let mut target = cwd.clone();
86-
target.push(dir.trim_start_matches("./"));
77+
let target = cwd.join(&dir).clean();
8778
if !target.as_path().is_dir() {
88-
output::error(&format!("defaultPackage points to a missing directory: {dir}"));
89-
return Some(AppTarget::Exit(ExitStatus(1)));
79+
return fail(&format!("defaultPackage points to a missing directory: {dir}"));
9080
}
9181
output::note(&format!("vp {command}: using {dir} (defaultPackage)"));
9282
Some(AppTarget::Dir(target))
9383
}
9484
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)))
85+
fail(&format!("defaultPackage must be a string of a directory, got: {other}"))
10386
}
87+
Some(vite_static_config::FieldValue::NonStatic) => fail(
88+
"defaultPackage in vite.config.ts must be a static string literal so vp can read it without executing the config",
89+
),
10490
None => None,
10591
}
10692
}
@@ -116,18 +102,21 @@ pub(super) fn resolve_app_target(
116102
return Ok(AppTarget::CurrentDir);
117103
}
118104

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-
105+
// `defaultPackage` comes before any workspace lookup: the non-workspace
106+
// framework shape (a Laravel-style root with a vite.config.ts pointer and
107+
// no package.json up-tree) has no workspace metadata at all.
124108
if let Some(target) = resolve_default_package(command, cwd) {
125109
return Ok(target);
126110
}
127111

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(_)) {
112+
// The package listing needs workspace metadata; anything unresolvable
113+
// keeps today's behavior (the caller surfaces its own workspace errors).
114+
let Ok((workspace_root, rel_from_root)) = vite_workspace::find_workspace_root(cwd) else {
115+
return Ok(AppTarget::CurrentDir);
116+
};
117+
if !rel_from_root.as_str().is_empty()
118+
|| matches!(workspace_root.workspace_file, WorkspaceFile::NonWorkspacePackage(_))
119+
{
131120
return Ok(AppTarget::CurrentDir);
132121
}
133122

@@ -136,25 +125,26 @@ pub(super) fn resolve_app_target(
136125
let mut rows: Vec<PackageRow> = graph
137126
.node_weights()
138127
.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,
128+
.map(|info| {
129+
let absolute = info.absolute_path.to_absolute_path_buf();
130+
PackageRow {
131+
name: info.package_json.name.to_string(),
132+
path: info.path.as_str().to_string(),
133+
runnable: looks_runnable(&absolute, command),
134+
absolute,
135+
}
144136
})
145137
.collect();
146138
if rows.is_empty() {
147139
return Ok(AppTarget::CurrentDir);
148140
}
149-
for row in &mut rows {
150-
row.runnable = looks_runnable(&row.absolute, command);
151-
}
152141
rows.sort_by(|a, b| (!a.runnable, a.path.as_str()).cmp(&(!b.runnable, b.path.as_str())));
153142

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");
143+
// With exactly one likely-runnable package (rows are sorted runnable
144+
// first), an interactive terminal auto-selects it (the degenerate picker).
145+
let single_runnable = rows[0].runnable && rows.get(1).is_none_or(|row| !row.runnable);
146+
if single_runnable && vite_shared::is_interactive_terminal() {
147+
let row = &rows[0];
158148
println!("Selected package: {} ({})", row.name, row.path);
159149
println!("Tip: run this directly with `vp -C {} {command}`", row.path);
160150
return Ok(AppTarget::Dir(row.absolute.clone()));
@@ -168,7 +158,7 @@ pub(super) fn resolve_app_target(
168158
output::raw_stderr(&format!(" {:<name_width$} {}", row.name, row.path));
169159
}
170160
output::raw_stderr("");
171-
let example = &rows.first().expect("rows is non-empty").path;
161+
let example = &rows[0].path;
172162
output::raw_stderr(&format!(" Pass a directory: vp -C {example} {command}"));
173163
output::raw_stderr(&format!(" Or run every package's {command} script: vp run -r {command}"));
174164
Ok(AppTarget::Exit(ExitStatus(1)))
@@ -179,7 +169,7 @@ mod tests {
179169
use super::*;
180170

181171
#[test]
182-
fn bare_means_no_positional_target() {
172+
fn bare_means_no_positional_target_and_no_help() {
183173
let to_args = |args: &[&str]| args.iter().map(|s| (*s).to_string()).collect::<Vec<_>>();
184174
assert!(is_bare(&to_args(&[])));
185175
assert!(is_bare(&to_args(&["--watch"])));
@@ -189,6 +179,10 @@ mod tests {
189179
// A flag value is indistinguishable from a positional without knowing
190180
// the tool's flag arity, so it conservatively counts as non-bare.
191181
assert!(!is_bare(&to_args(&["--port", "3000"])));
182+
// Help/version requests go to the underlying tool, never elicitation.
183+
assert!(!is_bare(&to_args(&["--help"])));
184+
assert!(!is_bare(&to_args(&["-h"])));
185+
assert!(!is_bare(&to_args(&["--watch", "--version"])));
192186
}
193187

194188
#[test]

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,14 @@ async fn execute_direct_subcommand(
5050
// A bare app command at a workspace root resolves its target first
5151
// (defaultPackage, package listing); the command then runs as if invoked
5252
// 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),
53+
let target = app_target::resolve_app_target(&subcommand, cwd)?;
54+
if let app_target::AppTarget::Exit(status) = target {
55+
return Ok(status);
56+
}
57+
let cwd = match &target {
58+
app_target::AppTarget::Dir(dir) => dir,
59+
_ => cwd,
5760
};
58-
let cwd = elicited_cwd.as_ref().unwrap_or(cwd);
5961

6062
let (workspace_root, _) = vite_workspace::find_workspace_root(cwd)?;
6163
let workspace_path: Arc<AbsolutePath> = workspace_root.path.into();

packages/cli/src/bin.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,10 @@ let args = process.argv.slice(2);
4242
// Global `-C <dir>` flag: run as if vp was started in <dir>. The global Rust
4343
// CLI parses this itself and spawns bin.js with the target cwd already set;
4444
// this branch covers direct local-bin invocations (`pnpm exec vp -C <dir> ...`).
45-
if (args[0] === '-C' || (args[0]?.startsWith('-C') && args[0].length > 2)) {
46-
const inline = args[0] !== '-C';
47-
const dir = inline ? args[0].slice(2) : args[1];
45+
// Accepts `-C dir`, `-Cdir`, and `-C=dir`, matching the clap grammar.
46+
if (args[0]?.startsWith('-C')) {
47+
const inline = args[0].length > 2;
48+
const dir = inline ? args[0].slice(args[0][2] === '=' ? 3 : 2) : args[1];
4849
if (!dir) {
4950
errorMsg('-C requires a directory argument');
5051
process.exit(1);

0 commit comments

Comments
 (0)