Skip to content

Commit bf8a5b6

Browse files
committed
refactor(cli): apply cleanup review across the -C and elicitation code
- 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
1 parent d3cf60a commit bf8a5b6

9 files changed

Lines changed: 145 additions & 157 deletions

File tree

crates/vite_global_cli/src/cli.rs

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -856,14 +856,13 @@ pub async fn run_command_with_options(
856856
args: Args,
857857
render_options: RenderOptions,
858858
) -> 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-
// `clean()` normalizes `.`/`..` so upward workspace walks never see them.
859+
// Apply the global `-C <dir>` flag before anything reads cwd. main
860+
// normally consumes a leading `-C` pre-parse; this covers orderings that
861+
// reach clap (e.g. a second `-C`), with identical semantics — including
862+
// the process-cwd change and PWD sync the shared helper performs.
862863
if let Some(dir) = &args.chdir {
863-
cwd = cwd.join(dir).clean();
864-
if !cwd.as_path().is_dir() {
865-
return Err(Error::UserMessage(format!("directory not found: {dir}").into()));
866-
}
864+
cwd =
865+
crate::apply_chdir(&cwd, dir).map_err(|message| Error::UserMessage(message.into()))?;
867866
}
868867

869868
// Handle --version flag (Category B: delegates to JS)

