Skip to content

Commit 37116dd

Browse files
committed
rfc: redefine around a global -C flag for cwd switching
1 parent 5146aae commit 37116dd

2 files changed

Lines changed: 370 additions & 378 deletions

File tree

rfcs/cwd-flag.md

Lines changed: 370 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
1+
# RFC: Global `-C` Flag for Working-Directory Switching
2+
3+
## Summary
4+
5+
Add a global `-C <dir>` flag to vp, then use it to fix the app-command experience in monorepos. Everything is additive and backward compatible:
6+
7+
1. **`-C <dir>` global flag** (the feature): for every vp command, `vp -C <dir> <cmd>` behaves exactly like `cd <dir> && vp <cmd>`, following the `git -C` / `make -C` convention. This gives vp the first-class "run there" primitive it currently lacks. All existing positional semantics stay untouched: `vp dev <path>` keeps upstream Vite semantics (`root` only), and `vp pack` positionals stay tsdown entries.
8+
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 <dir>`.
9+
10+
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. Fan-out stays with `vp run`.
11+
12+
## Motivation
13+
14+
### Current Pain Points
15+
16+
**1. vp has no first-class "run this command in that directory" form.**
17+
18+
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:
19+
20+
```ts
21+
// apps/admin/vite.config.ts
22+
const cert = fs.readFileSync(path.resolve('certs/dev.pem')) // cwd-relative
23+
```
24+
25+
```
26+
$ cd apps/admin && vp dev # cwd = apps/admin, cert found
27+
28+
VITE+ v0.2.2
29+
30+
➜ Local: http://localhost:5173/
31+
32+
$ vp dev apps/admin # root is right, cwd is still the repo root
33+
failed to load config from /acme/apps/admin/vite.config.ts
34+
error when starting dev server:
35+
Error: ENOENT: no such file or directory, open '/acme/certs/dev.pem'
36+
```
37+
38+
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()`:
39+
40+
```
41+
$ vp pack packages/ui
42+
ℹ entry: packages/ui
43+
ℹ Build start
44+
error: Build failed with 1 error:
45+
46+
[UNRESOLVED_ENTRY] Cannot resolve entry module packages/ui.
47+
```
48+
49+
So the only form that works reliably, uniformly, for every command, is `cd <path> && vp <cmd>`, and vp offers no flag equivalent of it.
50+
51+
**2. At a monorepo root, the app commands are silently wrong.**
52+
53+
The workspace root usually has no app, but `vp dev` happily starts a server pointed at it:
54+
55+
```
56+
$ vp dev
57+
58+
VITE+ v0.2.2
59+
60+
➜ Local: http://localhost:5173/ # opens to a 404, no index.html here
61+
```
62+
63+
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.
64+
65+
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
66+
67+
## Proposed UX
68+
69+
Example workspace used throughout:
70+
71+
```
72+
acme/
73+
├── pnpm-workspace.yaml
74+
├── vite.config.ts
75+
├── apps/web (Vite app)
76+
├── apps/admin (Vite app)
77+
├── packages/ui (library)
78+
└── packages/utils (library)
79+
```
80+
81+
### 1. `-C`: run any vp command in another directory
82+
83+
These do the same thing, byte for byte:
84+
85+
```bash
86+
vp -C apps/admin dev
87+
cd apps/admin && vp dev
88+
```
89+
90+
It is not limited to the app commands:
91+
92+
```bash
93+
vp -C apps/web test
94+
vp -C apps/web run build
95+
```
96+
97+
Args after the subcommand pass through unchanged:
98+
99+
```
100+
$ vp -C apps/admin dev --port 4000
101+
102+
VITE+ v0.2.2
103+
104+
➜ Local: http://localhost:4000/
105+
```
106+
107+
And `-C` gives pack its missing directory form:
108+
109+
```
110+
$ vp -C packages/ui pack
111+
ℹ entry: src/index.ts
112+
ℹ Build start
113+
ℹ dist/index.mjs 0.10 kB │ gzip: 0.11 kB
114+
✔ Build complete in 9ms
115+
```
116+
117+
`vp dev apps/admin` (the positional) is untouched and keeps upstream Vite semantics: `root` is set, cwd is not, so the pain point 1 pitfall remains on that form. Docs and every hint teach `-C` as the reliable way to target a directory.
118+
119+
### 2. `vp dev` at the workspace root (interactive terminal)
120+
121+
Same look and keybindings as the `vp run` task selector, listing packages instead of tasks:
122+
123+
```
124+
$ vp dev
125+
Select a package to dev (↑/↓, Enter to run, type to search):
126+
127+
› web apps/web
128+
admin apps/admin
129+
ui packages/ui
130+
utils packages/utils
131+
```
132+
133+
Typing filters fuzzily, with the query shown inline:
134+
135+
```
136+
Select a package to dev (↑/↓, Enter to run, type to search): adm
137+
138+
› admin apps/admin
139+
```
140+
141+
Enter confirms, prints the teaching hint once, then runs as an implicit `-C`:
142+
143+
```
144+
Selected package: admin (apps/admin)
145+
Tip: run this directly with `vp -C apps/admin dev`
146+
147+
VITE+ v0.2.2
148+
149+
➜ Local: http://localhost:5173/
150+
➜ Network: use --host to expose
151+
```
152+
153+
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`.
154+
155+
### 3. Non-interactive at the root (CI, piped output, scripts)
156+
157+
No picker can appear, so the command fails fast with the same information the picker would have shown:
158+
159+
```
160+
$ vp build
161+
✗ `vp build` at the workspace root needs a target package.
162+
163+
Packages in this workspace:
164+
web apps/web
165+
admin apps/admin
166+
ui packages/ui
167+
utils packages/utils
168+
169+
Pass a directory: vp -C apps/web build
170+
Or run every package's build script: vp run -r build
171+
172+
$ echo $?
173+
1
174+
```
175+
176+
### 4. With `defaultPackage` configured
177+
178+
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:
179+
180+
```
181+
shop/
182+
├── app/ (PHP / Ruby / Go)
183+
├── routes/
184+
├── composer.json
185+
├── vite.config.ts (root config below)
186+
└── frontend/ (the Vite app)
187+
```
188+
189+
There is no `pnpm-workspace.yaml` or `workspaces` field to enumerate, so the picker cannot serve this shape. `defaultPackage` can:
190+
191+
```ts
192+
// vp reads this key via static extraction and never executes this file, so
193+
// the missing vite-plus install at this root is fine. Vite never loads this
194+
// config either; at this root it is purely a pointer for vp.
195+
export default {
196+
defaultPackage: './frontend',
197+
}
198+
```
199+
200+
Bare app commands at the root now behave as `vp -C ./frontend <cmd>`, with one line of output so it never feels magical:
201+
202+
```
203+
$ vp dev
204+
vp dev: using ./frontend (defaultPackage)
205+
206+
VITE+ v0.2.2
207+
208+
➜ Local: http://localhost:5173/
209+
```
210+
211+
An explicit `-C` still wins: `vp -C apps/admin dev` ignores `defaultPackage`.
212+
213+
### 5. Inside a sub-package: nothing changes
214+
215+
```
216+
$ cd apps/web
217+
$ vp dev
218+
219+
VITE+ v0.2.2
220+
221+
➜ Local: http://localhost:5173/
222+
```
223+
224+
No picker ever appears below the root.
225+
226+
## Command Syntax
227+
228+
```
229+
vp [-C <dir>] <command> [args...]
230+
```
231+
232+
### The `-C <dir>` global flag
233+
234+
- 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.
235+
- Semantics: run the command exactly as if invoked in `<dir>`. The directory is resolved against the invocation cwd; a missing directory errors with `directory not found`.
236+
- Because it sits before the subcommand, it cannot collide with Vite or tsdown flags, present or future. The subcommands themselves gain zero flags.
237+
238+
### Positionals and forwarded args: unchanged
239+
240+
Everything after the subcommand is forwarded verbatim, exactly as today. `vp dev <path>` 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 `<dir>` under `-C`). There is no directory-vs-entry disambiguation anywhere.
241+
242+
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`).
243+
244+
## Behavior
245+
246+
### Target directory resolution
247+
248+
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:
249+
250+
1. **`-C <dir>`**: run there. Never triggers the picker.
251+
2. **Positional target present**: forward as today, upstream semantics, vp does not interfere.
252+
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.
253+
4. **Interactive picker**, when bare at the workspace root in an interactive TTY (and not CI): pick, print hint, run as implicit `-C`.
254+
5. **Non-interactive and bare at the workspace root**: print the package list and the `-C` hint, exit 1.
255+
6. **Anywhere else**: current behavior, run in the current directory.
256+
257+
"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`).
258+
259+
### Equivalence invariant
260+
261+
For every vp command:
262+
263+
```
264+
vp -C <dir> <cmd> [args...] === cd <dir> && vp <cmd> [args...]
265+
```
266+
267+
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 parent `vp` process never calls `process.chdir()`.
268+
269+
When the global binary parses `-C`, it 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 additionally assumes a single Vite+ version per workspace, which is the supported monorepo model.
270+
271+
### Entry points and version assumption
272+
273+
- `-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; a root-level `"dev": "vp dev"` script flows through the same logic and gets the same behavior.
274+
- Bare `vp dev` at an arbitrary root is primarily a global-CLI experience; local-only setups usually go through per-package scripts.
275+
- 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 global binary's bundled CLI executes end to end, which is equivalent under this assumption.
276+
277+
### Picker contents
278+
279+
- 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.
280+
- Fuzzy search over name and path via `vite_select::fuzzy_match`, paging identical to the task picker.
281+
- A runnable workspace root appears as a `(workspace root)` entry, keeping today's "run at root" behavior one keystroke away.
282+
- With exactly one likely-runnable package, the picker auto-selects it, printing only the `Selected package:` line and the tip.
283+
284+
### `defaultPackage` config
285+
286+
```ts
287+
export default defineConfig({
288+
// Relative to the config file's directory. Used by vp dev/build/preview/pack
289+
// when invoked bare next to this config: an implicit -C.
290+
defaultPackage: './frontend',
291+
})
292+
```
293+
294+
- Type: `string`, a single directory. A per-command map can come later if real demand appears.
295+
- 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.
296+
- A missing directory errors: `defaultPackage points to a missing directory: ./frontend`.
297+
- 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.
298+
299+
## Decisions
300+
301+
### Vite CLI parity preserved; `-C` carries the `cd` semantics
302+
303+
The core tension: parity with `vite <path>` (positional sets `root` only, cwd untouched) and parity with `cd <path> && 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, following the `git -C` / `make -C` convention. Nothing existing changes meaning, pack needs no directory-vs-entry heuristic, and the primitive generalizes to every vp command instead of four.
304+
305+
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`.
306+
307+
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.
308+
309+
### Root-only interactivity
310+
311+
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.
312+
313+
### Elicitation scope
314+
315+
`-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 fan-out tools. A future command joins the elicitation set exactly when its subject is one package directory.
316+
317+
## Implementation Architecture
318+
319+
All changes live in the Rust layers; no upstream Vite or tsdown changes are required.
320+
321+
- `crates/vite_global_cli/src/cli.rs`: parse the global `-C <dir>`; resolve the local install from `<dir>` and delegate with `<dir>` as the effective cwd.
322+
- `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).
323+
- `packages/cli/binding/src/cli/execution.rs`: spawn the child with cwd set to the target directory.
324+
- Picker: reuse `vite_select` and `vite_workspace`, both already dependencies via the `vite_task` crates.
325+
- `defaultPackage`: extend the `VitePlusConfigLoader` static extraction the same way `run` config is loaded, and add `defaultPackage?: string` to `packages/cli/src/define-config.ts`.
326+
- `packages/cli/src/pack-bin.ts` needs no change: positional handling is untouched and `-C` never reaches it.
327+
- Docs: a `-C` entry in the global CLI docs, `docs/guide/monorepo.md` "App Commands", and a `docs/config/` page for the new key, all teaching `-C` as the directory-targeting form.
328+
329+
## Compatibility
330+
331+
Fully backward compatible for every existing invocation: `vp dev <path>` keeps upstream Vite semantics, pack entries are untouched, and sub-package and non-workspace runs are 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 root that is itself runnable stays available as a picker entry, and `defaultPackage: '.'` restores the old behavior unconditionally.
332+
333+
## Snap Tests
334+
335+
Non-interactive branches are covered by snap tests:
336+
337+
- `vp -C <dir> build` / `vp -C <dir> pack` / `vp -C <dir> run <task>`, plus `-C` with a missing directory.
338+
- Parity regression: `vp dev <dir>` still forwards the positional as Vite `root` with cwd untouched.
339+
- Bare app commands at a workspace root without a TTY: package listing and exit code.
340+
- `defaultPackage`: happy path and missing-directory error.
341+
- Equivalence checks: `vp -C <dir> build` and `cd <dir> && vp build` produce the same output in a fixture whose config reads `process.cwd()`.
342+
343+
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.
344+
345+
## Open Questions
346+
347+
1. Does ranking plus search suffice, or is outright filtering of non-runnable packages ever wanted?
348+
2. Add a `VP_DEFAULT_PACKAGE` env override later? Env companions are an established pattern (`NX_DEFAULT_PROJECT`); deferred from v1.
349+
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).
350+
4. Exact non-interactive gate: the `vp run` picker's TTY check plus the `CI` check used by the global command picker?
351+
5. Should `vp dev <dir>` print a one-line tip pointing at `vp -C <dir> dev`, or would that be noise on a fully supported upstream form?
352+
6. Long form for `-C`: none (git style), or `--cwd` / `--directory` (make style)?
353+
354+
## Appendix: Naming Survey for `defaultPackage`
355+
356+
How comparable tools name "the member a root-level command targets when none is specified":
357+
358+
| Tool | Field | Notes |
359+
| --- | --- | --- |
360+
| Ionic CLI | `defaultProject` | active; root config with a `projects` map |
361+
| Nx | `defaultProject` | deprecated in favor of `NX_DEFAULT_PROJECT` env var |
362+
| Angular CLI | `defaultProject` | deprecated in favor of cwd inference |
363+
| Cargo | `workspace.default-members` | plural, fan-out semantics |
364+
| Salesforce DX | `default: true` on the member | marker pattern; needs member enumeration |
365+
| Vercel / Netlify / Amplify | `rootDirectory` / `base` / `appRoot` | per-app deploy config, not a default among many |
366+
| GitHub Actions | `defaults.run.working-directory` | names the mechanism (cwd) |
367+
368+
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`.
369+
370+
Rejected: `defaultProject` (collides with Vitest `test.projects`, and the picker says "package"), `defaultWorkspace` ("workspace" means the whole monorepo in vp/pnpm vocabulary), `defaultMembers` (fan-out plural, 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.

0 commit comments

Comments
 (0)