Skip to content

Commit e1209f2

Browse files
committed
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
1 parent e99e18c commit e1209f2

10 files changed

Lines changed: 149 additions & 4 deletions

File tree

crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,13 @@ name = "default_package_missing"
1616
vp = ["local", "global"]
1717
comment = "defaultPackage pointing at a missing directory errors before any workspace lookup."
1818
steps = [{ argv = ["vp", "build"], cwd = "missing" }]
19+
20+
[[case]]
21+
name = "unanalyzable_config_ignored"
22+
vp = ["local", "global"]
23+
comment = """
24+
Regression guard for spread/unanalyzable configs: a config that only parses
25+
as an open map might hide defaultPackage behind the spread, but that must
26+
not fail the command. Bare vp build falls through and runs in place.
27+
"""
28+
steps = [{ argv = ["vp", "build"], cwd = "spread", tty = false }]

crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/unanalyzable_config_ignored.global.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# unanalyzable_config_ignored
2+
3+
Regression guard for spread/unanalyzable configs: a config that only parses
4+
as an open map might hide defaultPackage behind the spread, but that must
5+
not fail the command. Bare vp build falls through and runs in place.
6+
7+
## `cd spread && vp build`
8+
9+
```
10+
vite <version> building client environment for production...
11+
transforming...✓ 2 modules transformed.
12+
rendering chunks...
13+
computing gzip size...
14+
dist/index.html <size> kB │ gzip: <size> kB
15+
16+
✓ built in <duration>
17+
```

crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/app_root_default_package/snapshots/unanalyzable_config_ignored.local.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# unanalyzable_config_ignored
2+
3+
Regression guard for spread/unanalyzable configs: a config that only parses
4+
as an open map might hide defaultPackage behind the spread, but that must
5+
not fail the command. Bare vp build falls through and runs in place.
6+
7+
## `cd spread && vp build`
8+
9+
```
10+
vite <version> building client environment for production...
11+
transforming...✓ 2 modules transformed.
12+
rendering chunks...
13+
computing gzip size...
14+
dist/index.html <size> kB │ gzip: <size> kB
15+
16+
✓ built in <duration>
17+
```
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>spread</h1>
5+
</body>
6+
</html>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{ "name": "spread-config", "private": true }
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const base = {};
2+
3+
export default {
4+
...base,
5+
};

crates/vite_global_cli/src/main.rs

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,23 @@ use crate::cli::{
3939
try_parse_args_from_with_options,
4040
};
4141

42+
/// Parse a leading `-C <dir>` / `-C=<dir>` / `-C<dir>` as the first user
43+
/// argument, mirroring clap's short-option grammar (and bin.ts on the local
44+
/// path). Returns the directory and how many argv tokens it consumed.
45+
fn parse_leading_chdir(args: &[String]) -> Option<(String, usize)> {
46+
let first = args.get(1)?;
47+
if first == "-C" {
48+
return args.get(2).map(|dir| (dir.clone(), 2));
49+
}
50+
if let Some(rest) = first.strip_prefix("-C") {
51+
let dir = rest.strip_prefix('=').unwrap_or(rest);
52+
if !dir.is_empty() {
53+
return Some((dir.to_string(), 1));
54+
}
55+
}
56+
None
57+
}
58+
4259
/// Normalize CLI arguments:
4360
/// - `vp list ...` / `vp ls ...` → `vp pm list ...`
4461
/// - `vp rebuild ...` → `vp pm rebuild ...`
@@ -323,14 +340,32 @@ async fn main() -> ExitCode {
323340
}
324341

