diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/astro/astro.config.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/astro/astro.config.mjs
new file mode 100644
index 0000000000..ff8b4c5632
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/astro/astro.config.mjs
@@ -0,0 +1 @@
+export default {};
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/astro/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/astro/package.json
new file mode 100644
index 0000000000..c35a149312
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/astro/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "@test/builtin-framework-guard-astro",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "vpt print astro build script",
+ "dev": "vpt print astro dev script"
+ }
+}
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/index.html b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/index.html
new file mode 100644
index 0000000000..13ee08c5d6
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/index.html
@@ -0,0 +1 @@
+
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/mismatched-script/nuxt.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/mismatched-script/nuxt.config.ts
new file mode 100644
index 0000000000..ff8b4c5632
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/mismatched-script/nuxt.config.ts
@@ -0,0 +1 @@
+export default {};
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/mismatched-script/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/mismatched-script/package.json
new file mode 100644
index 0000000000..7cf640d27c
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/mismatched-script/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "@test/builtin-framework-guard-mismatched-script",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "storybook dev",
+ "serve": "nuxt dev --host"
+ }
+}
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/no-scripts/nuxt.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/no-scripts/nuxt.config.ts
new file mode 100644
index 0000000000..ff8b4c5632
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/no-scripts/nuxt.config.ts
@@ -0,0 +1 @@
+export default {};
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/no-scripts/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/no-scripts/package.json
new file mode 100644
index 0000000000..6d0f11097a
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/no-scripts/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "@test/builtin-framework-guard-no-scripts",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module"
+}
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/nuxt.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/nuxt.config.ts
new file mode 100644
index 0000000000..ff8b4c5632
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/nuxt.config.ts
@@ -0,0 +1 @@
+export default {};
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/package.json
new file mode 100644
index 0000000000..783e243f04
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "@test/builtin-framework-guard",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "vpt print nuxt build script",
+ "dev": "vpt print nuxt dev script",
+ "nested-build": "vp build"
+ }
+}
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/renamed-script/nuxt.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/renamed-script/nuxt.config.ts
new file mode 100644
index 0000000000..ff8b4c5632
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/renamed-script/nuxt.config.ts
@@ -0,0 +1 @@
+export default {};
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/renamed-script/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/renamed-script/package.json
new file mode 100644
index 0000000000..6239701e01
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/renamed-script/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "@test/builtin-framework-guard-renamed-script",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "devtools": "nuxt devtools enable",
+ "make": "nuxt build",
+ "start": "nuxi dev --host"
+ }
+}
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/snapshots.toml
new file mode 100644
index 0000000000..d8ed9bfd53
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/snapshots.toml
@@ -0,0 +1,44 @@
+# `vp dev` and `vp build` run the bundled Vite CLI. That CLI cannot serve or
+# build a project whose framework wraps Vite behind its own CLI. When a Nuxt
+# or Astro config file is next to the nearest `package.json`, the two
+# commands stop with an error that points at `vp run `. The root of
+# this project looks like a Nuxt app, and `astro/` holds an Astro package.
+[[case]]
+name = "builtin_framework_guard"
+vp = ["local", "global"]
+skip-platforms = ["windows"]
+steps = [
+ { argv = ["vp", "dev"], comment = "`vp dev` refuses in a Nuxt project and points at the dev script", continue-on-failure = true },
+ { argv = ["vp", "build"], comment = "`vp build` refuses the same way", continue-on-failure = true },
+ { argv = ["vp", "dev"], cwd = "src", comment = "the refusal finds the enclosing package from a subdirectory, with the same walk `vp run` uses", continue-on-failure = true },
+ { argv = ["vp", "dev"], cwd = "astro", comment = "an Astro config triggers the same refusal", continue-on-failure = true },
+ { argv = ["vp", "dev"], cwd = "no-scripts", comment = "without scripts, the hint points at the framework CLI through `vp exec`", continue-on-failure = true },
+ { argv = ["vp", "build"], cwd = "no-scripts", comment = "`vp build` gets the same fallback hint", continue-on-failure = true },
+ { argv = ["vp", "dev"], cwd = "renamed-script", comment = "a script that runs the framework dev command under another name becomes the hint target", continue-on-failure = true },
+ { argv = ["vp", "build"], cwd = "renamed-script", comment = "the build hint finds the renamed build script the same way", continue-on-failure = true },
+ { argv = ["vp", "dev"], cwd = "mismatched-script", comment = "an unrelated same-named script does not become the hint; the script that runs nuxt dev does", continue-on-failure = true },
+ { argv = ["vp", "build", "web"], comment = "a positional root is inspected instead of the invocation package: the plain Vite child builds" },
+ { argv = ["vp", "dev", "astro"], comment = "a positional root inside an Astro package refuses, and the hint carries -C", continue-on-failure = true },
+ { argv = ["vp", "dev", "--config", "vite.config.ts", "--port", "12312312312"], comment = "an explicit --config selects the bundled Vite CLI on purpose, so only the script note prints (the invalid port stops the server immediately)", continue-on-failure = true },
+ { argv = ["vp", "dev", "--help"], comment = "a help request reaches the tool, so the guard steps aside" },
+ { argv = ["vp", "run", "dev"], comment = "`vp run dev` runs the dev script that the refusal points at" },
+]
+
+# A task or package script can spawn `vp dev` / `vp build` itself, for
+# example a `"nested-build": "vp build"` script. The guard checks direct
+# invocations only, so the nested command runs as invoked.
+[[case]]
+name = "builtin_framework_guard_inside_task"
+vp = "local"
+skip-platforms = ["windows"]
+steps = [
+ { argv = ["vp", "run", "nested-build"], comment = "no refusal: a task-spawned `vp build` runs the bundled Vite build as invoked" },
+]
+
+[[case]]
+name = "builtin_framework_guard_inside_npm_script"
+vp = "local"
+skip-platforms = ["windows"]
+steps = [
+ { argv = ["npm", "run", "nested-build"], comment = "no refusal: an npm-spawned `vp build` runs as invoked" },
+]
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/snapshots/builtin_framework_guard.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/snapshots/builtin_framework_guard.global.md
new file mode 100644
index 0000000000..79b531ff1c
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/snapshots/builtin_framework_guard.global.md
@@ -0,0 +1,213 @@
+# builtin_framework_guard
+
+## `vp dev`
+
+`vp dev` refuses in a Nuxt project and points at the dev script
+
+**Exit code:** 1
+
+```
+VITE+ - The Unified Toolchain for the Web
+
+error: this project uses Nuxt (nuxt.config.ts). `vp dev` runs the bundled Vite CLI, not the Nuxt CLI.
+hint: did you mean `vp run dev`?
+```
+
+## `vp build`
+
+`vp build` refuses the same way
+
+**Exit code:** 1
+
+```
+VITE+ - The Unified Toolchain for the Web
+
+error: this project uses Nuxt (nuxt.config.ts). `vp build` runs the bundled Vite CLI, not the Nuxt CLI.
+hint: did you mean `vp run build`?
+```
+
+## `cd src && vp dev`
+
+the refusal finds the enclosing package from a subdirectory, with the same walk `vp run` uses
+
+**Exit code:** 1
+
+```
+VITE+ - The Unified Toolchain for the Web
+
+error: this project uses Nuxt (nuxt.config.ts). `vp dev` runs the bundled Vite CLI, not the Nuxt CLI.
+hint: did you mean `vp run dev`?
+```
+
+## `cd astro && vp dev`
+
+an Astro config triggers the same refusal
+
+**Exit code:** 1
+
+```
+VITE+ - The Unified Toolchain for the Web
+
+error: this project uses Astro (astro.config.mjs). `vp dev` runs the bundled Vite CLI, not the Astro CLI.
+hint: did you mean `vp run dev`?
+```
+
+## `cd no-scripts && vp dev`
+
+without scripts, the hint points at the framework CLI through `vp exec`
+
+**Exit code:** 1
+
+```
+VITE+ - The Unified Toolchain for the Web
+
+error: this project uses Nuxt (nuxt.config.ts). `vp dev` runs the bundled Vite CLI, not the Nuxt CLI.
+hint: run the Nuxt CLI with `vp exec nuxt dev`.
+```
+
+## `cd no-scripts && vp build`
+
+`vp build` gets the same fallback hint
+
+**Exit code:** 1
+
+```
+VITE+ - The Unified Toolchain for the Web
+
+error: this project uses Nuxt (nuxt.config.ts). `vp build` runs the bundled Vite CLI, not the Nuxt CLI.
+hint: run the Nuxt CLI with `vp exec nuxt build`.
+```
+
+## `cd renamed-script && vp dev`
+
+a script that runs the framework dev command under another name becomes the hint target
+
+**Exit code:** 1
+
+```
+VITE+ - The Unified Toolchain for the Web
+
+error: this project uses Nuxt (nuxt.config.ts). `vp dev` runs the bundled Vite CLI, not the Nuxt CLI.
+hint: did you mean `vp run start`? The start script runs `nuxi dev`.
+```
+
+## `cd renamed-script && vp build`
+
+the build hint finds the renamed build script the same way
+
+**Exit code:** 1
+
+```
+VITE+ - The Unified Toolchain for the Web
+
+error: this project uses Nuxt (nuxt.config.ts). `vp build` runs the bundled Vite CLI, not the Nuxt CLI.
+hint: did you mean `vp run make`? The make script runs `nuxt build`.
+```
+
+## `cd mismatched-script && vp dev`
+
+an unrelated same-named script does not become the hint; the script that runs nuxt dev does
+
+**Exit code:** 1
+
+```
+VITE+ - The Unified Toolchain for the Web
+
+error: this project uses Nuxt (nuxt.config.ts). `vp dev` runs the bundled Vite CLI, not the Nuxt CLI.
+hint: did you mean `vp run serve`? The serve script runs `nuxt dev`.
+```
+
+## `vp build web`
+
+a positional root is inspected instead of the invocation package: the plain Vite child builds
+
+```
+VITE+ - The Unified Toolchain for the Web
+
+note: You are running `vp build` as a Vite+ built-in command. If you meant to run the build npm script, use `vpr build` instead.
+note: `vp build web` sets Vite's root without changing the working directory. To run as if started there, use `vp -C web build`.
+✓ 4 modules transformed.
+computing gzip size...
+web/dist/index.html kB │ gzip: kB
+web/dist/assets/index-.js kB │ gzip: kB
+
+✓ built in
+```
+
+## `vp dev astro`
+
+a positional root inside an Astro package refuses, and the hint carries -C
+
+**Exit code:** 1
+
+```
+VITE+ - The Unified Toolchain for the Web
+
+note: `vp dev astro` sets Vite's root without changing the working directory. To run as if started there, use `vp -C astro dev`.
+error: this project uses Astro (astro.config.mjs). `vp dev` runs the bundled Vite CLI, not the Astro CLI.
+hint: did you mean `vp -C astro run dev`?
+```
+
+## `vp dev --config vite.config.ts --port 12312312312`
+
+an explicit --config selects the bundled Vite CLI on purpose, so only the script note prints (the invalid port stops the server immediately)
+
+**Exit code:** 1
+
+```
+VITE+ - The Unified Toolchain for the Web
+
+note: You are running `vp dev` as a Vite+ built-in command. If you meant to run the dev npm script, use `vpr dev` instead.
+error when starting dev server:
+Error: No available ports found between 12312312312 and 65535
+```
+
+## `vp dev --help`
+
+a help request reaches the tool, so the guard steps aside
+
+```
+VITE+ - The Unified Toolchain for the Web
+
+Usage: vp dev [ROOT] [OPTIONS]
+
+Run the development server.
+Options are forwarded to Vite.
+
+Arguments:
+ [ROOT] Project root directory (default: current directory)
+
+Options:
+ --host [host] [string] specify hostname
+ --port [number] specify port
+ --open [path] [boolean | string] open browser on startup
+ --cors [boolean] enable CORS
+ --strictPort [boolean] exit if specified port is already in use
+ --force [boolean] force the optimizer to ignore the cache and re-bundle
+ --experimentalBundle [boolean] use experimental full bundle mode (this is highly experimental)
+ --base [string] public base path (default: /)
+ -l, --logLevel [string] info | warn | error | silent
+ --clearScreen [boolean] allow/disable clear screen when logging
+ -d, --debug [feat] [string | boolean] show debug logs
+ -f, --filter [string] filter debug logs
+ -m, --mode [string] set env mode
+ -h, --help Display this message
+
+Examples:
+ vp dev
+ vp dev --open
+ vp dev --host localhost --port 5173
+
+Documentation: https://viteplus.dev/guide/dev
+```
+
+## `vp run dev`
+
+`vp run dev` runs the dev script that the refusal points at
+
+```
+VITE+ - The Unified Toolchain for the Web
+
+$ vpt print nuxt dev script ⊘ cache disabled
+nuxt dev script
+```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/snapshots/builtin_framework_guard.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/snapshots/builtin_framework_guard.local.md
new file mode 100644
index 0000000000..e1e6a1da79
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/snapshots/builtin_framework_guard.local.md
@@ -0,0 +1,187 @@
+# builtin_framework_guard
+
+## `vp dev`
+
+`vp dev` refuses in a Nuxt project and points at the dev script
+
+**Exit code:** 1
+
+```
+error: this project uses Nuxt (nuxt.config.ts). `vp dev` runs the bundled Vite CLI, not the Nuxt CLI.
+hint: did you mean `vp run dev`?
+```
+
+## `vp build`
+
+`vp build` refuses the same way
+
+**Exit code:** 1
+
+```
+error: this project uses Nuxt (nuxt.config.ts). `vp build` runs the bundled Vite CLI, not the Nuxt CLI.
+hint: did you mean `vp run build`?
+```
+
+## `cd src && vp dev`
+
+the refusal finds the enclosing package from a subdirectory, with the same walk `vp run` uses
+
+**Exit code:** 1
+
+```
+error: this project uses Nuxt (nuxt.config.ts). `vp dev` runs the bundled Vite CLI, not the Nuxt CLI.
+hint: did you mean `vp run dev`?
+```
+
+## `cd astro && vp dev`
+
+an Astro config triggers the same refusal
+
+**Exit code:** 1
+
+```
+error: this project uses Astro (astro.config.mjs). `vp dev` runs the bundled Vite CLI, not the Astro CLI.
+hint: did you mean `vp run dev`?
+```
+
+## `cd no-scripts && vp dev`
+
+without scripts, the hint points at the framework CLI through `vp exec`
+
+**Exit code:** 1
+
+```
+error: this project uses Nuxt (nuxt.config.ts). `vp dev` runs the bundled Vite CLI, not the Nuxt CLI.
+hint: run the Nuxt CLI with `vp exec nuxt dev`.
+```
+
+## `cd no-scripts && vp build`
+
+`vp build` gets the same fallback hint
+
+**Exit code:** 1
+
+```
+error: this project uses Nuxt (nuxt.config.ts). `vp build` runs the bundled Vite CLI, not the Nuxt CLI.
+hint: run the Nuxt CLI with `vp exec nuxt build`.
+```
+
+## `cd renamed-script && vp dev`
+
+a script that runs the framework dev command under another name becomes the hint target
+
+**Exit code:** 1
+
+```
+error: this project uses Nuxt (nuxt.config.ts). `vp dev` runs the bundled Vite CLI, not the Nuxt CLI.
+hint: did you mean `vp run start`? The start script runs `nuxi dev`.
+```
+
+## `cd renamed-script && vp build`
+
+the build hint finds the renamed build script the same way
+
+**Exit code:** 1
+
+```
+error: this project uses Nuxt (nuxt.config.ts). `vp build` runs the bundled Vite CLI, not the Nuxt CLI.
+hint: did you mean `vp run make`? The make script runs `nuxt build`.
+```
+
+## `cd mismatched-script && vp dev`
+
+an unrelated same-named script does not become the hint; the script that runs nuxt dev does
+
+**Exit code:** 1
+
+```
+error: this project uses Nuxt (nuxt.config.ts). `vp dev` runs the bundled Vite CLI, not the Nuxt CLI.
+hint: did you mean `vp run serve`? The serve script runs `nuxt dev`.
+```
+
+## `vp build web`
+
+a positional root is inspected instead of the invocation package: the plain Vite child builds
+
+```
+note: You are running `vp build` as a Vite+ built-in command. If you meant to run the build npm script, use `vpr build` instead.
+note: `vp build web` sets Vite's root without changing the working directory. To run as if started there, use `vp -C web build`.
+✓ 4 modules transformed.
+computing gzip size...
+web/dist/index.html kB │ gzip: kB
+web/dist/assets/index-.js kB │ gzip: kB
+
+✓ built in
+```
+
+## `vp dev astro`
+
+a positional root inside an Astro package refuses, and the hint carries -C
+
+**Exit code:** 1
+
+```
+note: `vp dev astro` sets Vite's root without changing the working directory. To run as if started there, use `vp -C astro dev`.
+error: this project uses Astro (astro.config.mjs). `vp dev` runs the bundled Vite CLI, not the Astro CLI.
+hint: did you mean `vp -C astro run dev`?
+```
+
+## `vp dev --config vite.config.ts --port 12312312312`
+
+an explicit --config selects the bundled Vite CLI on purpose, so only the script note prints (the invalid port stops the server immediately)
+
+**Exit code:** 1
+
+```
+note: You are running `vp dev` as a Vite+ built-in command. If you meant to run the dev npm script, use `vpr dev` instead.
+error when starting dev server:
+Error: No available ports found between 12312312312 and 65535
+```
+
+## `vp dev --help`
+
+a help request reaches the tool, so the guard steps aside
+
+```
+VITE+ - The Unified Toolchain for the Web
+
+Usage: vp dev [ROOT] [OPTIONS]
+
+Run the development server.
+Options are forwarded to Vite.
+
+Arguments:
+ [ROOT] Project root directory (default: current directory)
+
+Options:
+ --host [host] [string] specify hostname
+ --port [number] specify port
+ --open [path] [boolean | string] open browser on startup
+ --cors [boolean] enable CORS
+ --strictPort [boolean] exit if specified port is already in use
+ --force [boolean] force the optimizer to ignore the cache and re-bundle
+ --experimentalBundle [boolean] use experimental full bundle mode (this is highly experimental)
+ --base [string] public base path (default: /)
+ -l, --logLevel [string] info | warn | error | silent
+ --clearScreen [boolean] allow/disable clear screen when logging
+ -d, --debug [feat] [string | boolean] show debug logs
+ -f, --filter [string] filter debug logs
+ -m, --mode [string] set env mode
+ -h, --help Display this message
+
+Examples:
+ vp dev
+ vp dev --open
+ vp dev --host localhost --port 5173
+
+Documentation: https://viteplus.dev/guide/dev
+```
+
+## `vp run dev`
+
+`vp run dev` runs the dev script that the refusal points at
+
+```
+$ vpt print nuxt dev script ⊘ cache disabled
+nuxt dev script
+```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/snapshots/builtin_framework_guard_inside_npm_script.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/snapshots/builtin_framework_guard_inside_npm_script.md
new file mode 100644
index 0000000000..314c7efde8
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/snapshots/builtin_framework_guard_inside_npm_script.md
@@ -0,0 +1,18 @@
+# builtin_framework_guard_inside_npm_script
+
+## `npm run nested-build`
+
+no refusal: an npm-spawned `vp build` runs as invoked
+
+```
+
+> @test/builtin-framework-guard@1.0.0 nested-build
+> vp build
+
+✓ 4 modules transformed.
+computing gzip size...
+dist/index.html kB │ gzip: kB
+dist/assets/index-.js kB │ gzip: kB
+
+✓ built in
+```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/snapshots/builtin_framework_guard_inside_task.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/snapshots/builtin_framework_guard_inside_task.md
new file mode 100644
index 0000000000..598d905f1d
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/snapshots/builtin_framework_guard_inside_task.md
@@ -0,0 +1,15 @@
+# builtin_framework_guard_inside_task
+
+## `vp run nested-build`
+
+no refusal: a task-spawned `vp build` runs the bundled Vite build as invoked
+
+```
+$ vp build ⊘ cache disabled
+✓ 4 modules transformed.
+computing gzip size...
+dist/index.html kB │ gzip: kB
+dist/assets/index-.js kB │ gzip: kB
+
+✓ built in
+```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/src/valid.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/src/valid.js
new file mode 100644
index 0000000000..9e1015feaf
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/src/valid.js
@@ -0,0 +1 @@
+export const valid = true;
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/vite.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/vite.config.ts
new file mode 100644
index 0000000000..ff8b4c5632
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/vite.config.ts
@@ -0,0 +1 @@
+export default {};
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/web/index.html b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/web/index.html
new file mode 100644
index 0000000000..13ee08c5d6
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/web/index.html
@@ -0,0 +1 @@
+
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/web/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/web/package.json
new file mode 100644
index 0000000000..af039894f4
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/web/package.json
@@ -0,0 +1 @@
+{ "name": "@test/builtin-framework-guard-web-child", "private": true }
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/web/src/valid.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/web/src/valid.js
new file mode 100644
index 0000000000..9e1015feaf
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard/web/src/valid.js
@@ -0,0 +1 @@
+export const valid = true;
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/apps/web/index.html b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/apps/web/index.html
new file mode 100644
index 0000000000..13ee08c5d6
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/apps/web/index.html
@@ -0,0 +1 @@
+
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/apps/web/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/apps/web/package.json
new file mode 100644
index 0000000000..b9775517b4
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/apps/web/package.json
@@ -0,0 +1 @@
+{ "name": "@test/builtin-framework-guard-web", "private": true }
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/apps/web/src/valid.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/apps/web/src/valid.js
new file mode 100644
index 0000000000..9e1015feaf
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/apps/web/src/valid.js
@@ -0,0 +1 @@
+export const valid = true;
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/framework_root_ws/nuxt.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/framework_root_ws/nuxt.config.ts
new file mode 100644
index 0000000000..ff8b4c5632
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/framework_root_ws/nuxt.config.ts
@@ -0,0 +1 @@
+export default {};
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/framework_root_ws/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/framework_root_ws/package.json
new file mode 100644
index 0000000000..1f81e92bec
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/framework_root_ws/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@test/builtin-framework-guard-ws-root",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "nuxt dev"
+ }
+}
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/framework_root_ws/packages/lib/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/framework_root_ws/packages/lib/package.json
new file mode 100644
index 0000000000..4be061b7bb
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/framework_root_ws/packages/lib/package.json
@@ -0,0 +1 @@
+{ "name": "@test/builtin-framework-guard-ws-lib", "private": true }
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/framework_root_ws/pnpm-workspace.yaml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/framework_root_ws/pnpm-workspace.yaml
new file mode 100644
index 0000000000..dee51e928d
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/framework_root_ws/pnpm-workspace.yaml
@@ -0,0 +1,2 @@
+packages:
+ - "packages/*"
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/nuxt.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/nuxt.config.ts
new file mode 100644
index 0000000000..ff8b4c5632
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/nuxt.config.ts
@@ -0,0 +1 @@
+export default {};
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/package.json
new file mode 100644
index 0000000000..3e235c4f0d
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/package.json
@@ -0,0 +1 @@
+{ "name": "@test/builtin-framework-guard-default-package", "private": true }
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/snapshots.toml
new file mode 100644
index 0000000000..822ac840cd
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/snapshots.toml
@@ -0,0 +1,27 @@
+# The framework refusal runs on the resolved target package, not on the
+# invocation directory. `defaultPackage` acts as an implicit -C, so it can
+# move a bare `vp build` out of a framework root into a plain Vite app, or
+# into a framework app that the invocation directory never saw.
+[[case]]
+name = "builtin_framework_guard_default_package"
+vp = ["local", "global"]
+steps = [
+ { argv = ["vp", "build"], comment = "the root holds a nuxt.config.ts, but defaultPackage retargets the build into apps/web, so it runs" },
+ { argv = ["vpt", "list-dir", "apps/web/dist"], comment = "output lands in the configured package" },
+]
+
+[[case]]
+name = "builtin_framework_guard_default_package_to_nuxt"
+vp = ["local", "global"]
+steps = [
+ { argv = ["vp", "build"], cwd = "to_nuxt", comment = "defaultPackage points at a Nuxt app, so the refusal fires for the resolved target and the hint carries -C", continue-on-failure = true },
+]
+
+# A framework app can itself be a workspace root with member packages. The
+# refusal owns that root: the command must not fall into the package picker.
+[[case]]
+name = "builtin_framework_guard_workspace_root"
+vp = ["local", "global"]
+steps = [
+ { argv = ["vp", "dev"], cwd = "framework_root_ws", comment = "a framework app at a workspace root refuses instead of listing member packages", continue-on-failure = true },
+]
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/snapshots/builtin_framework_guard_default_package.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/snapshots/builtin_framework_guard_default_package.global.md
new file mode 100644
index 0000000000..390e2d8e3e
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/snapshots/builtin_framework_guard_default_package.global.md
@@ -0,0 +1,26 @@
+# builtin_framework_guard_default_package
+
+## `vp build`
+
+the root holds a nuxt.config.ts, but defaultPackage retargets the build into apps/web, so it runs
+
+```
+VITE+ - The Unified Toolchain for the Web
+
+note: vp build: using ./apps/web (defaultPackage in vite.config.ts)
+✓ 4 modules transformed.
+computing gzip size...
+dist/index.html kB │ gzip: kB
+dist/assets/index-.js kB │ gzip: kB
+
+✓ built in
+```
+
+## `vpt list-dir apps/web/dist`
+
+output lands in the configured package
+
+```
+assets
+index.html
+```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/snapshots/builtin_framework_guard_default_package.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/snapshots/builtin_framework_guard_default_package.local.md
new file mode 100644
index 0000000000..9ed2e2699e
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/snapshots/builtin_framework_guard_default_package.local.md
@@ -0,0 +1,24 @@
+# builtin_framework_guard_default_package
+
+## `vp build`
+
+the root holds a nuxt.config.ts, but defaultPackage retargets the build into apps/web, so it runs
+
+```
+note: vp build: using ./apps/web (defaultPackage in vite.config.ts)
+✓ 4 modules transformed.
+computing gzip size...
+dist/index.html kB │ gzip: kB
+dist/assets/index-.js kB │ gzip: kB
+
+✓ built in
+```
+
+## `vpt list-dir apps/web/dist`
+
+output lands in the configured package
+
+```
+assets
+index.html
+```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/snapshots/builtin_framework_guard_default_package_to_nuxt.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/snapshots/builtin_framework_guard_default_package_to_nuxt.global.md
new file mode 100644
index 0000000000..a99922768e
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/snapshots/builtin_framework_guard_default_package_to_nuxt.global.md
@@ -0,0 +1,15 @@
+# builtin_framework_guard_default_package_to_nuxt
+
+## `cd to_nuxt && vp build`
+
+defaultPackage points at a Nuxt app, so the refusal fires for the resolved target and the hint carries -C
+
+**Exit code:** 1
+
+```
+VITE+ - The Unified Toolchain for the Web
+
+note: vp build: using ./app (defaultPackage in vite.config.ts)
+error: this project uses Nuxt (nuxt.config.ts). `vp build` runs the bundled Vite CLI, not the Nuxt CLI.
+hint: run the Nuxt CLI with `vp -C app exec nuxt build`.
+```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/snapshots/builtin_framework_guard_default_package_to_nuxt.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/snapshots/builtin_framework_guard_default_package_to_nuxt.local.md
new file mode 100644
index 0000000000..b8a9e86e11
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/snapshots/builtin_framework_guard_default_package_to_nuxt.local.md
@@ -0,0 +1,13 @@
+# builtin_framework_guard_default_package_to_nuxt
+
+## `cd to_nuxt && vp build`
+
+defaultPackage points at a Nuxt app, so the refusal fires for the resolved target and the hint carries -C
+
+**Exit code:** 1
+
+```
+note: vp build: using ./app (defaultPackage in vite.config.ts)
+error: this project uses Nuxt (nuxt.config.ts). `vp build` runs the bundled Vite CLI, not the Nuxt CLI.
+hint: run the Nuxt CLI with `vp -C app exec nuxt build`.
+```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/snapshots/builtin_framework_guard_workspace_root.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/snapshots/builtin_framework_guard_workspace_root.global.md
new file mode 100644
index 0000000000..e0cf6242f9
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/snapshots/builtin_framework_guard_workspace_root.global.md
@@ -0,0 +1,14 @@
+# builtin_framework_guard_workspace_root
+
+## `cd framework_root_ws && vp dev`
+
+a framework app at a workspace root refuses instead of listing member packages
+
+**Exit code:** 1
+
+```
+VITE+ - The Unified Toolchain for the Web
+
+error: this project uses Nuxt (nuxt.config.ts). `vp dev` runs the bundled Vite CLI, not the Nuxt CLI.
+hint: did you mean `vp run dev`?
+```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/snapshots/builtin_framework_guard_workspace_root.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/snapshots/builtin_framework_guard_workspace_root.local.md
new file mode 100644
index 0000000000..f70d96278d
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/snapshots/builtin_framework_guard_workspace_root.local.md
@@ -0,0 +1,12 @@
+# builtin_framework_guard_workspace_root
+
+## `cd framework_root_ws && vp dev`
+
+a framework app at a workspace root refuses instead of listing member packages
+
+**Exit code:** 1
+
+```
+error: this project uses Nuxt (nuxt.config.ts). `vp dev` runs the bundled Vite CLI, not the Nuxt CLI.
+hint: did you mean `vp run dev`?
+```
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/to_nuxt/app/nuxt.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/to_nuxt/app/nuxt.config.ts
new file mode 100644
index 0000000000..ff8b4c5632
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/to_nuxt/app/nuxt.config.ts
@@ -0,0 +1 @@
+export default {};
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/to_nuxt/app/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/to_nuxt/app/package.json
new file mode 100644
index 0000000000..dc4dd92c51
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/to_nuxt/app/package.json
@@ -0,0 +1 @@
+{ "name": "@test/builtin-framework-guard-to-nuxt-app", "private": true }
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/to_nuxt/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/to_nuxt/package.json
new file mode 100644
index 0000000000..22ac2051d2
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/to_nuxt/package.json
@@ -0,0 +1 @@
+{ "name": "@test/builtin-framework-guard-to-nuxt", "private": true }
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/to_nuxt/vite.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/to_nuxt/vite.config.ts
new file mode 100644
index 0000000000..1c15cee28a
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/to_nuxt/vite.config.ts
@@ -0,0 +1,3 @@
+export default {
+ defaultPackage: './app',
+};
diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/vite.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/vite.config.ts
new file mode 100644
index 0000000000..f3f5a014a7
--- /dev/null
+++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/builtin_framework_guard_default_package/vite.config.ts
@@ -0,0 +1,3 @@
+export default {
+ defaultPackage: './apps/web',
+};
diff --git a/docs/guide/run.md b/docs/guide/run.md
index 5752752078..cf49a9d6c2 100644
--- a/docs/guide/run.md
+++ b/docs/guide/run.md
@@ -51,7 +51,7 @@ Select a task (↑/↓, Enter to run, Esc to clear):
`build`, `preview`, `lint`, `fmt`, `check`, and `pack` work the same way.
-If the project defines that script or task, run it with `vp run `. For example, with a `"dev": "astro dev"` script, `vp run dev` starts Astro, while `vp dev` ignores the script and starts Vite.
+If the project defines that script or task, run it with `vp run `. For example, with a `"dev": "astro dev"` script, `vp run dev` starts Astro. In a Nuxt or Astro project, `vp dev` and `vp build` stop with an error. The hint points at the script that runs the framework command, or at the framework CLI through `vp exec` when no script matches. Those frameworks run Vite through their own CLI. The bundled Vite CLI cannot serve or build them.
## Caching
diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md
index ce88a329c5..664a80f382 100644
--- a/docs/guide/troubleshooting.md
+++ b/docs/guide/troubleshooting.md
@@ -45,6 +45,8 @@ For example:
- `vp test` always runs the built-in Vitest command
- `vp run dev`, `vp run build`, and `vp run test` run the matching `package.json` scripts instead
+In a Nuxt or Astro project (a `nuxt.config.*` or `astro.config.*` file next to `package.json`), `vp dev` and `vp build` stop with an error. The hint points at the script that runs the framework command, or at the framework CLI through `vp exec` when no script matches. Those frameworks run Vite through their own CLI. The bundled Vite CLI cannot serve or build them.
+
See [Built-in Commands vs Scripts](/guide/run#built-in-commands-vs-scripts) for when to prefer each path.
::: info
diff --git a/packages/cli/binding/src/cli/app_target.rs b/packages/cli/binding/src/cli/app_target.rs
index 6c687ba7cd..4587887c95 100644
--- a/packages/cli/binding/src/cli/app_target.rs
+++ b/packages/cli/binding/src/cli/app_target.rs
@@ -85,7 +85,7 @@ const PACK_BOOLEAN_FLAGS: &[&str] = &[
/// How an app command's arguments target it, per the walk in
/// [`classify_args`].
-enum ArgTarget<'a> {
+pub(super) enum ArgTarget<'a> {
/// No positional target and no help-like flag: elicitation territory.
Bare,
/// The first token the tool would treat as a positional (a Vite `[root]`
@@ -108,7 +108,7 @@ fn is_bare(command: &str, args: &[String]) -> bool {
/// and disables elicitation. pack's workspace selectors already define their
/// own target set and disable elicitation outright. Help/version requests
/// are answered by the underlying tool and must never be redirected.
-fn classify_args<'a>(command: &str, args: &'a [String]) -> ArgTarget<'a> {
+pub(super) fn classify_args<'a>(command: &str, args: &'a [String]) -> ArgTarget<'a> {
/// `arg` is one of `flags`, exactly or in inline `flag=value` form.
fn matches_flag(arg: &str, flags: &[&str]) -> bool {
flags.iter().any(|f| arg == *f || arg.strip_prefix(f).is_some_and(|r| r.starts_with('=')))
@@ -405,6 +405,12 @@ fn classify(subcommand: &SynthesizableSubcommand, cwd: &AbsolutePath) -> Classif
if root_looks_runnable(&root_config, &workspace_root.path, command) {
return Classification::RunInPlace(Some(workspace_root));
}
+ // A framework root is not runnable by the config heuristic, but the
+ // framework refusal owns it: a package listing must not hide the
+ // refusal. An explicit `defaultPackage` already won above.
+ if super::framework_guard::applies(subcommand, cwd) {
+ return Classification::RunInPlace(Some(workspace_root));
+ }
Classification::Elicit(command, Elicitation::WorkspaceRoot(workspace_root))
}
diff --git a/packages/cli/binding/src/cli/framework_guard.rs b/packages/cli/binding/src/cli/framework_guard.rs
new file mode 100644
index 0000000000..40675c14e0
--- /dev/null
+++ b/packages/cli/binding/src/cli/framework_guard.rs
@@ -0,0 +1,407 @@
+//! Refusal for framework projects whose own CLI wraps Vite.
+//!
+//! `vp dev` and `vp build` run the bundled Vite CLI. Nuxt and Astro run Vite
+//! only through their own CLIs. The bundled Vite CLI cannot serve or build
+//! these projects: dev answers every URL with 404, and build stops on the
+//! missing `index.html` entry. When a framework config file is next to the
+//! nearest `package.json`, the two commands stop with an error and a hint
+//! (voidzero-dev/vite-plus#1506). The hint points at the `package.json`
+//! script that runs the framework command, or at the framework CLI through
+//! `vp exec` when no script matches. The guard checks direct invocations
+//! only: a command spawned from a task or package script, for example a
+//! `"dev": "vp dev"` script, runs as invoked. The guarded directory is the
+//! one Vite would use: the invocation directory, a positional root, or the
+//! target that `defaultPackage`/elicitation resolved. Help and version
+//! requests reach the tool, and an explicit `--config`/`-c` flag selects a
+//! Vite config on purpose, so both skip the refusal.
+
+use owo_colors::OwoColorize;
+use vp_shared::output;
+use vt::ExitStatus;
+use vt_path::AbsolutePath;
+
+use super::types::SynthesizableSubcommand;
+
+/// Frameworks that wrap Vite behind their own CLI. Each entry lists the
+/// config files that the framework's loader resolves, in its resolution
+/// order.
+const FRAMEWORKS: &[Framework] = &[
+ // Nuxt resolves `nuxt.config` through c12. `loadNuxtConfig` passes
+ // `configFile: "nuxt.config"`
+ // (https://github.com/nuxt/nuxt/blob/v4.5.2/packages/kit/src/loader/config.ts),
+ // and c12 tries the script extensions in `SUPPORTED_EXTENSIONS`
+ // (https://github.com/unjs/c12/blob/v3.3.4/src/loader.ts). c12 also
+ // accepts data configs (`.json`, `.jsonc`, `.json5`, `.yaml`, `.yml`,
+ // `.toml`) and rc files. Those are rare, so the guard does not check
+ // them.
+ Framework {
+ name: "Nuxt",
+ config_files: &[
+ "nuxt.config.js",
+ "nuxt.config.ts",
+ "nuxt.config.mjs",
+ "nuxt.config.cjs",
+ "nuxt.config.mts",
+ "nuxt.config.cts",
+ ],
+ // The nuxt package ships the `nuxt` and `nuxi` bins (its `bin`
+ // field, verified against nuxt 4.5.2).
+ bins: &["nuxt", "nuxi"],
+ },
+ // Astro searches only these four names: `configPaths` in
+ // https://github.com/withastro/astro/blob/astro@7.2.2/packages/astro/src/core/config/config.ts.
+ // Astro does not load a `.cjs` or `.cts` config.
+ Framework {
+ name: "Astro",
+ config_files: &[
+ "astro.config.mjs",
+ "astro.config.js",
+ "astro.config.ts",
+ "astro.config.mts",
+ ],
+ // The astro package ships the `astro` bin (its `bin` field,
+ // verified against astro 7.2.2).
+ bins: &["astro"],
+ },
+];
+
+struct Framework {
+ name: &'static str,
+ config_files: &'static [&'static str],
+ /// Executable names of the framework CLI. The first one is the name the
+ /// `vp exec` hint shows.
+ bins: &'static [&'static str],
+}
+
+/// Refuse `vp dev` / `vp build` in a Nuxt or Astro project.
+///
+/// `retarget` is the path that elicitation or `defaultPackage` resolved for
+/// a bare command, relative to the invocation directory; hints then carry
+/// the matching `-C ` so they run in the refused package. Returns
+/// the exit status after it prints the refusal, or `None` when the command
+/// can proceed.
+pub(super) fn check(
+ subcommand: &SynthesizableSubcommand,
+ cwd: &AbsolutePath,
+ retarget: Option<&str>,
+) -> Option {
+ let refusal = find_refusal(subcommand, cwd, retarget)?;
+ let built_in = format!("`vp {}`", refusal.command).bright_blue().to_string();
+ output::error(&format!(
+ "this project uses {name} ({config_file}). {built_in} runs the bundled Vite CLI, \
+ not the {name} CLI.",
+ name = refusal.framework.name,
+ config_file = refusal.config_file,
+ ));
+ output::raw_stderr(&format!("hint: {}", run_hint(&refusal)));
+ Some(ExitStatus(1))
+}
+
+/// Whether `check` would refuse, without output. The script note and
+/// workspace elicitation ask this before they act: a note must not
+/// recommend `vpr` right before a refusal, and a package listing must not
+/// hide one.
+pub(super) fn applies(subcommand: &SynthesizableSubcommand, cwd: &AbsolutePath) -> bool {
+ find_refusal(subcommand, cwd, None).is_some()
+}
+
+struct Refusal {
+ command: &'static str,
+ framework: &'static Framework,
+ config_file: &'static str,
+ /// The enclosing `package.json` of the refused directory, for the hint.
+ manifest: serde_json::Value,
+ /// `-C` target for the hint when the refused directory is not the
+ /// invocation directory (a positional root or a resolved retarget).
+ target: Option,
+}
+
+/// The refusal for this invocation, or `None` when the command can proceed.
+fn find_refusal(
+ subcommand: &SynthesizableSubcommand,
+ cwd: &AbsolutePath,
+ retarget: Option<&str>,
+) -> Option {
+ let (command, args) = match subcommand {
+ SynthesizableSubcommand::Dev { args } => ("dev", args),
+ SynthesizableSubcommand::Build { args } => ("build", args),
+ _ => return None,
+ };
+ // A task or package script can spawn `vp dev` itself, for example a
+ // `"dev": "vp dev"` script. The nested command runs as invoked.
+ if super::script_note::spawned_from_script() {
+ return None;
+ }
+ // The tool's own arg walk decides what the guard inspects: a positional
+ // is the Vite root, and an explicit invocation (a help/version request,
+ // a `-c`/`--config` file) must reach the tool untouched.
+ let positional_root;
+ let (dir, target): (&AbsolutePath, Option<&str>) =
+ match super::app_target::classify_args(command, args) {
+ super::app_target::ArgTarget::Explicit => return None,
+ super::app_target::ArgTarget::Positional(root) => {
+ positional_root = cwd.join(root).clean();
+ (&positional_root, Some(root))
+ }
+ super::app_target::ArgTarget::Bare => (cwd, retarget),
+ };
+ // `vp run` resolves the task from the nearest `package.json`. The same
+ // walk here keeps the hint correct from a subdirectory.
+ let package = vt_workspace::find_package_root(dir).ok()?;
+ let (framework, config_file) = detect(package.path)?;
+ let manifest = serde_json::from_slice::(package.package_json.content())
+ .unwrap_or(serde_json::Value::Null);
+ Some(Refusal { command, framework, config_file, manifest, target: target.map(str::to_string) })
+}
+
+/// The hint that follows the refusal. It points at the first path that works
+/// in the refused package:
+///
+/// 1. the `package.json` script with the command's name, when its command
+/// runs the framework (a `"dev": "storybook dev"` must not become the
+/// hint),
+/// 2. another script that runs the framework command,
+/// 3. the framework CLI through `vp exec`.
+///
+/// The check reads `package.json` scripts only. A `run.tasks` entry in
+/// `vite.config.ts` with the command's name also works with `vp run`, but
+/// the guard does not load that config.
+fn run_hint(refusal: &Refusal) -> String {
+ let Refusal { command, framework, manifest, target, .. } = refusal;
+ let vp = match target {
+ Some(target) => format!("vp -C {target}"),
+ None => "vp".to_string(),
+ };
+ if let Some(scripts) = manifest.get("scripts").and_then(serde_json::Value::as_object) {
+ let named = scripts.get(*command).and_then(serde_json::Value::as_str);
+ if named.is_some_and(|script| framework_invocation(script, framework, command).is_some()) {
+ let via_run = format!("`{vp} run {command}`").bright_blue().to_string();
+ return format!("did you mean {via_run}?");
+ }
+ for (name, script) in scripts {
+ if name == command {
+ continue;
+ }
+ let Some(script) = script.as_str() else { continue };
+ if let Some(invocation) = framework_invocation(script, framework, command) {
+ let via_run = format!("`{vp} run {name}`").bright_blue().to_string();
+ return format!("did you mean {via_run}? The {name} script runs `{invocation}`.");
+ }
+ }
+ }
+ let via_exec = format!("`{vp} exec {} {command}`", framework.bins[0]).bright_blue().to_string();
+ format!("run the {} CLI with {via_exec}.", framework.name)
+}
+
+/// The ` ` invocation inside `script`, when the script runs
+/// the framework command.
+fn framework_invocation(script: &str, framework: &Framework, command: &str) -> Option {
+ framework
+ .bins
+ .iter()
+ .map(|bin| format!("{bin} {command}"))
+ .find(|invocation| contains_word(script, invocation))
+}
+
+/// Whether `text` contains `pattern` between whitespace boundaries, so
+/// `nuxt dev` does not match inside `nuxt devtools`.
+fn contains_word(text: &str, pattern: &str) -> bool {
+ let mut search_from = 0;
+ while let Some(found) = text[search_from..].find(pattern) {
+ let start = search_from + found;
+ let end = start + pattern.len();
+ let boundary_before = start == 0 || text[..start].ends_with(char::is_whitespace);
+ let boundary_after = end == text.len() || text[end..].starts_with(char::is_whitespace);
+ if boundary_before && boundary_after {
+ return true;
+ }
+ search_from = start + 1;
+ }
+ false
+}
+
+/// The first framework config file present in `dir`.
+fn detect(dir: &AbsolutePath) -> Option<(&'static Framework, &'static str)> {
+ for framework in FRAMEWORKS {
+ for &config_file in framework.config_files {
+ if dir.join(config_file).as_path().is_file() {
+ return Some((framework, config_file));
+ }
+ }
+ }
+ None
+}
+
+#[cfg(test)]
+mod tests {
+ use vt_path::AbsolutePathBuf;
+
+ use super::{
+ super::app_target::{ArgTarget, classify_args},
+ FRAMEWORKS, Refusal, contains_word, detect, run_hint,
+ };
+
+ fn framework(name: &str) -> &'static super::Framework {
+ FRAMEWORKS.iter().find(|framework| framework.name == name).expect("known framework")
+ }
+
+ fn temp_dir(label: &str) -> AbsolutePathBuf {
+ let unique = format!(
+ "vp-framework-guard-{label}-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("clock after epoch")
+ .as_nanos()
+ );
+ let dir = std::env::temp_dir().join(unique);
+ std::fs::create_dir_all(&dir).expect("create temp dir");
+ AbsolutePathBuf::new(dir).expect("temp dir is absolute")
+ }
+
+ #[test]
+ fn detects_nuxt_before_astro_across_extensions() {
+ let dir = temp_dir("detect");
+ std::fs::write(dir.as_path().join("astro.config.mjs"), "export default {}")
+ .expect("write astro config");
+ std::fs::write(dir.as_path().join("nuxt.config.mts"), "export default {}")
+ .expect("write nuxt config");
+
+ let (framework, config_file) = detect(&dir).expect("framework detected");
+ assert_eq!(framework.name, "Nuxt");
+ assert_eq!(config_file, "nuxt.config.mts");
+
+ std::fs::remove_dir_all(dir.as_path()).expect("remove temp dir");
+ }
+
+ #[test]
+ fn ignores_directories_and_unrelated_files() {
+ let dir = temp_dir("ignore");
+ std::fs::create_dir_all(dir.as_path().join("nuxt.config.ts")).expect("create dir");
+ std::fs::write(dir.as_path().join("vite.config.ts"), "export default {}")
+ .expect("write vite config");
+
+ assert!(detect(&dir).is_none());
+
+ std::fs::remove_dir_all(dir.as_path()).expect("remove temp dir");
+ }
+
+ #[test]
+ fn ignores_config_names_astro_does_not_load() {
+ let dir = temp_dir("astro-cjs");
+ std::fs::write(dir.as_path().join("astro.config.cjs"), "module.exports = {}")
+ .expect("write cjs config");
+ std::fs::write(dir.as_path().join("astro.config.cts"), "module.exports = {}")
+ .expect("write cts config");
+
+ assert!(detect(&dir).is_none());
+
+ std::fs::remove_dir_all(dir.as_path()).expect("remove temp dir");
+ }
+
+ fn refusal(
+ name: &str,
+ command: &'static str,
+ manifest: serde_json::Value,
+ target: Option<&str>,
+ ) -> Refusal {
+ Refusal {
+ command,
+ framework: framework(name),
+ config_file: "nuxt.config.ts",
+ manifest,
+ target: target.map(str::to_string),
+ }
+ }
+
+ #[test]
+ fn hint_prefers_the_script_with_the_command_name() {
+ let manifest = serde_json::json!({ "scripts": { "dev": "nuxt dev" } });
+ let hint = run_hint(&refusal("Nuxt", "dev", manifest, None));
+ assert!(hint.contains("vp run dev"), "hint was: {hint}");
+ }
+
+ #[test]
+ fn hint_skips_a_same_named_script_that_runs_something_else() {
+ let manifest = serde_json::json!({ "scripts": {
+ "dev": "storybook dev",
+ "serve": "nuxt dev --host",
+ } });
+ let hint = run_hint(&refusal("Nuxt", "dev", manifest, None));
+ assert!(hint.contains("vp run serve"), "hint was: {hint}");
+ assert!(!hint.contains("vp run dev"), "hint was: {hint}");
+
+ let only_unrelated = serde_json::json!({ "scripts": { "dev": "storybook dev" } });
+ let hint = run_hint(&refusal("Nuxt", "dev", only_unrelated, None));
+ assert!(hint.contains("vp exec nuxt dev"), "hint was: {hint}");
+ }
+
+ #[test]
+ fn hint_finds_a_renamed_script_that_runs_the_framework_command() {
+ let manifest = serde_json::json!({ "scripts": {
+ "devtools": "nuxt devtools enable",
+ "start": "NODE_OPTIONS=--inspect nuxi dev --host",
+ } });
+ let hint = run_hint(&refusal("Nuxt", "dev", manifest, None));
+ assert!(hint.contains("vp run start"), "hint was: {hint}");
+ assert!(hint.contains("nuxi dev"), "hint was: {hint}");
+ }
+
+ #[test]
+ fn hint_falls_back_to_vp_exec_without_a_matching_script() {
+ let hint = run_hint(&refusal("Nuxt", "dev", serde_json::json!({}), None));
+ assert!(hint.contains("vp exec nuxt dev"), "hint was: {hint}");
+
+ let unrelated = serde_json::json!({ "scripts": { "lint": "oxlint ." } });
+ let hint = run_hint(&refusal("Astro", "build", unrelated, None));
+ assert!(hint.contains("vp exec astro build"), "hint was: {hint}");
+ }
+
+ #[test]
+ fn hint_carries_the_resolved_target() {
+ let manifest = serde_json::json!({ "scripts": { "dev": "nuxt dev" } });
+ let hint = run_hint(&refusal("Nuxt", "dev", manifest, Some("apps/web")));
+ assert!(hint.contains("vp -C apps/web run dev"), "hint was: {hint}");
+
+ let hint = run_hint(&refusal("Nuxt", "build", serde_json::json!({}), Some("app")));
+ assert!(hint.contains("vp -C app exec nuxt build"), "hint was: {hint}");
+ }
+
+ #[test]
+ fn contains_word_needs_whitespace_boundaries() {
+ assert!(contains_word("nuxt dev", "nuxt dev"));
+ assert!(contains_word("NODE_OPTIONS=--inspect nuxt dev --host", "nuxt dev"));
+ assert!(!contains_word("nuxt devtools enable", "nuxt dev"));
+ assert!(!contains_word("pnpm-nuxt dev", "nuxt dev"));
+ }
+
+ #[test]
+ fn arg_classification_drives_the_guarded_directory() {
+ let owned = |args: &[&str]| args.iter().map(|arg| (*arg).to_string()).collect::>();
+ // Explicit invocations reach the tool untouched.
+ for args in [
+ vec!["--help"],
+ vec!["-h"],
+ vec!["--version"],
+ vec!["-v"],
+ vec!["--port", "5000", "--help"],
+ vec!["--config", "vite.config.ts"],
+ vec!["--config=vite.config.ts"],
+ vec!["-c", "vite.config.ts"],
+ ] {
+ assert!(
+ matches!(classify_args("dev", &owned(&args)), ArgTarget::Explicit),
+ "expected Explicit for {args:?}"
+ );
+ }
+ // A positional is the Vite root and becomes the guarded directory.
+ assert!(matches!(classify_args("dev", &owned(&["web"])), ArgTarget::Positional("web")));
+ assert!(matches!(
+ classify_args("dev", &owned(&["--cors", "web"])),
+ ArgTarget::Positional("web")
+ ));
+ // A flag value is not a positional; a bare command guards the
+ // invocation directory.
+ assert!(matches!(classify_args("dev", &owned(&["--port", "5000"])), ArgTarget::Bare));
+ }
+}
diff --git a/packages/cli/binding/src/cli/mod.rs b/packages/cli/binding/src/cli/mod.rs
index 1954d24daa..aa9fd82c43 100644
--- a/packages/cli/binding/src/cli/mod.rs
+++ b/packages/cli/binding/src/cli/mod.rs
@@ -5,6 +5,7 @@
mod app_target;
mod execution;
+mod framework_guard;
mod handler;
mod help;
mod resolver;
@@ -41,6 +42,17 @@ use self::{
types::CLIArgs,
};
+/// The `-C` spelling of a resolved target for user-facing hints: relative to
+/// the invocation directory when the target is inside it, absolute otherwise.
+fn display_target(invocation: &AbsolutePath, target: &AbsolutePath) -> String {
+ if let Ok(Some(rel)) = target.strip_prefix(invocation)
+ && !rel.as_str().is_empty()
+ {
+ return rel.as_str().to_string();
+ }
+ target.as_path().display().to_string()
+}
+
/// Execute a synthesizable subcommand directly (not through vite-task Session).
/// No caching, no task graph, no dependency resolution.
async fn execute_direct_subcommand(
@@ -53,11 +65,19 @@ async fn execute_direct_subcommand(
// in the resolved directory (rfcs/cwd-flag.md).
let (target, workspace_root_hint) = app_target::resolve_app_target(&subcommand, cwd)?;
let retargeted = matches!(&target, app_target::AppTarget::Dir(_));
- let cwd = match &target {
+ let (cwd, retarget) = match &target {
app_target::AppTarget::Exit(status) => return Ok(*status),
- app_target::AppTarget::Dir(dir) => dir,
- app_target::AppTarget::CurrentDir => cwd,
+ app_target::AppTarget::Dir(dir) => (dir, Some(display_target(cwd, dir))),
+ app_target::AppTarget::CurrentDir => (cwd, None),
};
+ // The refusal belongs to the resolved target: elicitation or
+ // `defaultPackage` can select a package that the invocation directory
+ // never saw, or move the command out of a framework root into a plain
+ // Vite app. A retarget rides into the hints as `-C `, so they
+ // run in the refused package.
+ if let Some(exit) = framework_guard::check(&subcommand, cwd, retarget.as_deref()) {
+ return Ok(exit);
+ }
// The resolver hands back the workspace root it already found whenever the
// command runs in the unchanged cwd (never after a -C/elicitation
@@ -392,7 +412,15 @@ pub async fn main(
// legitimately trigger a project's `install` lifecycle scripts
// through the package manager, so redirecting those to `vpr` would
// be wrong; and `exec` names a binary rather than a task.
- script_note::print(raw_subcommand.as_deref(), &cwd);
+ //
+ // The framework refusal itself runs after target resolution in
+ // `execute_direct_subcommand`, because `defaultPackage` can move
+ // the command out of a framework root. The note still must not
+ // recommend `vpr` right before a refusal, so a refusal in the
+ // invocation package silences it.
+ if !framework_guard::applies(&subcmd, &cwd) {
+ script_note::print(raw_subcommand.as_deref(), &cwd);
+ }
execute_direct_subcommand(subcmd, &cwd, options).await
}
CLIArgs::ViteTask(command) => execute_vite_task_command(command, cwd, options).await,
diff --git a/packages/cli/binding/src/cli/script_note.rs b/packages/cli/binding/src/cli/script_note.rs
index ec1d812302..a7fb9a5c1a 100644
--- a/packages/cli/binding/src/cli/script_note.rs
+++ b/packages/cli/binding/src/cli/script_note.rs
@@ -21,11 +21,8 @@ const NPM_LIFECYCLE_EVENT_ENV_NAME: &str = "npm_lifecycle_event";
pub(super) fn print(command: Option<&str>, cwd: &AbsolutePath) {
let Some(command) = command else { return };
// A task spawned this command, so the user is already on a script-running
- // path. npm-compatible runners set `npm_lifecycle_event`; Vite Task uses
- // its own marker.
- if std::env::var_os(MARKER_ENV_NAME).is_some()
- || std::env::var_os(NPM_LIFECYCLE_EVENT_ENV_NAME).is_some_and(|event| !event.is_empty())
- {
+ // path.
+ if spawned_from_script() {
return;
}
if !has_package_json_script(cwd, command) {
@@ -40,6 +37,13 @@ pub(super) fn print(command: Option<&str>, cwd: &AbsolutePath) {
));
}
+/// Whether a task or a package script spawned this command. npm-compatible
+/// runners set `npm_lifecycle_event`; Vite Task uses its own marker.
+pub(super) fn spawned_from_script() -> bool {
+ std::env::var_os(MARKER_ENV_NAME).is_some()
+ || std::env::var_os(NPM_LIFECYCLE_EVENT_ENV_NAME).is_some_and(|event| !event.is_empty())
+}
+
/// Whether the package enclosing `cwd` defines a `` script.
///
/// Walks up to the nearest `package.json`, which is the package `vp run`