crates/vite_global_cli/src/commands/vpr.rs

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,33 +11,26 @@ use vite_shared::output;
1111
/// Called from shim dispatch when `argv[0]` is `vpr`.
1212
pub async fn execute_vpr(args: &[String], cwd: &AbsolutePath) -> i32 {
1313
// `vpr -C <dir> <task>` mirrors `vp -C <dir> run <task>`: consume the
14-
// global flag before treating the rest as run arguments.
14+
// global flag before treating the rest as run arguments. There is no
15+
// clap parse on this path, so a missing value is reported here.
1516
let mut args = args;
1617
let mut cwd_buf = cwd.to_absolute_path_buf();
17-
// A bare `-C` with no value must error like the vp binary would, not
18-
// fall through as a run argument (there is no clap parse on this path).
19-
if args.first().is_some_and(|arg| matches!(arg.as_str(), "-C" | "-C="))
20-
&& crate::parse_leading_chdir(args).is_none()
21-
{
22-
output::raw_stderr("-C requires a directory argument");
23-
return 1;
24-
}
25-
if let Some((dir, consumed)) = crate::parse_leading_chdir(args) {
26-
cwd_buf = cwd_buf.join(&dir).clean();
27-
if !cwd_buf.as_path().is_dir() {
28-
output::raw_stderr(&format!("directory not found: {dir}"));
29-
return 1;
18+
match crate::parse_leading_chdir(args) {
19+
Some((dir, consumed)) => {
20+
cwd_buf = match crate::apply_chdir(cwd, &dir) {
21+
Ok(target) => target,
22+
Err(message) => {
23+
output::raw_stderr(&message);
24+
return 1;
25+
}
26+
};
27+
args = &args[consumed..];
3028
}
31-
if std::env::set_current_dir(cwd_buf.as_path()).is_err() {
32-
output::error(&format!("Failed to change directory to {dir}"));
29+
None if args.first().is_some_and(|arg| arg.starts_with("-C")) => {
30+
output::raw_stderr("-C requires a directory argument");
3331
return 1;
3432
}
35-
#[cfg(unix)]
36-
// SAFETY: single-threaded startup, before any command logic runs.
37-
unsafe {
38-
std::env::set_var("PWD", cwd_buf.as_path());
39-
}
40-
args = &args[consumed..];
33+
None => {}
4134
}
4235

4336
if crate::help::maybe_print_unified_delegate_help("run", args, true) {

crates/vite_global_cli/src/main.rs

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,30 @@ pub(crate) fn parse_leading_chdir(user_args: &[String]) -> Option<(String, usize
6262
/// - `vp rebuild ...` → `vp pm rebuild ...`
6363
/// - `vp help [command] [args...]` → `vp [command] [args...] --help`
6464
/// - `vp node [args...]` → `vp env exec node [args...]`
65+
/// Apply `-C <dir>`: resolve against `cwd`, validate, change the process
66+
/// cwd, and keep the POSIX `PWD` in sync like a real `cd`. Returns the new
67+
/// cwd, or the user-facing error message (not printed here: the completion
68+
/// peek must stay silent, and clap-path callers wrap it in their error type).
69+
pub(crate) fn apply_chdir(
70+
cwd: &vite_path::AbsolutePath,
71+
dir: &str,
72+
) -> Result<vite_path::AbsolutePathBuf, String> {
73+
let target = cwd.join(dir).clean();
74+
if !target.as_path().is_dir() {
75+
return Err(format!("directory not found: {dir}"));
76+
}
77+
if let Err(e) = std::env::set_current_dir(target.as_path()) {
78+
return Err(format!("Failed to change directory to {dir}: {e}"));
79+
}
80+
// Node tools commonly read process.env.PWD.
81+
#[cfg(unix)]
82+
// SAFETY: single-threaded startup, before any command logic runs.
83+
unsafe {
84+
std::env::set_var("PWD", target.as_path());
85+
}
86+
Ok(target)
87+
}
88+
6589
fn normalize_args(args: Vec<String>) -> Vec<String> {
6690
let mut normalized = args;
6791
loop {
@@ -333,10 +357,8 @@ async fn main() -> ExitCode {
333357
&& let Some((dir, _)) = parse_leading_chdir(&args[1..])
334358
&& let Ok(current) = vite_path::current_dir()
335359
{
336-
let target = current.join(&dir).clean();
337-
if target.as_path().is_dir() {
338-
let _ = std::env::set_current_dir(target.as_path());
339-
}
360+
// Best-effort and silent: mid-typing values are often not (yet) dirs.
361+
let _ = apply_chdir(&current, &dir);
340362
}
341363
CompleteEnv::with_factory(command_with_help).var("VP_COMPLETE").complete();
342364

@@ -367,22 +389,13 @@ async fn main() -> ExitCode {
367389
// cwd (before any command logic runs) keeps `vite_path::current_dir()`
368390
// callers deep inside command implementations equivalent to the cd form.
369391
if let Some((dir, consumed)) = parse_leading_chdir(&args[1..]) {
370-
cwd = cwd.join(&dir).clean();
371-
if !cwd.as_path().is_dir() {
372-
output::raw_stderr(&format!("directory not found: {dir}"));
373-
return ExitCode::FAILURE;
374-
}
375-
if let Err(e) = std::env::set_current_dir(cwd.as_path()) {
376-
output::error(&format!("Failed to change directory to {dir}: {e}"));
377-
return ExitCode::FAILURE;
378-
}
379-
// Keep the POSIX PWD in sync, like a real `cd`: Node tools commonly
380-
// read process.env.PWD.
381-
#[cfg(unix)]
382-
// SAFETY: single-threaded startup, before any command logic runs.
383-
unsafe {
384-
std::env::set_var("PWD", cwd.as_path());
385-
}
392+
cwd = match apply_chdir(&cwd, &dir) {
393+
Ok(target) => target,
394+
Err(message) => {
395+
output::raw_stderr(&message);
396+
return ExitCode::FAILURE;
397+
}
398+
};
386399
args.drain(1..=consumed);
387400
}
388401

crates/vite_static_config/src/lib.rs

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,8 @@ impl FieldMap {
7979
/// unanalyzable config would break unrelated commands).
8080
#[must_use]
8181
pub fn get_declared(&self, key: &str) -> Option<FieldValue> {
82-
match &self.0 {
83-
FieldMapInner::Closed(map) => map.get(key).cloned(),
84-
FieldMapInner::Open(map) => map.get(key).cloned(),
85-
}
82+
let (FieldMapInner::Closed(map) | FieldMapInner::Open(map)) = &self.0;
83+
map.get(key).cloned()
8684
}
8785
}
8886

@@ -347,21 +345,12 @@ fn extract_object_fields(obj: &oxc_ast::ast::ObjectExpression<'_>) -> FieldMap {
347345
continue;
348346
};
349347

350-
match &mut inner {
351-
FieldMapInner::Closed(map) => {
352-
let value =
353-
expr_to_json(&prop.value).map_or(FieldValue::NonStatic, FieldValue::Json);
354-
map.insert(Box::from(key.as_ref()), value);
355-
}
356-
FieldMapInner::Open(map) => {
357-
// Record explicit declarations, including NonStatic ones:
358-
// `get_declared` must distinguish a written `key: expr` from a
359-
// key that merely might exist behind the spread.
360-
let value =
361-
expr_to_json(&prop.value).map_or(FieldValue::NonStatic, FieldValue::Json);
362-
map.insert(Box::from(key.as_ref()), value);
363-
}
364-
}
348+
// Both variants record explicit declarations, including NonStatic
349+
// ones: `get_declared` must distinguish a written `key: expr` from a
350+
// key that merely might exist behind a spread.
351+
let (FieldMapInner::Closed(map) | FieldMapInner::Open(map)) = &mut inner;
352+
let value = expr_to_json(&prop.value).map_or(FieldValue::NonStatic, FieldValue::Json);
353+
map.insert(Box::from(key.as_ref()), value);
365354
}
366355

367356
FieldMap(inner)

packages/cli/bin/vpr

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,4 @@ import module from 'node:module';
44
if (module.enableCompileCache) {
55
module.enableCompileCache();
66
}
7-
8-
// Insert `run` after a leading global `-C <dir>` so `vpr -C <dir> <task>`
9-
// behaves like `vp -C <dir> run <task>` (bin.ts consumes -C before dispatch).
10-
// A bare `vpr -C` (missing value) inserts nothing: bin.ts then reports the
11-
// missing-argument error instead of misreading `run` as the directory.
12-
const first = process.argv[2];
13-
if (first === '-C') {
14-
if (process.argv[3] !== undefined) {
15-
process.argv.splice(4, 0, 'run');
16-
}
17-
} else if (first?.startsWith('-C') && first.length > 2) {
18-
process.argv.splice(3, 0, 'run');
19-
} else {
20-
process.argv.splice(2, 0, 'run');
21-
}
22-
await import('../dist/bin.js');
7+
import '../dist/bin.js';

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

Lines changed: 66 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -134,47 +134,49 @@ fn is_bare(command: &str, args: &[String]) -> bool {
134134
/// silent root build this feature exists to prevent.
135135
fn looks_runnable(dir: &AbsolutePathBuf, command: &str, is_root: bool) -> bool {
136136
match command {
137-
// Bare `vp pack` succeeds when the config explicitly declares a
138-
// `pack` block or tsdown's default entry exists. A spread that only
137+
// Bare `vp pack` succeeds when tsdown's default entry exists or the
138+
// config explicitly declares a `pack` block (a spread that only
139139
// might contain `pack` does not count: auto-select acts on this
140-
// signal, so a false positive runs tsdown in a non-packable package.
140+
// signal, so a false positive runs tsdown in a non-packable
141+
// package). The one-stat entry check runs first: this executes per
142+
// workspace package, and the config check reads and parses a file.
141143
"pack" => {
142-
vite_static_config::resolve_static_config(dir).get_declared("pack").is_some()
143-
|| dir.as_path().join("src/index.ts").is_file()
144+
dir.as_path().join("src/index.ts").is_file()
145+
|| vite_static_config::resolve_static_config(dir).get_declared("pack").is_some()
144146
}
145147
_ if is_root => dir.as_path().join("index.html").is_file(),
146148
_ => vite_static_config::has_config_file(dir) || dir.as_path().join("index.html").is_file(),
147149
}
148150
}
149151

150-
/// `defaultPackage` from the `vite.config.*` in `cwd`, read via static
151-
/// extraction so it works at roots without a vite-plus install (non-workspace
152-
/// framework repos). The value must be a static string literal.
153-
///
154-
/// `get_declared` keeps this to explicitly written fields: a config that is
155-
/// unanalyzable or hides fields behind a spread simply falls through to the
156-
/// picker/current-dir resolution instead of failing every bare app command.
157-
fn resolve_default_package(command: &str, cwd: &AbsolutePathBuf) -> Option<AppTarget> {
152+
/// Resolve the `defaultPackage` value [`classify`] extracted from the
153+
/// invocation root's `vite.config.*` (static extraction, so it works at
154+
/// roots without a vite-plus install). The value must be a static string
155+
/// literal naming an existing directory.
156+
fn resolve_default_package(
157+
command: &str,
158+
cwd: &AbsolutePathBuf,
159+
value: vite_static_config::FieldValue,
160+
) -> AppTarget {
158161
let fail = |msg: &str| {
159162
output::error(msg);
160-
Some(AppTarget::Exit(ExitStatus(1)))
163+
AppTarget::Exit(ExitStatus(1))
161164
};
162-
match vite_static_config::resolve_static_config(cwd).get_declared("defaultPackage") {
163-
Some(vite_static_config::FieldValue::Json(serde_json::Value::String(dir))) => {
165+
match value {
166+
vite_static_config::FieldValue::Json(serde_json::Value::String(dir)) => {
164167
let target = cwd.join(&dir).clean();
165168
if !target.as_path().is_dir() {
166169
return fail(&format!("defaultPackage points to a missing directory: {dir}"));
167170
}
168171
output::note(&format!("vp {command}: using {dir} (defaultPackage)"));
169-
Some(AppTarget::Dir(target))
172+
AppTarget::Dir(target)
170173
}
171-
Some(vite_static_config::FieldValue::Json(other)) => {
174+
vite_static_config::FieldValue::Json(other) => {
172175
fail(&format!("defaultPackage must be a string of a directory, got: {other}"))
173176
}
174-
Some(vite_static_config::FieldValue::NonStatic) => fail(
177+
vite_static_config::FieldValue::NonStatic => fail(
175178
"defaultPackage in vite.config.ts must be a static string literal so vp can read it without executing the config",
176179
),
177-
None => None,
178180
}
179181
}
180182

@@ -239,67 +241,68 @@ pub(super) fn needs_elicitation(
239241
subcommand: &SynthesizableSubcommand,
240242
cwd: &AbsolutePathBuf,
241243
) -> bool {
242-
let Some((command, args)) = app_command_parts(subcommand) else {
243-
return false;
244-
};
245-
if !is_bare(command, args) {
246-
return false;
247-
}
248-
let workspace = vite_workspace::find_workspace_root(cwd);
249-
if at_invocation_root(workspace.as_ref().ok().map(|(_, rel)| rel.as_str()))
250-
&& vite_static_config::resolve_static_config(cwd).get_declared("defaultPackage").is_some()
251-
{
252-
return true;
253-
}
254-
let Ok((workspace_root, rel_from_root)) = workspace else {
255-
return false;
256-
};
257-
rel_from_root.as_str().is_empty()
258-
&& !matches!(workspace_root.workspace_file, WorkspaceFile::NonWorkspacePackage(_))
244+
classify(subcommand, cwd).is_some()
259245
}
260246

261-
/// `defaultPackage` is a root-pointer concept: it applies where the
262-
/// invocation directory is its own root (a workspace root, a standalone
263-
/// package, or a framework directory with no package.json ancestry — pass
264-
/// the workspace lookup's `rel_from_root`, or `None` when the lookup
265-
/// failed). Below a workspace root the current directory already identifies
266-
/// the target package, so a member's own config must not redirect.
267-
fn at_invocation_root(rel_from_root: Option<&str>) -> bool {
268-
rel_from_root.is_none_or(str::is_empty)
247+
/// Why a bare app command needs target elicitation.
248+
enum Elicitation {
249+
/// The invocation root's config explicitly declares `defaultPackage`
250+
/// (with this value — possibly invalid, which the resolver reports).
251+
DefaultPackage(vite_static_config::FieldValue),
252+
/// Bare app command at a real workspace root: picker/listing territory.
253+
WorkspaceRoot(vite_workspace::WorkspaceRoot),
269254
}
270255

271-
pub(super) fn resolve_app_target(
256+
/// The RFC's resolution order, written once for both entry points: bare app
257+
/// command, then `defaultPackage` at the invocation root, then the workspace
258+
/// root itself. `defaultPackage` is a root-pointer concept: it applies where
259+
/// the invocation directory is its own root (a workspace root, a standalone
260+
/// package, or a framework directory with no package.json ancestry); below a
261+
/// workspace root the current directory already identifies the target, so a
262+
/// member's own config must not redirect.
263+
fn classify(
272264
subcommand: &SynthesizableSubcommand,
273265
cwd: &AbsolutePathBuf,
274-
) -> Result<AppTarget, Error> {
275-
let Some((command, args)) = app_command_parts(subcommand) else {
276-
return Ok(AppTarget::CurrentDir);
277-
};
266+
) -> Option<(&'static str, Elicitation)> {
267+
let (command, args) = app_command_parts(subcommand)?;
278268
if !is_bare(command, args) {
279-
return Ok(AppTarget::CurrentDir);
269+
return None;
280270
}
281-
282-
// `defaultPackage` is consulted before the workspace-shape dispatch (the
283-
// non-workspace framework shape has no workspace metadata at all), but
284-
// only at the invocation root: a member package's config must not
285-
// redirect a command already running in that member.
286271
let workspace = vite_workspace::find_workspace_root(cwd);
287-
if at_invocation_root(workspace.as_ref().ok().map(|(_, rel)| rel.as_str()))
288-
&& let Some(target) = resolve_default_package(command, cwd)
272+
let at_invocation_root =
273+
workspace.as_ref().map_or(true, |(_, rel_from_root)| rel_from_root.as_str().is_empty());
274+
if at_invocation_root
275+
&& let Some(value) =
276+
vite_static_config::resolve_static_config(cwd).get_declared("defaultPackage")
289277
{
290-
return Ok(target);
278+
return Some((command, Elicitation::DefaultPackage(value)));
291279
}
292-
293-
// The package listing needs workspace metadata; anything unresolvable
280+
// The picker/listing needs workspace metadata; anything unresolvable
294281
// keeps today's behavior (the caller surfaces its own workspace errors).
295282
let Ok((workspace_root, rel_from_root)) = workspace else {
296-
return Ok(AppTarget::CurrentDir);
283+
return None;
297284
};
298285
if !rel_from_root.as_str().is_empty()
299286
|| matches!(workspace_root.workspace_file, WorkspaceFile::NonWorkspacePackage(_))
300287
{
301-
return Ok(AppTarget::CurrentDir);
288+
return None;
302289
}
290+
Some((command, Elicitation::WorkspaceRoot(workspace_root)))
291+
}
292+
293+
pub(super) fn resolve_app_target(
294+
subcommand: &SynthesizableSubcommand,
295+
cwd: &AbsolutePathBuf,
296+
) -> Result<AppTarget, Error> {
297+
let Some((command, elicitation)) = classify(subcommand, cwd) else {
298+
return Ok(AppTarget::CurrentDir);
299+
};
300+
let workspace_root = match elicitation {
301+
Elicitation::DefaultPackage(value) => {
302+
return Ok(resolve_default_package(command, cwd, value));
303+
}
304+
Elicitation::WorkspaceRoot(workspace_root) => workspace_root,
305+
};
303306

304307
let graph =
305308
vite_workspace::load_package_graph(&workspace_root).map_err(|e| Error::Anyhow(e.into()))?;

0 commit comments

Comments
 (0)