325342
// Normal CLI mode - get current working directory
326-
let cwd = match vite_path::current_dir() {
343+
let mut cwd = match vite_path::current_dir() {
327344
Ok(path) => path,
328345
Err(e) => {
329346
output::error(&format!("Failed to get current directory: {e}"));
330347
return ExitCode::FAILURE;
331348
}
332349
};
333350

351+
// Consume a leading `-C <dir>` here, before anything inspects args or cwd,
352+
// so alias normalization, the command picker, and in-process helpers all
353+
// behave exactly as if vp had been started in <dir>. Setting the process
354+
// cwd (before any command logic runs) keeps `vite_path::current_dir()`
355+
// callers deep inside command implementations equivalent to the cd form.
356+
if let Some((dir, consumed)) = parse_leading_chdir(&args) {
357+
cwd = cwd.join(&dir).clean();
358+
if !cwd.as_path().is_dir() {
359+
output::raw_stderr(&format!("directory not found: {dir}"));
360+
return ExitCode::FAILURE;
361+
}
362+
if let Err(e) = std::env::set_current_dir(cwd.as_path()) {
363+
output::error(&format!("Failed to change directory to {dir}: {e}"));
364+
return ExitCode::FAILURE;
365+
}
366+
args.drain(1..=consumed);
367+
}
368+
334369
if args.len() == 1 {
335370
match command_picker::pick_top_level_command_if_interactive(&cwd) {
336371
Ok(command_picker::TopLevelCommandPick::Selected(selection)) => {
@@ -469,6 +504,23 @@ mod tests {
469504
assert_eq!(normalized, s(&["vp", "env", "exec", "node", "script.js", "foo", "--flag"]));
470505
}
471506

507+
#[test]
508+
fn parse_leading_chdir_accepts_all_clap_short_forms() {
509+
// `main` consumes these before alias normalization and the picker, so
510+
// `vp -C dir node --version` normalizes like `cd dir && vp node ...`.
511+
assert_eq!(parse_leading_chdir(&s(&["vp", "-C", "apps/web", "dev"])), some_dir(2));
512+
assert_eq!(parse_leading_chdir(&s(&["vp", "-Capps/web", "dev"])), some_dir(1));
513+
assert_eq!(parse_leading_chdir(&s(&["vp", "-C=apps/web", "dev"])), some_dir(1));
514+
// Missing or empty value falls through to clap's own error.
515+
assert_eq!(parse_leading_chdir(&s(&["vp", "-C"])), None);
516+
assert_eq!(parse_leading_chdir(&s(&["vp", "-C="])), None);
517+
assert_eq!(parse_leading_chdir(&s(&["vp", "dev"])), None);
518+
}
519+
520+
fn some_dir(consumed: usize) -> Option<(String, usize)> {
521+
Some(("apps/web".to_string(), consumed))
522+
}
523+
472524
#[test]
473525
fn normalize_args_rewrites_bare_vp_node() {
474526
let input = s(&["vp", "node"]);

crates/vite_static_config/src/lib.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,20 @@ impl FieldMap {
8080
FieldMapInner::Open(_) => true,
8181
}
8282
}
83+
84+
/// Like [`get`](Self::get), but only for fields the config explicitly
85+
/// declares. Unlike `get`, an open map (spread, computed keys, or an
86+
/// unanalyzable config) does NOT report undeclared keys as `NonStatic`:
87+
/// they return `None`. Use this when a merely-possible field must not
88+
/// change behavior (e.g. `defaultPackage`, where erroring on every
89+
/// unanalyzable config would break unrelated commands).
90+
#[must_use]
91+
pub fn get_declared(&self, key: &str) -> Option<FieldValue> {
92+
match &self.0 {
93+
FieldMapInner::Closed(map) => map.get(key).cloned(),
94+
FieldMapInner::Open(map) => map.get(key).map(|v| FieldValue::Json(v.clone())),
95+
}
96+
}
8397
}
8498

8599
/// Config file names to try, in priority order.
@@ -458,6 +472,24 @@ mod tests {
458472
assert_eq!(map.get(key), Some(FieldValue::Json(expected)));
459473
}
460474

475+
#[test]
476+
fn get_declared_ignores_undeclared_keys_in_open_maps() {
477+
// A spread makes the map open: `get` reports any key as possibly
478+
// present, but `get_declared` only reports explicitly written ones.
479+
let map = parse("const base = {};\nexport default { ...base, run: { tasks: {} } };");
480+
assert_eq!(map.get("defaultPackage"), Some(FieldValue::NonStatic));
481+
assert_eq!(map.get_declared("defaultPackage"), None);
482+
assert!(map.get_declared("run").is_some());
483+
484+
// Closed map: explicitly declared non-static values still surface.
485+
let map = parse("export default { defaultPackage: process.env.DIR };");
486+
assert_eq!(map.get_declared("defaultPackage"), Some(FieldValue::NonStatic));
487+
488+
// Closed map without the key: definitively absent either way.
489+
let map = parse("export default { run: {} };");
490+
assert_eq!(map.get_declared("defaultPackage"), None);
491+
}
492+
461493
/// Shorthand for asserting a field is `NonStatic`.
462494
fn assert_non_static(map: &FieldMap, key: &str) {
463495
assert_eq!(

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,16 @@ fn looks_runnable(dir: &AbsolutePathBuf, command: &str) -> bool {
7575
/// `defaultPackage` from the `vite.config.*` in `cwd`, read via static
7676
/// extraction so it works at roots without a vite-plus install (non-workspace
7777
/// framework repos). The value must be a static string literal.
78+
///
79+
/// `get_declared` keeps this to explicitly written fields: a config that is
80+
/// unanalyzable or hides fields behind a spread simply falls through to the
81+
/// picker/current-dir resolution instead of failing every bare app command.
7882
fn resolve_default_package(command: &str, cwd: &AbsolutePathBuf) -> Option<AppTarget> {
7983
let fail = |msg: &str| {
8084
output::error(msg);
8185
Some(AppTarget::Exit(ExitStatus(1)))
8286
};
83-
match vite_static_config::resolve_static_config(cwd).get("defaultPackage") {
87+
match vite_static_config::resolve_static_config(cwd).get_declared("defaultPackage") {
8488
Some(vite_static_config::FieldValue::Json(serde_json::Value::String(dir))) => {
8589
let target = cwd.join(&dir).clean();
8690
if !target.as_path().is_dir() {

rfcs/cwd-flag.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ For every vp command:
265265
vp -C <dir> <cmd> [args...] === cd <dir> && vp <cmd> [args...]
266266
```
267267

268-
The child's spawn cwd is `<dir>`, 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 `<dir>`.
268+
The child's spawn cwd is `<dir>`, 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 `<dir>`: 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.
269269

270270
The global binary also resolves the local `vite-plus` install from `<dir>`, 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).
271271

@@ -312,7 +312,8 @@ export default defineConfig({
312312
- Type: `string`, a single directory. A per-command map can come later if real demand appears.
313313
- 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.
314314
- A missing directory errors: `defaultPackage points to a missing directory: ./frontend`.
315-
- 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.
315+
- 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.
316+
- 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.
316317

317318
## Decisions
318319

0 commit comments

Comments
 (0)