diff --git a/Cargo.lock b/Cargo.lock index 8de2ce868a..74643d29c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8366,6 +8366,7 @@ dependencies = [ "vite_static_config", "vite_str", "vite_task", + "vite_toolchain", "vite_workspace", ] @@ -8473,6 +8474,7 @@ dependencies = [ "vite_setup", "vite_shared", "vite_str", + "vite_toolchain", "vite_workspace", ] @@ -8863,6 +8865,20 @@ dependencies = [ "wincode", ] +[[package]] +name = "vite_toolchain" +version = "0.0.0" +dependencies = [ + "pretty_assertions", + "rustc-hash", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.19", + "vite_path", + "vite_str", +] + [[package]] name = "vite_trampoline" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index cec043de63..7d1fba39f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -298,6 +298,7 @@ vite_pm_cli_macros = { path = "crates/vite_pm_cli_macros" } vite_setup = { path = "crates/vite_setup" } vite_shared = { path = "crates/vite_shared" } vite_static_config = { path = "crates/vite_static_config" } +vite_toolchain = { path = "crates/vite_toolchain" } vite_path = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "5c1d02c750ac21c6f4cf0528062590a145e87fd1" } vite_powershell = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "5c1d02c750ac21c6f4cf0528062590a145e87fd1" } vite_select = { git = "https://github.com/voidzero-dev/vite-task.git", rev = "5c1d02c750ac21c6f4cf0528062590a145e87fd1" } diff --git a/README.md b/README.md index db19882f0a..30caadb6e5 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Vite+ is the unified entry point for local web development. It combines [Vite](h - **`vp build`:** Build applications for production with Vite + Rolldown - **`vp run`:** Run `package.json` scripts and monorepo tasks with caching and dependency-aware scheduling - **`vp pack`:** Build libraries for npm publishing or standalone app binaries +- **`vp toolchain`:** Inspect the exact Vite+, Vite, Rolldown, Oxc, and other tool versions in use - **`vp create` / `vp migrate`:** Scaffold new projects and migrate existing ones All of this is configured from your project root and works across Vite's framework ecosystem. @@ -145,6 +146,7 @@ Vite+ automatically wraps your package manager (pnpm, npm, Yarn, or Bun) based o #### Maintain +- **toolchain** - Show Vite+ tool versions and their relationships - **upgrade** - Update `vp` itself to the latest version - **implode** - Remove `vp` and all related data @@ -195,7 +197,7 @@ If you are manually migrating a project to Vite+, install these dev dependencies npm install -D vite-plus @voidzero-dev/vite-plus-core@latest ``` -You need to add overrides to your package manager so that other packages resolve the Vite+ versions: alias `vite` to `@voidzero-dev/vite-plus-core`, and pin `vitest` to the version Vite+ bundles (run `vp --version`) so the whole project shares a single Vitest copy with `vp test`. Without the `vitest` pin, a dependency or workspace package can pull a different Vitest than the bundled runner, splitting Vitest's internals (mocks, `expect`, runner state): +You need to add overrides to your package manager so that other packages resolve the Vite+ versions: alias `vite` to `@voidzero-dev/vite-plus-core`, and pin `vitest` to the version reported by `vp toolchain vitest` so the whole project shares a single Vitest copy with `vp test`. Without the `vitest` pin, a dependency or workspace package can pull a different Vitest than the bundled runner, splitting Vitest's internals (mocks, `expect`, runner state): ```json "overrides": { diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md index 0e9d8953a2..0a3989aa1a 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message.md @@ -50,6 +50,9 @@ Manage Dependencies: rebuild Rebuild native modules pm Forward a command to the package manager +Inspect: + toolchain Show versions and relationships in the active Vite+ toolchain + Maintain: upgrade Update vp itself to the latest version implode Remove vp and all related data diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message_local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message_local.md index 074c6346c6..7216cc0e77 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message_local.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/cli_helper_message/snapshots/cli_helper_message_local.md @@ -25,6 +25,7 @@ Core Commands: cache Manage the task cache config Configure hooks and agent integration staged Run linters on staged files + toolchain Show versions and relationships in the Vite+ toolchain Package Manager Commands: install Install all dependencies, or add packages if package names are provided diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_helper/snapshots/command_helper.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_helper/snapshots/command_helper.md index baa96f267f..3944be15c2 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_helper/snapshots/command_helper.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_helper/snapshots/command_helper.md @@ -25,6 +25,7 @@ Core Commands: cache Manage the task cache config Configure hooks and agent integration staged Run linters on staged files + toolchain Show versions and relationships in the Vite+ toolchain Package Manager Commands: install Install all dependencies, or add packages if package names are provided diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/package.json b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/package.json new file mode 100644 index 0000000000..c73fb55c9b --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/package.json @@ -0,0 +1,6 @@ +{ + "name": "command-toolchain", + "version": "1.0.0", + "private": true, + "packageManager": "pnpm@11.0.6" +} diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots.toml b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots.toml new file mode 100644 index 0000000000..1804d9cace --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots.toml @@ -0,0 +1,61 @@ +[[case]] +name = "toolchain_full_local" +vp = "local" +comment = "The local CLI reads the manifest shipped in its vite-plus package." +steps = [["vp", "toolchain"]] + +[[case]] +name = "toolchain_full_global" +vp = "global" +comment = "The global flag reads the manifest paired with the global binary." +steps = [["vp", "toolchain", "--global"]] + +[[case]] +name = "toolchain_filters" +vp = "local" +steps = [ + { argv = ["vp", "toolchain", "vite"], comment = "a tool filter keeps its ownership and engine chain" }, + { argv = ["vp", "toolchain", "vite", "vitest"], comment = "multiple filters return a stable union" }, + { argv = ["vp", "toolchain", "vite-plus-core", "tsgolint", "vite-task"], comment = "stable IDs and declared aliases resolve" }, +] + +[[case]] +name = "toolchain_json" +vp = "global" +steps = [ + { argv = ["vp", "toolchain", "vite", "--json", "--global"], tty = false, comment = "JSON contains one node per ID and no human header" }, +] + +[[case]] +name = "toolchain_unknown" +vp = "local" +steps = [ + { argv = ["vp", "toolchain", "rollup"], comment = "human output points back to the package graph", continue-on-failure = true }, + { argv = ["vp", "toolchain", "rollup", "--json"], tty = false, comment = "JSON mode omits the human hint", continue-on-failure = true }, +] + +[[case]] +name = "toolchain_global_from_local_cli" +vp = "local" +steps = [ + { argv = ["vp", "toolchain", "--global"], comment = "the local package leaves global selection to the global vp binary", continue-on-failure = true }, +] + +[[case]] +name = "toolchain_old_local_routing" +vp = "global" +steps = [ + { argv = ["vpt", "mkdir", "-p", "node_modules/vite-plus/dist"], snapshot = false }, + { argv = ["vpt", "write-file", "node_modules/vite-plus/package.json", "{\"name\":\"vite-plus\",\"version\":\"0.1.0\"}\n"], snapshot = false }, + { argv = ["vpt", "write-file", "node_modules/vite-plus/dist/bin.js", "console.error(\"error: Command 'toolchain' not found\");\nprocess.exitCode = 2;\n"], snapshot = false }, + { argv = ["vp", "toolchain"], timeout = 120000, comment = "the global binary delegates and lets an old local CLI reject the command", continue-on-failure = true }, + { argv = ["vp", "toolchain", "--global"], comment = "--global skips the old local package" }, +] + +[[case]] +name = "toolchain_why_hint" +vp = ["local", "global"] +steps = [ + { argv = ["vp", "why", "vite"], timeout = 120000, comment = "a successful human package query points to toolchain inspection" }, + { argv = ["vp", "why", "vite", "--json"], tty = false, timeout = 120000, comment = "machine-readable package-manager output has no hint" }, +] diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_filters.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_filters.md new file mode 100644 index 0000000000..3250a1c1ee --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_filters.md @@ -0,0 +1,45 @@ +# toolchain_filters + +## `vp toolchain vite` + +a tool filter keeps its ownership and engine chain + +``` +Vite+ toolchain (local) + +vite-plus@0.2.7 +`-- depends on @voidzero-dev/vite-plus-core@ + `-- bundles vite@8.2.0 + `-- uses rolldown@1.2.2 + |-- compiles oxc@0.142.0 + `-- compiles oxc-resolver@11.24.2 +``` + +## `vp toolchain vite vitest` + +multiple filters return a stable union + +``` +Vite+ toolchain (local) + +vite-plus@0.2.7 +|-- depends on @voidzero-dev/vite-plus-core@ +| `-- bundles vite@8.2.0 +| `-- uses rolldown@1.2.2 +| |-- compiles oxc@0.142.0 +| `-- compiles oxc-resolver@11.24.2 +`-- depends on vitest@4.1.10 +``` + +## `vp toolchain vite-plus-core tsgolint vite-task` + +stable IDs and declared aliases resolve + +``` +Vite+ toolchain (local) + +vite-plus@0.2.7 +|-- depends on @voidzero-dev/vite-plus-core@ +|-- depends on oxlint-tsgolint@7.0.2001 +`-- compiles vite-task@0.0.0 (5c1d02c750ac21c6f4cf0528062590a145e87fd1) +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_full_global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_full_global.md new file mode 100644 index 0000000000..1c1cc60b7f --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_full_global.md @@ -0,0 +1,25 @@ +# toolchain_full_global + +The global flag reads the manifest paired with the global binary. + +## `vp toolchain --global` + +``` +Vite+ toolchain (global) + +vite-plus@0.2.7 +|-- depends on @voidzero-dev/vite-plus-core@ +| |-- bundles vite@8.2.0 +| | `-- uses rolldown@1.2.2 +| | |-- compiles oxc@0.142.0 +| | `-- compiles oxc-resolver@11.24.2 +| |-- bundles rolldown@1.2.2 +| | |-- compiles oxc@0.142.0 +| | `-- compiles oxc-resolver@11.24.2 +| `-- bundles tsdown@0.22.14 +|-- depends on vitest@4.1.10 +|-- depends on oxlint@1.76.0 +|-- depends on oxlint-tsgolint@7.0.2001 +|-- depends on oxfmt@0.61.0 +`-- compiles vite-task@0.0.0 (5c1d02c750ac21c6f4cf0528062590a145e87fd1) +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_full_local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_full_local.md new file mode 100644 index 0000000000..7167c7cefc --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_full_local.md @@ -0,0 +1,25 @@ +# toolchain_full_local + +The local CLI reads the manifest shipped in its vite-plus package. + +## `vp toolchain` + +``` +Vite+ toolchain (local) + +vite-plus@0.2.7 +|-- depends on @voidzero-dev/vite-plus-core@ +| |-- bundles vite@8.2.0 +| | `-- uses rolldown@1.2.2 +| | |-- compiles oxc@0.142.0 +| | `-- compiles oxc-resolver@11.24.2 +| |-- bundles rolldown@1.2.2 +| | |-- compiles oxc@0.142.0 +| | `-- compiles oxc-resolver@11.24.2 +| `-- bundles tsdown@0.22.14 +|-- depends on vitest@4.1.10 +|-- depends on oxlint@1.76.0 +|-- depends on oxlint-tsgolint@7.0.2001 +|-- depends on oxfmt@0.61.0 +`-- compiles vite-task@0.0.0 (5c1d02c750ac21c6f4cf0528062590a145e87fd1) +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_global_from_local_cli.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_global_from_local_cli.md new file mode 100644 index 0000000000..96603d45f1 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_global_from_local_cli.md @@ -0,0 +1,11 @@ +# toolchain_global_from_local_cli + +## `vp toolchain --global` + +the local package leaves global selection to the global vp binary + +**Exit code:** 1 + +``` +error: `--global` is only supported by the globally installed `vp` CLI +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_json.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_json.md new file mode 100644 index 0000000000..01e80dd515 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_json.md @@ -0,0 +1,108 @@ +# toolchain_json + +## `vp toolchain vite --json --global` + +JSON contains one node per ID and no human header + +``` +{ + "schemaVersion": 1, + "source": { + "scope": "global", + "path": "/.vite-plus/current/node_modules/vite-plus", + "vitePlusVersion": "0.2.7" + }, + "nodes": [ + { + "id": "vite-plus", + "name": "vite-plus", + "version": "0.2.7", + "kind": "package", + "delivery": [ + "dependency" + ], + "aliases": [] + }, + { + "id": "vite-plus-core", + "name": "@voidzero-dev/vite-plus-core", + "version": "0.2.7", + "kind": "package", + "delivery": [ + "dependency" + ], + "aliases": [ + "vite-plus-core" + ] + }, + { + "id": "vite", + "name": "vite", + "version": "8.2.0", + "kind": "tool", + "delivery": [ + "bundled" + ], + "aliases": [] + }, + { + "id": "rolldown", + "name": "rolldown", + "version": "1.2.2", + "kind": "tool", + "delivery": [ + "bundled", + "compiled" + ], + "aliases": [] + }, + { + "id": "oxc", + "name": "oxc", + "version": "0.142.0", + "kind": "engine", + "delivery": [ + "compiled" + ], + "aliases": [] + }, + { + "id": "oxc-resolver", + "name": "oxc-resolver", + "version": "11.24.2", + "kind": "engine", + "delivery": [ + "compiled" + ], + "aliases": [] + } + ], + "edges": [ + { + "from": "vite-plus", + "to": "vite-plus-core", + "relationship": "depends-on" + }, + { + "from": "vite-plus-core", + "to": "vite", + "relationship": "bundles" + }, + { + "from": "vite", + "to": "rolldown", + "relationship": "uses" + }, + { + "from": "rolldown", + "to": "oxc", + "relationship": "compiles" + }, + { + "from": "rolldown", + "to": "oxc-resolver", + "relationship": "compiles" + } + ] +} +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_old_local_routing.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_old_local_routing.md new file mode 100644 index 0000000000..788286b6a9 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_old_local_routing.md @@ -0,0 +1,47 @@ +# toolchain_old_local_routing + +## `vpt mkdir -p node_modules/vite-plus/dist` + + +## `vpt write-file node_modules/vite-plus/package.json '{"name":"vite-plus","version":"0.1.0"} +'` + + +## `vpt write-file node_modules/vite-plus/dist/bin.js 'console.error("error: Command '\''toolchain'\'' not found"); +process.exitCode = 2; +'` + + +## `vp toolchain` + +the global binary delegates and lets an old local CLI reject the command + +**Exit code:** 2 + +``` +error: Command 'toolchain' not found +``` + +## `vp toolchain --global` + +--global skips the old local package + +``` +Vite+ toolchain (global) + +vite-plus@0.2.7 +|-- depends on @voidzero-dev/vite-plus-core@ +| |-- bundles vite@8.2.0 +| | `-- uses rolldown@1.2.2 +| | |-- compiles oxc@0.142.0 +| | `-- compiles oxc-resolver@11.24.2 +| |-- bundles rolldown@1.2.2 +| | |-- compiles oxc@0.142.0 +| | `-- compiles oxc-resolver@11.24.2 +| `-- bundles tsdown@0.22.14 +|-- depends on vitest@4.1.10 +|-- depends on oxlint@1.76.0 +|-- depends on oxlint-tsgolint@7.0.2001 +|-- depends on oxfmt@0.61.0 +`-- compiles vite-task@0.0.0 (5c1d02c750ac21c6f4cf0528062590a145e87fd1) +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_unknown.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_unknown.md new file mode 100644 index 0000000000..97fdd1dad1 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_unknown.md @@ -0,0 +1,22 @@ +# toolchain_unknown + +## `vp toolchain rollup` + +human output points back to the package graph + +**Exit code:** 1 + +``` +error: `rollup` is not part of the Vite+ toolchain manifest +hint: run `vp why rollup` to inspect project dependencies +``` + +## `vp toolchain rollup --json` + +JSON mode omits the human hint + +**Exit code:** 1 + +``` +error: `rollup` is not part of the Vite+ toolchain manifest +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_why_hint.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_why_hint.global.md new file mode 100644 index 0000000000..b6cb293123 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_why_hint.global.md @@ -0,0 +1,18 @@ +# toolchain_why_hint + +## `vp why vite` + +a successful human package query points to toolchain inspection + +``` + +Vite+ also provides vite@8.2.0 through its toolchain. +Run `vp toolchain vite` to inspect bundled versions and relationships. +``` + +## `vp why vite --json` + +machine-readable package-manager output has no hint + +``` +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_why_hint.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_why_hint.local.md new file mode 100644 index 0000000000..b6cb293123 --- /dev/null +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_why_hint.local.md @@ -0,0 +1,18 @@ +# toolchain_why_hint + +## `vp why vite` + +a successful human package query points to toolchain inspection + +``` + +Vite+ also provides vite@8.2.0 through its toolchain. +Run `vp toolchain vite` to inspect bundled versions and relationships. +``` + +## `vp why vite --json` + +machine-readable package-manager output has no hint + +``` +``` diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_vp_alias/snapshots/command_vp_alias.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_vp_alias/snapshots/command_vp_alias.md index c9202244ec..de2c14d94f 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_vp_alias/snapshots/command_vp_alias.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/command_vp_alias/snapshots/command_vp_alias.md @@ -25,6 +25,7 @@ Core Commands: cache Manage the task cache config Configure hooks and agent integration staged Run linters on staged files + toolchain Show versions and relationships in the Vite+ toolchain Package Manager Commands: install Install all dependencies, or add packages if package names are provided diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.global.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.global.md index fc51e24490..0cfed68651 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.global.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.global.md @@ -50,6 +50,9 @@ Manage Dependencies: rebuild Rebuild native modules pm Forward a command to the package manager +Inspect: + toolchain Show versions and relationships in the active Vite+ toolchain + Maintain: upgrade Update vp itself to the latest version implode Remove vp and all related data diff --git a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.local.md b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.local.md index 166ac74621..4a400c9a47 100644 --- a/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.local.md +++ b/crates/vite_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.local.md @@ -25,6 +25,7 @@ Core Commands: cache Manage the task cache config Configure hooks and agent integration staged Run linters on staged files + toolchain Show versions and relationships in the Vite+ toolchain Package Manager Commands: install Install all dependencies, or add packages if package names are provided diff --git a/crates/vite_global_cli/Cargo.toml b/crates/vite_global_cli/Cargo.toml index 5aea1bc6c2..aef29d84df 100644 --- a/crates/vite_global_cli/Cargo.toml +++ b/crates/vite_global_cli/Cargo.toml @@ -39,6 +39,7 @@ vite_command = { workspace = true } vite_setup = { workspace = true } vite_shared = { workspace = true } vite_str = { workspace = true } +vite_toolchain = { workspace = true } vite_workspace = { workspace = true } uuid = { workspace = true, features = ["v4"] } diff --git a/crates/vite_global_cli/src/cli.rs b/crates/vite_global_cli/src/cli.rs index aa9d622977..2a29d64b4f 100644 --- a/crates/vite_global_cli/src/cli.rs +++ b/crates/vite_global_cli/src/cli.rs @@ -195,6 +195,21 @@ pub enum Commands { args: Vec, }, + /// Show versions and relationships in the active Vite+ toolchain + Toolchain { + /// Filter by tool or package name + #[arg(value_name = "TOOLS")] + tools: Vec, + + /// Output the toolchain graph as JSON + #[arg(long)] + json: bool, + + /// Inspect the global Vite+ toolchain + #[arg(long)] + global: bool, + }, + /// Manage Node.js versions Env(EnvArgs), @@ -246,6 +261,7 @@ impl Commands { pub fn is_quiet_or_machine_readable(&self) -> bool { match self { Self::PackageManager(pm) => pm.is_quiet_or_machine_readable(), + Self::Toolchain { json, .. } => *json, Self::Upgrade { silent, .. } => *silent, Self::Env(args) => { args.command.as_ref().is_some_and(|sub| sub.is_quiet_or_machine_readable()) @@ -565,6 +581,7 @@ async fn run_package_manager_command( cwd: AbsolutePathBuf, command: PackageManagerCommand, ) -> Result { + let why_hint_packages = command.why_hint_packages().map(<[String]>::to_vec); match command.managed_global_command() { Some(ManagedGlobalCommand::Install { packages, node, force, concurrency }) => { return managed_install(packages, node, force, concurrency).await; @@ -611,7 +628,27 @@ async fn run_package_manager_command( } commands::prepend_js_runtime_to_path_env(&cwd).await?; - Ok(vite_pm_cli::dispatch(&cwd, command).await?) + let status = vite_pm_cli::dispatch(&cwd, command).await?; + if status.success() + && let Some(packages) = why_hint_packages + && let Some(manifest) = active_toolchain_manifest(&cwd) + && let Some(hint) = vite_toolchain::why_hint(&manifest, &packages) + { + output::raw_stderr(""); + output::raw_stderr(&hint); + } + Ok(status) +} + +fn active_toolchain_manifest(cwd: &vite_path::AbsolutePath) -> Option { + let manifest_path = if let Some(package_dir) = + crate::js_executor::JsExecutor::resolve_local_vite_plus_package_dir(cwd) + { + package_dir.join("dist").join("toolchain.json") + } else { + crate::js_executor::JsExecutor::new(None).get_scripts_dir().ok()?.join("toolchain.json") + }; + vite_toolchain::load_manifest(&manifest_path).ok() } async fn managed_install( @@ -1033,6 +1070,10 @@ pub async fn run_command_with_options( commands::delegate::execute(cwd, "cache", &args, raw_subcommand).await } + Commands::Toolchain { tools, json, global } => { + commands::toolchain::execute(cwd, tools, json, global, raw_subcommand).await + } + Commands::Env(args) => commands::env::execute(cwd, args).await, // Self-Management diff --git a/crates/vite_global_cli/src/command_picker.rs b/crates/vite_global_cli/src/command_picker.rs index 590f36c941..e99fd5d799 100644 --- a/crates/vite_global_cli/src/command_picker.rs +++ b/crates/vite_global_cli/src/command_picker.rs @@ -106,6 +106,12 @@ const COMMANDS: &[CommandEntry] = &[ summary: "Manage Node.js versions.", append_help: false, }, + CommandEntry { + label: "toolchain", + command: "toolchain", + summary: "Inspect Vite+ tool versions and relationships.", + append_help: false, + }, CommandEntry { label: "help", command: "help", diff --git a/crates/vite_global_cli/src/commands/mod.rs b/crates/vite_global_cli/src/commands/mod.rs index 18679f0de6..9c9b6643f8 100644 --- a/crates/vite_global_cli/src/commands/mod.rs +++ b/crates/vite_global_cli/src/commands/mod.rs @@ -93,6 +93,7 @@ pub mod config; pub mod create; pub mod migrate; pub mod staged; +pub mod toolchain; pub mod version; // Category D: Environment Management diff --git a/crates/vite_global_cli/src/commands/toolchain.rs b/crates/vite_global_cli/src/commands/toolchain.rs new file mode 100644 index 0000000000..753e40b259 --- /dev/null +++ b/crates/vite_global_cli/src/commands/toolchain.rs @@ -0,0 +1,57 @@ +use std::process::ExitStatus; + +use vite_path::AbsolutePathBuf; + +use crate::{commands::delegate, error::Error, js_executor::JsExecutor}; + +pub async fn execute( + cwd: AbsolutePathBuf, + tools: Vec, + json: bool, + global: bool, + raw_subcommand: Option<&str>, +) -> Result { + if !global && JsExecutor::resolve_local_vite_plus_package_dir(&cwd).is_some() { + let mut args = tools; + if json { + args.push("--json".to_string()); + } + return delegate::execute(cwd, "toolchain", &args, raw_subcommand).await; + } + + let scripts_dir = JsExecutor::new(None).get_scripts_dir()?; + let package_dir = scripts_dir.parent().ok_or(Error::JsScriptsDirNotFound)?; + let manifest_path = scripts_dir.join("toolchain.json"); + let manifest = vite_toolchain::load_manifest(&manifest_path)?; + let version = vite_toolchain::root_version(&manifest) + .ok_or_else(|| Error::Other("toolchain manifest does not contain vite-plus".into()))?; + let source = vite_toolchain::Source { + scope: vite_toolchain::Scope::Global, + path: package_dir.as_path().to_string_lossy().into_owned().into(), + vite_plus_version: version.into(), + }; + let report = match vite_toolchain::build_report(&manifest, &tools, source) { + Ok(report) => report, + Err(vite_toolchain::ToolchainError::UnknownFilter(filter)) => { + let message = format!("`{filter}` is not part of the Vite+ toolchain manifest"); + if json { + vite_shared::output::raw_stderr(&format!("error: {message}")); + } else { + vite_shared::output::error(&message); + vite_shared::output::raw_stderr(&format!( + "hint: run `vp why {filter}` to inspect project dependencies" + )); + } + return Ok(crate::cli::exit_status(1)); + } + Err(error) => return Err(error.into()), + }; + + let rendered = if json { + vite_toolchain::render_json(&report)? + } else { + vite_toolchain::render_human(&report) + }; + vite_shared::output::raw_inline(&rendered); + Ok(ExitStatus::default()) +} diff --git a/crates/vite_global_cli/src/commands/version.rs b/crates/vite_global_cli/src/commands/version.rs index 0a6cd2bc1e..f069db7e4b 100644 --- a/crates/vite_global_cli/src/commands/version.rs +++ b/crates/vite_global_cli/src/commands/version.rs @@ -1,7 +1,6 @@ //! Version command. use std::{ - collections::BTreeMap, fs, path::{Path, PathBuf}, process::ExitStatus, @@ -19,8 +18,6 @@ use crate::{commands::env::config::resolve_version, error::Error, help}; #[serde(rename_all = "camelCase")] struct PackageJson { version: String, - #[serde(default)] - bundled_versions: BTreeMap, } #[derive(Debug)] @@ -29,39 +26,6 @@ struct LocalVitePlus { package_dir: PathBuf, } -#[derive(Debug, Clone, Copy)] -struct ToolSpec { - display_name: &'static str, - package_name: &'static str, - bundled_version_key: Option<&'static str>, -} - -const TOOL_SPECS: [ToolSpec; 7] = [ - ToolSpec { - display_name: "vite", - package_name: "@voidzero-dev/vite-plus-core", - bundled_version_key: Some("vite"), - }, - ToolSpec { - display_name: "rolldown", - package_name: "@voidzero-dev/vite-plus-core", - bundled_version_key: Some("rolldown"), - }, - ToolSpec { display_name: "vitest", package_name: "vitest", bundled_version_key: None }, - ToolSpec { display_name: "oxfmt", package_name: "oxfmt", bundled_version_key: None }, - ToolSpec { display_name: "oxlint", package_name: "oxlint", bundled_version_key: None }, - ToolSpec { - display_name: "oxlint-tsgolint", - package_name: "oxlint-tsgolint", - bundled_version_key: None, - }, - ToolSpec { - display_name: "tsdown", - package_name: "@voidzero-dev/vite-plus-core", - bundled_version_key: Some("tsdown"), - }, -]; - const NOT_FOUND: &str = "Not found"; fn read_package_json(package_json_path: &Path) -> Option { @@ -85,26 +49,10 @@ fn find_local_vite_plus(start: &Path) -> Option { None } -fn resolve_package_json(base_dir: &Path, package_name: &str) -> Option { - let mut current = Some(base_dir); - while let Some(dir) = current { - let package_json_path = dir.join("node_modules").join(package_name).join("package.json"); - if let Some(pkg) = read_package_json(&package_json_path) { - return Some(pkg); - } - current = dir.parent(); - } - None -} - -fn resolve_tool_version(local: &LocalVitePlus, tool: ToolSpec) -> Option { - let pkg = resolve_package_json(&local.package_dir, tool.package_name)?; - if let Some(key) = tool.bundled_version_key - && let Some(version) = pkg.bundled_versions.get(key) - { - return Some(version.clone()); - } - Some(pkg.version) +fn read_toolchain_manifest(local: &LocalVitePlus) -> Option { + let manifest_path = local.package_dir.join("dist").join("toolchain.json"); + let manifest_path = vite_path::AbsolutePath::new(&manifest_path)?; + vite_toolchain::load_manifest(manifest_path).ok() } fn accent(text: &str) -> String { @@ -170,12 +118,15 @@ pub async fn execute(cwd: AbsolutePathBuf) -> Result { ); println!(); - let tool_rows = TOOL_SPECS + let manifest = local.as_ref().and_then(read_toolchain_manifest); + let tool_rows = vite_toolchain::VERSION_SUMMARY_IDS .iter() - .map(|tool| { - let version = - local.as_ref().and_then(|local_pkg| resolve_tool_version(local_pkg, *tool)); - (tool.display_name, format_version(version)) + .map(|id| { + let version = manifest + .as_ref() + .and_then(|manifest| vite_toolchain::node_by_id(manifest, id)) + .map(|node| node.version.to_string()); + (*id, format_version(version)) }) .collect::>(); print_rows("Tools", &tool_rows); @@ -220,9 +171,9 @@ mod tests { use serial_test::serial; - #[cfg(unix)] - use super::{ToolSpec, find_local_vite_plus, resolve_tool_version}; use super::{detect_system_node_version, format_version}; + #[cfg(unix)] + use super::{find_local_vite_plus, read_toolchain_manifest}; #[cfg(unix)] fn symlink_dir(src: &Path, dst: &Path) { @@ -250,21 +201,38 @@ mod tests { #[cfg(unix)] #[test] - fn resolves_tool_versions_from_pnpm_symlink_layout() { + fn resolves_toolchain_manifest_from_pnpm_symlink_layout() { let temp = tempfile::tempdir().unwrap(); let project = temp.path(); let pnpm_pkg_dir = project.join("node_modules/.pnpm/vite-plus@1.0.0/node_modules/vite-plus"); - fs::create_dir_all(&pnpm_pkg_dir).unwrap(); + fs::create_dir_all(pnpm_pkg_dir.join("dist")).unwrap(); fs::write(pnpm_pkg_dir.join("package.json"), r#"{"version":"1.0.0"}"#).unwrap(); - - let core_pkg_dir = project - .join("node_modules/.pnpm/vite-plus@1.0.0/node_modules/@voidzero-dev/vite-plus-core"); - fs::create_dir_all(&core_pkg_dir).unwrap(); fs::write( - core_pkg_dir.join("package.json"), - r#"{"version":"1.0.0","bundledVersions":{"vite":"8.0.0"}}"#, + pnpm_pkg_dir.join("dist/toolchain.json"), + r#"{ + "schemaVersion": 1, + "nodes": [ + { + "id": "vite-plus", + "name": "vite-plus", + "version": "1.0.0", + "kind": "package", + "delivery": ["dependency"], + "aliases": [] + }, + { + "id": "vite", + "name": "vite", + "version": "8.0.0", + "kind": "tool", + "delivery": ["bundled"], + "aliases": [] + } + ], + "edges": [] + }"#, ) .unwrap(); @@ -276,12 +244,10 @@ mod tests { ); let local = find_local_vite_plus(project).expect("expected local vite-plus to resolve"); - let tool = ToolSpec { - display_name: "vite", - package_name: "@voidzero-dev/vite-plus-core", - bundled_version_key: Some("vite"), - }; - let resolved = resolve_tool_version(&local, tool); - assert_eq!(resolved.as_deref(), Some("8.0.0")); + let manifest = read_toolchain_manifest(&local).expect("expected manifest to resolve"); + assert_eq!( + vite_toolchain::node_by_id(&manifest, "vite").map(|node| node.version.as_str()), + Some("8.0.0") + ); } } diff --git a/crates/vite_global_cli/src/error.rs b/crates/vite_global_cli/src/error.rs index 4ba423a3d9..866ee53a96 100644 --- a/crates/vite_global_cli/src/error.rs +++ b/crates/vite_global_cli/src/error.rs @@ -57,6 +57,9 @@ pub enum Error { #[error(transparent)] PmCli(#[from] vite_pm_cli::Error), + + #[error(transparent)] + Toolchain(#[from] vite_toolchain::ToolchainError), } impl Error { diff --git a/crates/vite_global_cli/src/help.rs b/crates/vite_global_cli/src/help.rs index f244653fd6..c8b23fc7ba 100644 --- a/crates/vite_global_cli/src/help.rs +++ b/crates/vite_global_cli/src/help.rs @@ -47,6 +47,7 @@ fn documentation_url_for_command_path(command_path: &[&str]) -> Option<&'static ] => Some("https://viteplus.dev/guide/install"), ["dlx"] => Some("https://viteplus.dev/guide/vpx"), ["env", ..] => Some("https://viteplus.dev/guide/env"), + ["toolchain"] => Some("https://viteplus.dev/guide/upgrade"), ["upgrade"] => Some("https://viteplus.dev/guide/upgrade"), ["implode"] => Some("https://viteplus.dev/guide/implode"), _ => None, @@ -440,6 +441,13 @@ pub fn top_level_help_doc() -> HelpDoc { row("pm", "Forward a command to the package manager"), ], ), + section_rows( + "Inspect", + vec![row( + "toolchain", + "Show versions and relationships in the active Vite+ toolchain", + )], + ), section_rows( "Maintain", vec![ diff --git a/crates/vite_global_cli/src/js_executor.rs b/crates/vite_global_cli/src/js_executor.rs index 9cdba333de..f401e09132 100644 --- a/crates/vite_global_cli/src/js_executor.rs +++ b/crates/vite_global_cli/src/js_executor.rs @@ -381,8 +381,10 @@ impl JsExecutor { Ok(output) } - /// Resolve the local vite-plus package's `dist/bin.js` from the project directory. - fn resolve_local_vite_plus(project_path: &AbsolutePath) -> Option { + /// Resolve the local vite-plus package root from the project directory. + pub(crate) fn resolve_local_vite_plus_package_dir( + project_path: &AbsolutePath, + ) -> Option { use oxc_resolver::{ResolveOptions, Resolver}; let resolver = Resolver::new(ResolveOptions { @@ -393,11 +395,17 @@ impl JsExecutor { // Resolve vite-plus/package.json from the project directory to find the package root let resolved = resolver.resolve(project_path, "vite-plus/package.json").ok()?; let pkg_dir = resolved.path().parent()?; + AbsolutePathBuf::new(pkg_dir.to_path_buf()) + } + + /// Resolve the local vite-plus package's `dist/bin.js` from the project directory. + fn resolve_local_vite_plus(project_path: &AbsolutePath) -> Option { + let pkg_dir = Self::resolve_local_vite_plus_package_dir(project_path)?; let bin_js = pkg_dir.join("dist").join("bin.js"); - if bin_js.exists() { + if bin_js.as_path().exists() { tracing::debug!("Found local vite-plus at {:?}", bin_js); - AbsolutePathBuf::new(bin_js) + Some(bin_js) } else { tracing::debug!("Local vite-plus found but dist/bin.js missing at {:?}", bin_js); None @@ -407,14 +415,8 @@ impl JsExecutor { /// Resolve the version of the project-local `vite-plus`, if one is installed. fn resolve_local_vite_plus_version(project_path: &AbsolutePath) -> Option { - use oxc_resolver::{ResolveOptions, Resolver}; - - let resolver = Resolver::new(ResolveOptions { - condition_names: vec!["import".into(), "node".into()], - ..ResolveOptions::default() - }); - let resolved = resolver.resolve(project_path, "vite-plus/package.json").ok()?; - read_package_json_version(resolved.path()) + let package_dir = JsExecutor::resolve_local_vite_plus_package_dir(project_path)?; + read_package_json_version(package_dir.join("package.json")) } /// Read the top-level `version` string from a package.json. Returns `None` when diff --git a/crates/vite_pm_cli/src/cli.rs b/crates/vite_pm_cli/src/cli.rs index 8734c261b6..675d8ec72e 100644 --- a/crates/vite_pm_cli/src/cli.rs +++ b/crates/vite_pm_cli/src/cli.rs @@ -314,6 +314,17 @@ impl PackageManagerCommand { _ => None, } } + + /// Package names eligible for the Vite+ toolchain discovery hint. + /// + /// Machine-readable `why` output must remain package-manager output only. + #[must_use] + pub fn why_hint_packages(&self) -> Option<&[String]> { + match self { + Self::Why(args) if !args.json && !args.parseable => Some(&args.packages), + _ => None, + } + } } impl PmCommand { @@ -609,6 +620,15 @@ mod tests { assert!(!parse(&["install"]).unwrap().is_quiet_or_machine_readable()); } + #[test] + fn why_hint_packages_excludes_machine_readable_output() { + let human = parse(&["why", "vite"]).unwrap(); + assert_eq!(human.why_hint_packages(), Some(["vite".to_string()].as_slice())); + + assert_eq!(parse(&["why", "vite", "--json"]).unwrap().why_hint_packages(), None); + assert_eq!(parse(&["why", "vite", "--parseable"]).unwrap().why_hint_packages(), None); + } + #[test] fn suppresses_diagnostics_only_for_explicit_silent_modes() { for args in [ diff --git a/crates/vite_toolchain/Cargo.toml b/crates/vite_toolchain/Cargo.toml new file mode 100644 index 0000000000..16c043c7f2 --- /dev/null +++ b/crates/vite_toolchain/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "vite_toolchain" +version = "0.0.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +publish = false +rust-version.workspace = true + +[dependencies] +rustc-hash = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +vite_path = { workspace = true } +vite_str = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/crates/vite_toolchain/src/lib.rs b/crates/vite_toolchain/src/lib.rs new file mode 100644 index 0000000000..23392f8857 --- /dev/null +++ b/crates/vite_toolchain/src/lib.rs @@ -0,0 +1,553 @@ +use std::{collections::VecDeque, fs}; + +use rustc_hash::{FxHashMap, FxHashSet}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use vite_path::AbsolutePath; +use vite_str::Str; + +pub const VERSION_SUMMARY_IDS: [&str; 7] = + ["vite", "rolldown", "vitest", "oxfmt", "oxlint", "oxlint-tsgolint", "tsdown"]; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Manifest { + pub schema_version: u32, + pub nodes: Vec, + pub edges: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Node { + pub id: Str, + pub name: Str, + pub version: Str, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub revision: Option, + pub kind: NodeKind, + pub delivery: Vec, + #[serde(default)] + pub aliases: Vec, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum NodeKind { + Package, + Tool, + Engine, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum Delivery { + Dependency, + Bundled, + Compiled, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Edge { + pub from: Str, + pub to: Str, + pub relationship: Relationship, +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum Relationship { + DependsOn, + Bundles, + Uses, + Compiles, +} + +impl Relationship { + fn display(self) -> &'static str { + match self { + Self::DependsOn => "depends on", + Self::Bundles => "bundles", + Self::Uses => "uses", + Self::Compiles => "compiles", + } + } + + fn is_downstream_engine_relationship(self) -> bool { + matches!(self, Self::Uses | Self::Compiles) + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Scope { + Local, + Global, +} + +impl Scope { + pub const fn display(self) -> &'static str { + match self { + Self::Local => "local", + Self::Global => "global", + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Source { + pub scope: Scope, + pub path: Str, + pub vite_plus_version: Str, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Report { + pub schema_version: u32, + pub source: Source, + pub nodes: Vec, + pub edges: Vec, +} + +#[derive(Debug, Error)] +pub enum ToolchainError { + #[error("failed to read toolchain manifest at {path}: {source}")] + Read { path: Box, source: std::io::Error }, + #[error("failed to parse toolchain manifest at {path}: {source}")] + Parse { path: Box, source: serde_json::Error }, + #[error("unsupported toolchain manifest schema version {0}")] + UnsupportedSchema(u32), + #[error("invalid toolchain manifest: {0}")] + InvalidManifest(Str), + #[error("`{0}` is not part of the Vite+ toolchain manifest")] + UnknownFilter(Str), + #[error("failed to serialize toolchain report: {0}")] + Serialize(#[from] serde_json::Error), +} + +pub fn load_manifest(path: &AbsolutePath) -> Result { + let content = fs::read_to_string(path) + .map_err(|source| ToolchainError::Read { path: path.into(), source })?; + let manifest = serde_json::from_str(&content) + .map_err(|source| ToolchainError::Parse { path: path.into(), source })?; + validate_manifest(&manifest)?; + Ok(manifest) +} + +pub fn validate_manifest(manifest: &Manifest) -> Result<(), ToolchainError> { + if manifest.schema_version != 1 { + return Err(ToolchainError::UnsupportedSchema(manifest.schema_version)); + } + + let mut ids = FxHashSet::default(); + let mut labels = FxHashMap::default(); + for node in &manifest.nodes { + if node.id.is_empty() || node.name.is_empty() || node.version.is_empty() { + return Err(ToolchainError::InvalidManifest( + "node IDs, names, and versions must not be empty".into(), + )); + } + if !ids.insert(node.id.as_str()) { + return Err(ToolchainError::InvalidManifest(vite_str::format!( + "duplicate node ID `{}`", + node.id + ))); + } + for label in std::iter::once(node.id.as_str()) + .chain(std::iter::once(node.name.as_str())) + .chain(node.aliases.iter().map(Str::as_str)) + { + if label.is_empty() { + return Err(ToolchainError::InvalidManifest(vite_str::format!( + "node `{}` has an empty name or alias", + node.id + ))); + } + if let Some(existing_node) = labels.insert(label, node.id.as_str()) + && existing_node != node.id.as_str() + { + return Err(ToolchainError::InvalidManifest(vite_str::format!( + "filter label `{label}` is shared by `{existing_node}` and `{}`", + node.id + ))); + } + } + } + + for edge in &manifest.edges { + if !ids.contains(edge.from.as_str()) || !ids.contains(edge.to.as_str()) { + return Err(ToolchainError::InvalidManifest(vite_str::format!( + "edge references an unknown node: {} -> {}", + edge.from, + edge.to + ))); + } + } + + Ok(()) +} + +pub fn node_by_id<'a>(manifest: &'a Manifest, id: &str) -> Option<&'a Node> { + manifest.nodes.iter().find(|node| node.id == id) +} + +pub fn root_version(manifest: &Manifest) -> Option<&str> { + node_by_id(manifest, "vite-plus").map(|node| node.version.as_str()) +} + +pub fn build_report>( + manifest: &Manifest, + filters: &[T], + source: Source, +) -> Result { + validate_manifest(manifest)?; + let filtered = filter_manifest(manifest, filters)?; + Ok(Report { + schema_version: filtered.schema_version, + source, + nodes: filtered.nodes, + edges: filtered.edges, + }) +} + +pub fn filter_manifest>( + manifest: &Manifest, + filters: &[T], +) -> Result { + if filters.is_empty() { + return Ok(manifest.clone()); + } + + let node_indices = manifest + .nodes + .iter() + .enumerate() + .map(|(index, node)| (node.id.as_str(), index)) + .collect::>(); + let mut names = FxHashMap::default(); + for (index, node) in manifest.nodes.iter().enumerate() { + names.insert(node.id.as_str(), index); + names.insert(node.name.as_str(), index); + for alias in &node.aliases { + names.insert(alias.as_str(), index); + } + } + + let mut keep_nodes = vec![false; manifest.nodes.len()]; + let mut keep_edges = vec![false; manifest.edges.len()]; + for filter in filters { + let filter = filter.as_ref(); + let Some(&matched) = names.get(filter) else { + return Err(ToolchainError::UnknownFilter(filter.into())); + }; + keep_nodes[matched] = true; + + let mut ancestors = VecDeque::from([matched]); + while let Some(node_index) = ancestors.pop_front() { + let node_id = manifest.nodes[node_index].id.as_str(); + for (edge_index, edge) in manifest.edges.iter().enumerate() { + if edge.to != node_id { + continue; + } + keep_edges[edge_index] = true; + let parent = node_indices[edge.from.as_str()]; + if !keep_nodes[parent] { + keep_nodes[parent] = true; + ancestors.push_back(parent); + } + } + } + + let mut downstream = VecDeque::from([matched]); + while let Some(node_index) = downstream.pop_front() { + let node_id = manifest.nodes[node_index].id.as_str(); + for (edge_index, edge) in manifest.edges.iter().enumerate() { + if edge.from != node_id || !edge.relationship.is_downstream_engine_relationship() { + continue; + } + keep_edges[edge_index] = true; + let child = node_indices[edge.to.as_str()]; + if !keep_nodes[child] { + keep_nodes[child] = true; + downstream.push_back(child); + } + } + } + } + + Ok(Manifest { + schema_version: manifest.schema_version, + nodes: manifest + .nodes + .iter() + .enumerate() + .filter(|(index, _)| keep_nodes[*index]) + .map(|(_, node)| node.clone()) + .collect(), + edges: manifest + .edges + .iter() + .enumerate() + .filter(|(index, _)| keep_edges[*index]) + .map(|(_, edge)| edge.clone()) + .collect(), + }) +} + +pub fn render_human(report: &Report) -> Str { + let mut output = vite_str::format!("Vite+ toolchain ({})\n\n", report.source.scope.display()); + let nodes = + report.nodes.iter().map(|node| (node.id.as_str(), node)).collect::>(); + let mut children: FxHashMap<&str, Vec<&Edge>> = FxHashMap::default(); + let mut nodes_with_parents = FxHashSet::default(); + for edge in &report.edges { + children.entry(edge.from.as_str()).or_default().push(edge); + nodes_with_parents.insert(edge.to.as_str()); + } + + let roots = report + .nodes + .iter() + .filter(|node| !nodes_with_parents.contains(node.id.as_str())) + .collect::>(); + for (root_index, root) in roots.iter().enumerate() { + if root_index > 0 { + output.push('\n'); + } + output.push_str(&display_node(root)); + output.push('\n'); + let mut path = FxHashSet::from_iter([root.id.as_str()]); + render_children(&mut output, root.id.as_str(), "", &nodes, &children, &mut path); + } + output +} + +fn render_children<'a>( + output: &mut Str, + node_id: &'a str, + prefix: &str, + nodes: &FxHashMap<&'a str, &'a Node>, + children: &FxHashMap<&'a str, Vec<&'a Edge>>, + path: &mut FxHashSet<&'a str>, +) { + let Some(edges) = children.get(node_id) else { + return; + }; + for (index, edge) in edges.iter().enumerate() { + let is_last = index + 1 == edges.len(); + let connector = if is_last { "`-- " } else { "|-- " }; + let Some(node) = nodes.get(edge.to.as_str()) else { + continue; + }; + output.push_str(&vite_str::format!( + "{prefix}{connector}{} {}", + edge.relationship.display(), + display_node(node) + )); + output.push('\n'); + + if path.insert(node.id.as_str()) { + let child_prefix = + vite_str::format!("{prefix}{}", if is_last { " " } else { "| " }); + render_children(output, node.id.as_str(), child_prefix.as_str(), nodes, children, path); + path.remove(node.id.as_str()); + } + } +} + +fn display_node(node: &Node) -> Str { + match &node.revision { + Some(revision) => vite_str::format!("{}@{} ({revision})", node.name, node.version), + None => vite_str::format!("{}@{}", node.name, node.version), + } +} + +pub fn render_json(report: &Report) -> Result { + let mut output: Str = serde_json::to_string_pretty(report)?.into(); + output.push('\n'); + Ok(output) +} + +pub fn why_hint>(manifest: &Manifest, queries: &[T]) -> Option { + let mut matched_ids = FxHashSet::default(); + let mut matched_nodes = Vec::new(); + for query in queries { + let query = query.as_ref(); + let Some(node) = manifest.nodes.iter().find(|node| { + node.id == query + || node.name == query + || node.aliases.iter().any(|alias| alias == query) + }) else { + continue; + }; + if matched_ids.insert(node.id.as_str()) { + matched_nodes.push(node); + } + } + if matched_nodes.is_empty() { + return None; + } + + let mut provided = Str::default(); + let mut filters = Str::default(); + for (index, node) in matched_nodes.iter().enumerate() { + if index > 0 { + provided.push_str(", "); + filters.push(' '); + } + provided.push_str(&display_node(node)); + filters.push_str(node.name.as_str()); + } + Some(vite_str::format!( + "Vite+ also provides {provided} through its toolchain.\nRun `vp toolchain {filters}` to inspect bundled versions and relationships." + )) +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + fn manifest() -> Manifest { + serde_json::from_str( + r#"{ + "schemaVersion": 1, + "nodes": [ + {"id":"vite-plus","name":"vite-plus","version":"1.0.0","kind":"package","delivery":["dependency"],"aliases":[]}, + {"id":"core","name":"@scope/core","version":"1.0.0","kind":"package","delivery":["dependency"],"aliases":["core"]}, + {"id":"vite","name":"vite","version":"8.0.0","kind":"tool","delivery":["bundled"],"aliases":[]}, + {"id":"rolldown","name":"rolldown","version":"1.0.0","kind":"tool","delivery":["bundled","compiled"],"aliases":[]}, + {"id":"oxc","name":"oxc","version":"0.1.0","kind":"engine","delivery":["compiled"],"aliases":[]}, + {"id":"vitest","name":"vitest","version":"4.0.0","kind":"tool","delivery":["dependency"],"aliases":[]} + ], + "edges": [ + {"from":"vite-plus","to":"core","relationship":"depends-on"}, + {"from":"core","to":"vite","relationship":"bundles"}, + {"from":"vite","to":"rolldown","relationship":"uses"}, + {"from":"core","to":"rolldown","relationship":"bundles"}, + {"from":"rolldown","to":"oxc","relationship":"compiles"}, + {"from":"vite-plus","to":"vitest","relationship":"depends-on"} + ] + }"#, + ) + .unwrap() + } + + #[test] + fn vite_filter_keeps_ownership_and_engine_chain_without_unrelated_edges() { + let filtered = filter_manifest(&manifest(), &["vite"]).unwrap(); + assert_eq!( + filtered.nodes.iter().map(|node| node.id.as_str()).collect::>(), + ["vite-plus", "core", "vite", "rolldown", "oxc"] + ); + assert_eq!( + filtered + .edges + .iter() + .map(|edge| (edge.from.as_str(), edge.to.as_str())) + .collect::>(), + [("vite-plus", "core"), ("core", "vite"), ("vite", "rolldown"), ("rolldown", "oxc")] + ); + } + + #[test] + fn multiple_filters_form_a_stable_union() { + let filtered = filter_manifest(&manifest(), &["vite", "vitest"]).unwrap(); + assert_eq!( + filtered.nodes.iter().map(|node| node.id.as_str()).collect::>(), + ["vite-plus", "core", "vite", "rolldown", "oxc", "vitest"] + ); + } + + #[test] + fn aliases_match() { + let filtered = filter_manifest(&manifest(), &["core"]).unwrap(); + assert!(filtered.nodes.iter().any(|node| node.id == "core")); + } + + #[test] + fn validation_allows_a_nodes_alias_to_repeat_its_id() { + let mut manifest = manifest(); + manifest.nodes[1].aliases.push("core".into()); + validate_manifest(&manifest).unwrap(); + } + + #[test] + fn validation_rejects_filter_labels_shared_by_different_nodes() { + let mut manifest = manifest(); + manifest.nodes[5].aliases.push("vite".into()); + let error = validate_manifest(&manifest).unwrap_err(); + assert_eq!( + error.to_string(), + "invalid toolchain manifest: filter label `vite` is shared by `vite` and `vitest`" + ); + } + + #[test] + fn unknown_filter_is_an_error() { + let error = filter_manifest(&manifest(), &["rollup"]).unwrap_err(); + assert_eq!(error.to_string(), "`rollup` is not part of the Vite+ toolchain manifest"); + } + + #[test] + fn human_tree_repeats_shared_nodes_in_the_full_graph() { + let report = build_report( + &manifest(), + &[] as &[&str], + Source { + scope: Scope::Local, + path: "/project/node_modules/vite-plus".into(), + vite_plus_version: "1.0.0".into(), + }, + ) + .unwrap(); + assert_eq!( + render_human(&report), + "Vite+ toolchain (local)\n\ + \n\ + vite-plus@1.0.0\n\ + |-- depends on @scope/core@1.0.0\n\ + | |-- bundles vite@8.0.0\n\ + | | `-- uses rolldown@1.0.0\n\ + | | `-- compiles oxc@0.1.0\n\ + | `-- bundles rolldown@1.0.0\n\ + | `-- compiles oxc@0.1.0\n\ + `-- depends on vitest@4.0.0\n" + ); + } + + #[test] + fn why_hint_uses_all_matching_queries() { + assert_eq!( + why_hint(&manifest(), &["vite", "vitest"]).as_deref(), + Some( + "Vite+ also provides vite@8.0.0, vitest@4.0.0 through its toolchain.\n\ + Run `vp toolchain vite vitest` to inspect bundled versions and relationships." + ) + ); + } + + #[test] + fn why_hint_keeps_matching_queries() { + assert_eq!( + why_hint(&manifest(), &["vite", "react"]).as_deref(), + Some( + "Vite+ also provides vite@8.0.0 through its toolchain.\n\ + Run `vp toolchain vite` to inspect bundled versions and relationships." + ) + ); + } + + #[test] + fn manifest_round_trips_from_disk() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("toolchain.json"); + fs::write(&path, serde_json::to_string(&manifest()).unwrap()).unwrap(); + let path = vite_path::AbsolutePathBuf::new(path).unwrap(); + assert_eq!(load_manifest(&path).unwrap(), manifest()); + } +} diff --git a/docs/guide/index.md b/docs/guide/index.md index 1ee398444a..4827993edf 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -126,6 +126,7 @@ Vite+ can handle the entire local frontend development cycle from starting a pro ### Maintain +- [`vp toolchain`](/guide/upgrade#inspect-the-toolchain) shows the versions and relationships in the active Vite+ toolchain. - [`vp upgrade`](/guide/upgrade) updates the `vp` installation itself. - [`vp implode`](/guide/implode) removes `vp` and related Vite+ data from your machine. diff --git a/docs/guide/install.md b/docs/guide/install.md index 7eb21a015a..a796cfba38 100644 --- a/docs/guide/install.md +++ b/docs/guide/install.md @@ -149,6 +149,11 @@ Use these when you need to understand the current state of dependencies. - `vp why react` explains why `react` is installed - `vp info react` shows registry metadata such as versions and dist-tags +These commands report the package manager's installed graph. Use +`vp toolchain [tool]` for versions bundled or compiled into Vite+, including +Vite, Rolldown, and Oxc. A human-readable `vp why` query prints a hint when its +package name also belongs to the Vite+ toolchain. + #### Rebuild Use `vp rebuild` when native modules need to be recompiled, for example after switching Node.js versions or when a C/C++ addon fails to load. diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md index 68bc1ba79c..965ee720b1 100644 --- a/docs/guide/troubleshooting.md +++ b/docs/guide/troubleshooting.md @@ -15,6 +15,9 @@ Vite+ expects modern upstream tool versions. If you are migrating an existing project and it still depends on older Vite or Vitest versions, upgrade those first before adopting Vite+. +Run `vp toolchain` to see the versions selected by the local Vite+ package. +Use `vp toolchain --global` to inspect the global release. + ## `vp check` does not run type-aware lint rules or type checks - Confirm that `lint.options.typeAware` and `lint.options.typeCheck` are enabled in `vite.config.ts` @@ -106,7 +109,7 @@ If you are stuck, please reach out: When reporting a problem, please include: -- The full output of `vp env current` and `vp --version` +- The full output of `vp env current`, `vp --version`, and `vp toolchain` - The package manager used by the project - The exact steps needed to reproduce the problem and your `vite.config.ts` - A minimal reproduction repository or runnable sandbox diff --git a/docs/guide/upgrade.md b/docs/guide/upgrade.md index 647e865c9e..dbba7746a9 100644 --- a/docs/guide/upgrade.md +++ b/docs/guide/upgrade.md @@ -11,6 +11,30 @@ There are two parts to upgrading Vite+: You can upgrade both of them independently. +## Inspect the Toolchain + +Run `vp toolchain` to see the exact components selected for the current +directory: + +```bash +vp toolchain +vp toolchain vite +vp toolchain vite rolldown oxc +vp toolchain --json +``` + +The command uses the local `vite-plus` package when the project has one. Pass +`--global` to inspect the release paired with the global `vp` command: + +```bash +vp toolchain --global +``` + +`vp why ` still reports the package manager's installed dependency +graph. It cannot report code bundled into `@voidzero-dev/vite-plus-core` or +engines compiled into Vite+, so use `vp toolchain` for those versions and +relationships. + ## Global `vp` ```bash @@ -53,7 +77,7 @@ A Vite+ release can bump the bundled Vitest. Because that pin also applies to `v After upgrading `vite-plus`, re-pin `vitest` to the version Vite+ now bundles. Check that version with: ```bash -vp --version +vp toolchain vitest ``` Then set the `vitest` override to that exact version, or rerun `vp migrate` to update the pin for you. @@ -86,7 +110,7 @@ irm https://vite.plus/ps1 | iex Remove-Item Env:\VP_PR_VERSION ``` -The installer resolves the ref to its `0.0.0-commit.` build through the registry bridge and installs it like any other version. Run `vp --version` afterward to confirm which build and bundled tool versions are active. When you are done testing, return to the published release with `vp upgrade --force` or by running the installer again without `VP_PR_VERSION`. +The installer resolves the ref to its `0.0.0-commit.` build through the registry bridge and installs it like any other version. Run `vp toolchain --global` afterward to confirm which build and tool versions are active. When you are done testing, return to the published release with `vp upgrade --force` or by running the installer again without `VP_PR_VERSION`. ### Local `vite-plus` Preview @@ -98,4 +122,4 @@ vp migrate Migrate points the project at the bridge registry (writing it to `.npmrc`, or `.yarnrc.yml` for Yarn Berry) and pins `vite-plus` and the `vite` -> `@voidzero-dev/vite-plus-core` alias to the matching `0.0.0-commit.` version. That registry line is what lets the same versions resolve in the project's own CI, so commit it if you want CI to test the preview too. -After installing, check the bundled versions with `vp --version`. When testing is complete, restore the published release: set `vite-plus` back to `latest`, remove the bridge `registry` line from `.npmrc` (or `.yarnrc.yml`), and reinstall with `vp install`. +After installing, check the selected versions with `vp toolchain`. When testing is complete, restore the published release: set `vite-plus` back to `latest`, remove the bridge `registry` line from `.npmrc` (or `.yarnrc.yml`), and reinstall with `vp install`. diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 693c7933c9..145babdb50 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -10,6 +10,13 @@ Docs are local at `node_modules/vite-plus/docs` or online at https://viteplus.de `vp ` runs a built-in command. `vp run ` runs a `package.json` script or a `vite.config.ts` task. Scripts cannot overwrite built-ins, so `vp dev` and `vp run dev` may do different things. Check `package.json` and `vite.config.ts` first, and run `vp run ` when the project defines a script or task with that name. +## Inspecting Tool Versions + +Run `vp toolchain` to inspect the versions and relationships in the active +Vite+ release. Add a tool name such as `vp toolchain vite` to filter the graph, +or pass `--global` to ignore the project's local `vite-plus` package. Use +`vp why ` for the package manager's installed dependency graph. + ## Review Checklist - [ ] Run `vp install` after pulling remote changes and before getting started. diff --git a/packages/cli/README.md b/packages/cli/README.md index 56f6bd17f4..22d94bd400 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -17,6 +17,7 @@ Vite+ is the unified entry point for local web development. It combines [Vite](h - **`vp build`:** Build applications for production with Vite + Rolldown - **`vp run`:** Run `package.json` scripts and monorepo tasks with caching and dependency-aware scheduling - **`vp pack`:** Build libraries for npm publishing or standalone app binaries +- **`vp toolchain`:** Inspect the exact Vite+, Vite, Rolldown, Oxc, and other tool versions in use - **`vp create` / `vp migrate`:** Scaffold new projects and migrate existing ones All of this is configured from your project root and works across Vite's framework ecosystem. @@ -139,6 +140,7 @@ Vite+ automatically wraps your package manager (pnpm, npm, Yarn, or Bun) based o #### Maintain +- **toolchain** - Show Vite+ tool versions and their relationships - **upgrade** - Update `vp` itself to the latest version - **implode** - Remove `vp` and all related data @@ -189,7 +191,7 @@ If you are manually migrating a project to Vite+, install these dev dependencies npm install -D vite-plus @voidzero-dev/vite-plus-core@latest ``` -You need to add overrides to your package manager so that other packages resolve the Vite+ versions: alias `vite` to `@voidzero-dev/vite-plus-core`, and pin `vitest` to the version Vite+ bundles (run `vp --version`) so the whole project shares a single Vitest copy with `vp test`. Without the `vitest` pin, a dependency or workspace package can pull a different Vitest than the bundled runner, splitting Vitest's internals (mocks, `expect`, runner state): +You need to add overrides to your package manager so that other packages resolve the Vite+ versions: alias `vite` to `@voidzero-dev/vite-plus-core`, and pin `vitest` to the version reported by `vp toolchain vitest` so the whole project shares a single Vitest copy with `vp test`. Without the `vitest` pin, a dependency or workspace package can pull a different Vitest than the bundled runner, splitting Vitest's internals (mocks, `expect`, runner state): ```json "overrides": { diff --git a/packages/cli/binding/Cargo.toml b/packages/cli/binding/Cargo.toml index d46347eca2..1adea51afe 100644 --- a/packages/cli/binding/Cargo.toml +++ b/packages/cli/binding/Cargo.toml @@ -37,6 +37,7 @@ vite_shared = { workspace = true } vite_static_config = { workspace = true } vite_str = { workspace = true } vite_task = { workspace = true } +vite_toolchain = { workspace = true } vite_workspace = { workspace = true } rolldown_binding = { workspace = true, optional = true, features = ["disable_panic_hook"] } diff --git a/packages/cli/binding/index.d.cts b/packages/cli/binding/index.d.cts index aadf0696ba..3e9f65d793 100644 --- a/packages/cli/binding/index.d.cts +++ b/packages/cli/binding/index.d.cts @@ -3379,6 +3379,10 @@ export interface CliOptions { cwd?: string; /** CLI arguments (should be process.argv.slice(2) from JavaScript) */ args?: Array; + /** Generated toolchain manifest shipped with this vite-plus package. */ + toolchainManifestPath: string; + /** Root directory of this vite-plus package. */ + vitePlusPackagePath: string; /** Read the vite.config.ts in the Node.js side and return the `lint` and `fmt` config JSON string back to the Rust side */ resolveUniversalViteConfig: (err: Error | null, arg: string) => Promise; } diff --git a/packages/cli/binding/src/cli/handler.rs b/packages/cli/binding/src/cli/handler.rs index 2a71d0db04..63a8a7d464 100644 --- a/packages/cli/binding/src/cli/handler.rs +++ b/packages/cli/binding/src/cli/handler.rs @@ -100,7 +100,7 @@ impl CommandHandler for VitePlusCommandHandler { Ok(HandledCommand::Synthesized(resolved.into_synthetic_plan_request())) } CLIArgs::ViteTask(cmd) => Ok(HandledCommand::ViteTaskCommand(cmd)), - CLIArgs::PackageManager(_) | CLIArgs::Exec(_) => { + CLIArgs::PackageManager(_) | CLIArgs::Exec(_) | CLIArgs::Toolchain(_) => { // PM commands and exec in task scripts run as subprocesses // — no caching, no synthesis through the resolver. Ok(HandledCommand::Synthesized( diff --git a/packages/cli/binding/src/cli/help.rs b/packages/cli/binding/src/cli/help.rs index 1c9fa5b0c5..03fc2d041b 100644 --- a/packages/cli/binding/src/cli/help.rs +++ b/packages/cli/binding/src/cli/help.rs @@ -203,6 +203,7 @@ pub(super) fn print_help() { {bold}cache{reset} Manage the task cache {bold}config{reset} Configure hooks and agent integration {bold}staged{reset} Run linters on staged files + {bold}toolchain{reset} Show versions and relationships in the Vite+ toolchain {bold_underline}Package Manager Commands:{reset} {bold}install{reset} Install all dependencies, or add packages if package names are provided diff --git a/packages/cli/binding/src/cli/mod.rs b/packages/cli/binding/src/cli/mod.rs index 8c0f2044d2..b19a785131 100644 --- a/packages/cli/binding/src/cli/mod.rs +++ b/packages/cli/binding/src/cli/mod.rs @@ -21,7 +21,7 @@ pub use resolver::SubcommandResolver; use rustc_hash::FxHashMap; pub(crate) use types::CapturedCommandOutput; pub use types::{ - BoxedResolverFn, CliOptions, ResolveCommandResult, SynthesizableSubcommand, + BoxedResolverFn, CliOptions, ResolveCommandResult, SynthesizableSubcommand, ToolchainArgs, ViteConfigResolverFn, }; use vite_error::Error; @@ -275,6 +275,62 @@ async fn execute_vite_task_command( Ok(status) } +fn execute_toolchain_command( + args: ToolchainArgs, + options: Option<&CliOptions>, +) -> Result { + if args.global { + vite_shared::output::error( + "`--global` is only supported by the globally installed `vp` CLI", + ); + return Ok(ExitStatus(1)); + } + + let options = options.ok_or_else(|| { + Error::Anyhow(anyhow::anyhow!("toolchain metadata is unavailable in this CLI")) + })?; + let manifest_path = + vite_path::AbsolutePathBuf::new(options.toolchain_manifest_path.clone().into()) + .ok_or_else(|| { + Error::Anyhow(anyhow::anyhow!("toolchain manifest path must be absolute")) + })?; + let manifest = vite_toolchain::load_manifest(&manifest_path) + .map_err(|error| Error::Anyhow(anyhow::Error::new(error)))?; + let version = vite_toolchain::root_version(&manifest).ok_or_else(|| { + Error::Anyhow(anyhow::anyhow!("toolchain manifest does not contain vite-plus")) + })?; + let source = vite_toolchain::Source { + scope: vite_toolchain::Scope::Local, + path: options.vite_plus_package_path.clone().into(), + vite_plus_version: version.into(), + }; + let report = match vite_toolchain::build_report(&manifest, &args.tools, source) { + Ok(report) => report, + Err(vite_toolchain::ToolchainError::UnknownFilter(filter)) => { + let message = format!("`{filter}` is not part of the Vite+ toolchain manifest"); + if args.json { + vite_shared::output::raw_stderr(&format!("error: {message}")); + } else { + vite_shared::output::error(&message); + vite_shared::output::raw_stderr(&format!( + "hint: run `vp why {filter}` to inspect project dependencies" + )); + } + return Ok(ExitStatus(1)); + } + Err(error) => return Err(Error::Anyhow(anyhow::Error::new(error))), + }; + + let rendered = if args.json { + vite_toolchain::render_json(&report) + .map_err(|error| Error::Anyhow(anyhow::Error::new(error)))? + } else { + vite_toolchain::render_human(&report) + }; + vite_shared::output::raw_inline(&rendered); + Ok(ExitStatus::SUCCESS) +} + /// Main entry point for vite-plus CLI. /// /// # Arguments @@ -317,8 +373,9 @@ pub async fn main( execute_direct_subcommand(subcmd, &cwd, options).await } CLIArgs::ViteTask(command) => execute_vite_task_command(command, cwd, options).await, - CLIArgs::PackageManager(pm) => execute_pm_command(pm, &cwd).await, + CLIArgs::PackageManager(pm) => execute_pm_command(pm, &cwd, options.as_ref()).await, CLIArgs::Exec(exec_args) => crate::exec::execute(exec_args, &cwd).await, + CLIArgs::Toolchain(args) => execute_toolchain_command(args, options.as_ref()), } } @@ -327,6 +384,7 @@ pub async fn main( async fn execute_pm_command( command: vite_pm_cli::PackageManagerCommand, cwd: &AbsolutePath, + options: Option<&CliOptions>, ) -> Result { // Commands projected into the vite-plus-managed package store only work // in the global CLI. The local CLI has no such store, so refuse rather @@ -337,6 +395,7 @@ async fn execute_pm_command( "Global package operations (`-g`/`--global`) are only supported by the globally-installed `vp` CLI. See https://viteplus.dev/guide/ to install it, then run the same command via the global `vp` binary.", ))); } + let why_hint_packages = command.why_hint_packages().map(<[String]>::to_vec); let status = match vite_pm_cli::dispatch(cwd, command).await { Ok(status) => status, // Render `UserMessage` cleanly (no `error:` prefix) and exit non-zero — @@ -348,6 +407,17 @@ async fn execute_pm_command( } Err(e) => return Err(Error::Anyhow(anyhow::Error::new(e))), }; + if status.success() + && let Some(packages) = why_hint_packages + && let Some(options) = options + && let Some(manifest_path) = + vite_path::AbsolutePathBuf::new(options.toolchain_manifest_path.clone().into()) + && let Ok(manifest) = vite_toolchain::load_manifest(&manifest_path) + && let Some(hint) = vite_toolchain::why_hint(&manifest, &packages) + { + vite_shared::output::raw_stderr(""); + vite_shared::output::raw_stderr(&hint); + } Ok(types::exit_status_from(status)) } diff --git a/packages/cli/binding/src/cli/types.rs b/packages/cli/binding/src/cli/types.rs index fba76817fe..5f2cf7a77e 100644 --- a/packages/cli/binding/src/cli/types.rs +++ b/packages/cli/binding/src/cli/types.rs @@ -97,6 +97,21 @@ pub enum SynthesizableSubcommand { }, } +#[derive(Debug, clap::Args)] +pub struct ToolchainArgs { + /// Filter by tool or package name + #[arg(value_name = "TOOLS")] + pub tools: Vec, + + /// Output the toolchain graph as JSON + #[arg(long)] + pub json: bool, + + /// Inspect the global Vite+ toolchain + #[arg(long)] + pub global: bool, +} + /// Top-level CLI argument parser for vite-plus. #[derive(Debug, Parser)] #[command(name = "vp", disable_help_subcommand = true)] @@ -115,6 +130,9 @@ pub(super) enum CLIArgs { /// Execute a command from local node_modules/.bin Exec(crate::exec::ExecArgs), + + /// Show versions and relationships in the active Vite+ toolchain + Toolchain(ToolchainArgs), } /// Type alias for boxed async resolver function @@ -138,6 +156,8 @@ pub struct CliOptions { pub test: BoxedResolverFn, pub pack: BoxedResolverFn, pub doc: BoxedResolverFn, + pub toolchain_manifest_path: String, + pub vite_plus_package_path: String, pub resolve_universal_vite_config: ViteConfigResolverFn, } diff --git a/packages/cli/binding/src/lib.rs b/packages/cli/binding/src/lib.rs index ed9d1008b3..b88a2707ca 100644 --- a/packages/cli/binding/src/lib.rs +++ b/packages/cli/binding/src/lib.rs @@ -74,6 +74,10 @@ pub struct CliOptions { pub cwd: Option, /// CLI arguments (should be process.argv.slice(2) from JavaScript) pub args: Option>, + /// Generated toolchain manifest shipped with this vite-plus package. + pub toolchain_manifest_path: String, + /// Root directory of this vite-plus package. + pub vite_plus_package_path: String, /// Read the vite.config.ts in the Node.js side and return the `lint` and `fmt` config JSON string back to the Rust side pub resolve_universal_vite_config: Arc>>, } @@ -173,6 +177,8 @@ pub async fn run(options: CliOptions) -> Result { let doc_tsf = options.doc; let resolve_universal_vite_config_tsf = options.resolve_universal_vite_config; let args = options.args; + let toolchain_manifest_path = options.toolchain_manifest_path; + let vite_plus_package_path = options.vite_plus_package_path; // Create a channel to receive the result from the worker thread let (tx, rx) = tokio::sync::oneshot::channel(); @@ -189,6 +195,8 @@ pub async fn run(options: CliOptions) -> Result { test: create_resolver(test_tsf, "Failed to resolve test command"), pack: create_resolver(pack_tsf, "Failed to resolve pack command"), doc: create_resolver(doc_tsf, "Failed to resolve doc command"), + toolchain_manifest_path, + vite_plus_package_path, resolve_universal_vite_config: create_vite_config_resolver( resolve_universal_vite_config_tsf, ), diff --git a/packages/cli/build.ts b/packages/cli/build.ts index 2414446856..ba8f4d9508 100644 --- a/packages/cli/build.ts +++ b/packages/cli/build.ts @@ -6,7 +6,7 @@ * 2. buildNapiBinding() - Builds the native Rust binding via NAPI * 3. syncCorePackageExports() - Creates shim files to re-export from @voidzero-dev/vite-plus-core * 4. syncTestPackageExports() - Creates shim files to re-export from vitest - * 5. syncVersionsExport() - Generates ./versions module with bundled tool versions + * 5. syncToolchainExports() - Generates the toolchain manifest and ./versions module * 6. copyBundledDocs() - Copies docs into docs/ for bundled package access * 7. syncReadmeFromRoot() - Keeps package README in sync * @@ -18,7 +18,7 @@ * Native binding is built first because TypeScript may depend on generated binding types. */ -import { execSync } from 'node:child_process'; +import { execFileSync, execSync } from 'node:child_process'; import { existsSync, readdirSync, statSync } from 'node:fs'; import { copyFile, cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; @@ -137,7 +137,7 @@ if (!skipNative) { if (!skipTs) { await syncCorePackageExports(); await syncTestPackageExports(); - await syncVersionsExport(); + await syncToolchainExports(); } await copyBundledDocs(); await syncReadmeFromRoot(); @@ -602,71 +602,263 @@ async function createBrowserCompatExport(testDistDir: string): Promise { - try { - const pkgPath = join(projectDir, 'node_modules', packageName, 'package.json'); - const pkg = JSON.parse(await readFile(pkgPath, 'utf-8')); - return pkg.version ?? null; - } catch { - return null; +type ToolchainKind = 'package' | 'tool' | 'engine'; +type ToolchainDelivery = 'dependency' | 'bundled' | 'compiled'; +type ToolchainRelationship = 'depends-on' | 'bundles' | 'uses' | 'compiles'; + +type ToolchainVersionSource = + | { type: 'cli-package' } + | { type: 'core-package' } + | { type: 'core-bundled'; key: string } + | { type: 'npm-dependency'; package: string } + | { type: 'cargo'; package: string; revision?: boolean }; + +interface ToolchainConfigNode { + id: string; + name: string; + kind: ToolchainKind; + delivery: ToolchainDelivery[]; + aliases: string[]; + versionSource: ToolchainVersionSource; +} + +interface ToolchainConfig { + schemaVersion: number; + versionExportIds: string[]; + nodes: ToolchainConfigNode[]; + edges: Array<{ + from: string; + to: string; + relationship: ToolchainRelationship; + }>; +} + +interface CargoMetadata { + packages: Array<{ + name: string; + version: string; + source: string | null; + }>; +} + +interface ResolvedToolchainNode { + id: string; + name: string; + version: string; + revision?: string; + kind: ToolchainKind; + delivery: ToolchainDelivery[]; + aliases: string[]; +} + +async function readPackageVersion(packageJsonPath: string, label: string): Promise { + const pkg = JSON.parse(await readFile(packageJsonPath, 'utf-8')) as { version?: unknown }; + if (typeof pkg.version !== 'string' || pkg.version.length === 0) { + throw new Error(`Expected an exact version in ${label}`); + } + return pkg.version; +} + +function readCargoMetadata(): CargoMetadata { + const repoDir = join(projectDir, '..', '..'); + const stdout = execFileSync('cargo', ['metadata', '--locked', '--format-version', '1'], { + cwd: repoDir, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + }); + return JSON.parse(stdout) as CargoMetadata; +} + +function resolveCargoPackage(metadata: CargoMetadata, packageName: string) { + const matches = metadata.packages.filter((pkg) => pkg.name === packageName); + if (matches.length !== 1) { + throw new Error( + `Expected one Cargo package named ${JSON.stringify(packageName)}, found ${matches.length}`, + ); + } + return matches[0]; +} + +function sourceRevision(source: string | null): string | undefined { + return source?.match(/#([0-9a-f]{40})$/)?.[1]; +} + +function validateToolchainConfig(config: ToolchainConfig) { + if (config.schemaVersion !== 1) { + throw new Error(`Unsupported toolchain schema version: ${config.schemaVersion}`); + } + + const ids = new Set(); + const labels = new Map(); + for (const node of config.nodes) { + if (!node.id || !node.name) { + throw new Error('Toolchain node IDs and names must not be empty'); + } + if (ids.has(node.id)) { + throw new Error(`Duplicate toolchain node ID: ${node.id}`); + } + ids.add(node.id); + + for (const label of [node.id, node.name, ...node.aliases]) { + if (!label) { + throw new Error(`Toolchain node ${node.id} has an empty name or alias`); + } + const existingNode = labels.get(label); + if (existingNode && existingNode !== node.id) { + throw new Error( + `Toolchain filter label ${JSON.stringify(label)} is shared by ${existingNode} and ${node.id}`, + ); + } + labels.set(label, node.id); + } } + + for (const edge of config.edges) { + if (!ids.has(edge.from) || !ids.has(edge.to)) { + throw new Error(`Toolchain edge references an unknown node: ${edge.from} -> ${edge.to}`); + } + } + + for (const id of config.versionExportIds) { + if (!ids.has(id)) { + throw new Error(`versions export references an unknown toolchain node: ${id}`); + } + } +} + +async function resolveToolchainNode( + node: ToolchainConfigNode, + cargoMetadata: CargoMetadata, + cliVersion: string, +): Promise { + let version: string; + let revision: string | undefined; + + switch (node.versionSource.type) { + case 'cli-package': + version = cliVersion; + break; + case 'core-package': + version = corePkg.version; + break; + case 'core-bundled': { + const bundledVersions = (corePkg as { bundledVersions?: Record }) + .bundledVersions; + version = bundledVersions?.[node.versionSource.key] ?? ''; + break; + } + case 'npm-dependency': + version = await readPackageVersion( + join(projectDir, 'node_modules', node.versionSource.package, 'package.json'), + `${node.versionSource.package}/package.json`, + ); + break; + case 'cargo': { + const pkg = resolveCargoPackage(cargoMetadata, node.versionSource.package); + version = pkg.version; + revision = sourceRevision(pkg.source); + if (node.versionSource.revision && !revision) { + throw new Error(`Expected an exact source revision for Cargo package ${pkg.name}`); + } + break; + } + } + + if (!version) { + throw new Error(`Could not resolve an exact version for toolchain node ${node.id}`); + } + + return { + id: node.id, + name: node.name, + version, + ...(revision ? { revision } : {}), + kind: node.kind, + delivery: node.delivery, + aliases: node.aliases, + }; } /** - * Generate ./versions export module with bundled tool versions. - * - * Collects versions from: - * - core package.json bundledVersions (vite, rolldown, tsdown) - * - CLI dependency package.json (oxlint, oxfmt, oxlint-tsgolint, vitest) - * - * Generates dist/versions.js and dist/versions.d.ts with inlined constants. + * Generate the published toolchain manifest and derive the existing versions export from it. */ -async function syncVersionsExport() { - console.log('\nSyncing versions export...'); +async function syncToolchainExports() { + console.log('\nSyncing toolchain exports...'); const distDir = join(projectDir, 'dist'); - - // Collect bundled versions from the core package - const versions: Record = { - ...(corePkg as Record).bundledVersions, + const config = JSON.parse( + await readFile(join(projectDir, 'toolchain.config.json'), 'utf8'), + ) as ToolchainConfig; + validateToolchainConfig(config); + + const cliVersion = await readPackageVersion(join(projectDir, 'package.json'), 'vite-plus'); + const cargoMetadata = readCargoMetadata(); + const nodes = await Promise.all( + config.nodes.map((node) => resolveToolchainNode(node, cargoMetadata, cliVersion)), + ); + const toolchain = { + schemaVersion: config.schemaVersion, + nodes, + edges: config.edges, }; - // Read versions from CLI dependencies' installed package.json files - // (these packages don't export ./package.json, so node_modules is the source of truth) - const depTools = ['oxlint', 'oxfmt', 'oxlint-tsgolint', 'vitest'] as const; - for (const name of depTools) { - const version = await readDepVersion(name); - if (version) { - versions[name] = version; - } - } + await writeFile(join(distDir, 'toolchain.json'), `${JSON.stringify(toolchain, null, 2)}\n`); + await writeFile( + join(distDir, 'toolchain.js'), + `export const toolchain = ${JSON.stringify(toolchain, null, 2)};\nexport default toolchain;\n`, + ); + await writeFile( + join(distDir, 'toolchain.d.ts'), + `export type ToolchainNodeKind = 'package' | 'tool' | 'engine'; +export type ToolchainDelivery = 'dependency' | 'bundled' | 'compiled'; +export type ToolchainRelationship = 'depends-on' | 'bundles' | 'uses' | 'compiles'; +export interface ToolchainNode { + readonly id: string; + readonly name: string; + readonly version: string; + readonly revision?: string; + readonly kind: ToolchainNodeKind; + readonly delivery: readonly ToolchainDelivery[]; + readonly aliases: readonly string[]; +} +export interface ToolchainEdge { + readonly from: string; + readonly to: string; + readonly relationship: ToolchainRelationship; +} +export interface ToolchainManifest { + readonly schemaVersion: 1; + readonly nodes: readonly ToolchainNode[]; + readonly edges: readonly ToolchainEdge[]; +} +export declare const toolchain: ToolchainManifest; +export default toolchain; +`, + ); + + const nodesById = new Map(nodes.map((node) => [node.id, node])); + const versions = Object.fromEntries( + config.versionExportIds.map((id) => { + const node = nodesById.get(id); + if (!node) { + throw new Error(`Missing generated toolchain node for versions export: ${id}`); + } + return [node.name, node.version]; + }), + ); - // dist/versions.js — inlined constants (no runtime I/O) await writeFile( join(distDir, 'versions.js'), `export const versions = ${JSON.stringify(versions, null, 2)};\n`, ); - - // dist/versions.d.ts — type declarations const typeFields = Object.keys(versions) - .map((k) => ` readonly '${k}': string;`) + .map((key) => ` readonly '${key}': string;`) .join('\n'); await writeFile( join(distDir, 'versions.d.ts'), `export declare const versions: {\n${typeFields}\n};\n`, ); + console.log(` Created ./toolchain (${nodes.length} components)`); console.log(` Created ./versions (${Object.keys(versions).length} tools)`); } diff --git a/packages/cli/package.json b/packages/cli/package.json index ef42d4ca9a..23459fab6b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -85,6 +85,10 @@ "./pack/client": { "types": "./dist/pack-client.d.ts" }, + "./toolchain": { + "types": "./dist/toolchain.d.ts", + "default": "./dist/toolchain.js" + }, "./versions": { "types": "./dist/versions.d.ts", "default": "./dist/versions.js" diff --git a/packages/cli/src/__tests__/toolchain.spec.ts b/packages/cli/src/__tests__/toolchain.spec.ts new file mode 100644 index 0000000000..4fa4fb691a --- /dev/null +++ b/packages/cli/src/__tests__/toolchain.spec.ts @@ -0,0 +1,107 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import url from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +const cliPkgDir = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '../..'); +const distDir = path.join(cliPkgDir, 'dist'); + +interface ToolchainNode { + id: string; + name: string; + version: string; + revision?: string; + aliases: string[]; +} + +interface ToolchainManifest { + schemaVersion: number; + nodes: ToolchainNode[]; + edges: Array<{ + from: string; + to: string; + relationship: string; + }>; +} + +describe('toolchain export', () => { + it('generates JSON, JavaScript, and type declaration artifacts', () => { + for (const file of ['toolchain.json', 'toolchain.js', 'toolchain.d.ts']) { + expect(fs.existsSync(path.join(distDir, file)), `${file} should exist`).toBe(true); + } + }); + + it('exports the same manifest as the JSON artifact', async () => { + const json = JSON.parse( + fs.readFileSync(path.join(distDir, 'toolchain.json'), 'utf8'), + ) as ToolchainManifest; + const module = await import('../../dist/toolchain.js'); + + expect(module.toolchain).toEqual(json); + expect(module.default).toBe(module.toolchain); + }); + + it('contains every required toolchain component with exact versions', async () => { + const { toolchain } = (await import('../../dist/toolchain.js')) as { + toolchain: ToolchainManifest; + }; + const nodes = new Map(toolchain.nodes.map((node) => [node.id, node])); + + expect([...nodes.keys()]).toEqual([ + 'vite-plus', + 'vite-plus-core', + 'vite', + 'rolldown', + 'vitest', + 'oxlint', + 'oxfmt', + 'oxlint-tsgolint', + 'tsdown', + 'vite-task', + 'oxc', + 'oxc-resolver', + ]); + for (const node of nodes.values()) { + expect(node.version, `${node.id} should have an exact version`).toMatch(/^\d+\.\d+\.\d+/); + } + expect(nodes.get('vite-task')?.revision).toMatch(/^[0-9a-f]{40}$/); + }); + + it('uses unambiguous filter labels', async () => { + const { toolchain } = (await import('../../dist/toolchain.js')) as { + toolchain: ToolchainManifest; + }; + const labels = new Map(); + + for (const node of toolchain.nodes) { + for (const label of [node.id, node.name, ...node.aliases]) { + const existingNode = labels.get(label); + expect( + existingNode === undefined || existingNode === node.id, + `${label} should resolve to one node`, + ).toBe(true); + labels.set(label, node.id); + } + } + }); + + it('derives the versions export from manifest nodes', async () => { + const [{ toolchain }, { versions }] = await Promise.all([ + import('../../dist/toolchain.js') as Promise<{ toolchain: ToolchainManifest }>, + import('../../dist/versions.js') as Promise<{ versions: Record }>, + ]); + const nodes = new Map(toolchain.nodes.map((node) => [node.name, node.version])); + + for (const [name, version] of Object.entries(versions)) { + expect(version, `versions.${name} should match the manifest`).toBe(nodes.get(name)); + } + }); + + it('declares the public manifest schema', () => { + const declarations = fs.readFileSync(path.join(distDir, 'toolchain.d.ts'), 'utf8'); + expect(declarations).toContain('export interface ToolchainManifest'); + expect(declarations).toContain('readonly schemaVersion: 1'); + expect(declarations).toContain('export default toolchain'); + }); +}); diff --git a/packages/cli/src/__tests__/versions.spec.ts b/packages/cli/src/__tests__/versions.spec.ts index 98ba8fe14e..51fb890c09 100644 --- a/packages/cli/src/__tests__/versions.spec.ts +++ b/packages/cli/src/__tests__/versions.spec.ts @@ -2,7 +2,7 @@ * Verify that the vite-plus/versions export works correctly. * * Tests run against the already-built dist/ directory, ensuring - * that syncVersionsExport() produces correct artifacts. + * that syncToolchainExports() produces correct artifacts. */ import fs from 'node:fs'; import { createRequire } from 'node:module'; diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 60fc0b7ae8..0054b41854 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -11,6 +11,7 @@ */ import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { ensureBlockingStdio, run } from '../binding/index.js'; import { maybePrintCommandHelp } from './help.ts'; @@ -101,6 +102,8 @@ if (args[0] === 'help' && args[1]) { } const command = args[0]; +const cliDistDir = path.dirname(fileURLToPath(import.meta.url)); +const vitePlusPackagePath = path.dirname(cliDistDir); if (maybePrintCommandHelp(args)) { // Help is rendered by the local CLI so it matches the installed toolchain. @@ -137,6 +140,8 @@ if (maybePrintCommandHelp(args)) { vite, test, doc, + toolchainManifestPath: path.join(cliDistDir, 'toolchain.json'), + vitePlusPackagePath, resolveUniversalViteConfig, args: rustCliArgs, }); diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts index c6fe41a114..49019c7ff4 100644 --- a/packages/cli/src/version.ts +++ b/packages/cli/src/version.ts @@ -62,7 +62,7 @@ function isVitePlusDeclaredInAncestors(cwd: string): boolean { /** * Resolve all tool versions from the locally installed vite-plus package. - * Uses the `vite-plus/versions` export generated by `syncVersionsExport()`. + * Uses the `vite-plus/versions` export generated by `syncToolchainExports()`. */ async function resolveToolVersions(localPackagePath: string): Promise> { try { diff --git a/packages/cli/toolchain.config.json b/packages/cli/toolchain.config.json new file mode 100644 index 0000000000..b721807265 --- /dev/null +++ b/packages/cli/toolchain.config.json @@ -0,0 +1,207 @@ +{ + "schemaVersion": 1, + "versionExportIds": [ + "vite", + "rolldown", + "vitest", + "oxfmt", + "oxlint", + "oxlint-tsgolint", + "tsdown" + ], + "nodes": [ + { + "id": "vite-plus", + "name": "vite-plus", + "kind": "package", + "delivery": ["dependency"], + "aliases": [], + "versionSource": { + "type": "cli-package" + } + }, + { + "id": "vite-plus-core", + "name": "@voidzero-dev/vite-plus-core", + "kind": "package", + "delivery": ["dependency"], + "aliases": ["vite-plus-core"], + "versionSource": { + "type": "core-package" + } + }, + { + "id": "vite", + "name": "vite", + "kind": "tool", + "delivery": ["bundled"], + "aliases": [], + "versionSource": { + "type": "core-bundled", + "key": "vite" + } + }, + { + "id": "rolldown", + "name": "rolldown", + "kind": "tool", + "delivery": ["bundled", "compiled"], + "aliases": [], + "versionSource": { + "type": "core-bundled", + "key": "rolldown" + } + }, + { + "id": "vitest", + "name": "vitest", + "kind": "tool", + "delivery": ["dependency"], + "aliases": [], + "versionSource": { + "type": "npm-dependency", + "package": "vitest" + } + }, + { + "id": "oxlint", + "name": "oxlint", + "kind": "tool", + "delivery": ["dependency"], + "aliases": [], + "versionSource": { + "type": "npm-dependency", + "package": "oxlint" + } + }, + { + "id": "oxfmt", + "name": "oxfmt", + "kind": "tool", + "delivery": ["dependency"], + "aliases": [], + "versionSource": { + "type": "npm-dependency", + "package": "oxfmt" + } + }, + { + "id": "oxlint-tsgolint", + "name": "oxlint-tsgolint", + "kind": "tool", + "delivery": ["dependency"], + "aliases": ["tsgolint"], + "versionSource": { + "type": "npm-dependency", + "package": "oxlint-tsgolint" + } + }, + { + "id": "tsdown", + "name": "tsdown", + "kind": "tool", + "delivery": ["bundled"], + "aliases": [], + "versionSource": { + "type": "core-bundled", + "key": "tsdown" + } + }, + { + "id": "vite-task", + "name": "vite-task", + "kind": "tool", + "delivery": ["compiled"], + "aliases": [], + "versionSource": { + "type": "cargo", + "package": "vite_task", + "revision": true + } + }, + { + "id": "oxc", + "name": "oxc", + "kind": "engine", + "delivery": ["compiled"], + "aliases": [], + "versionSource": { + "type": "cargo", + "package": "oxc" + } + }, + { + "id": "oxc-resolver", + "name": "oxc-resolver", + "kind": "engine", + "delivery": ["compiled"], + "aliases": [], + "versionSource": { + "type": "cargo", + "package": "oxc_resolver" + } + } + ], + "edges": [ + { + "from": "vite-plus", + "to": "vite-plus-core", + "relationship": "depends-on" + }, + { + "from": "vite-plus-core", + "to": "vite", + "relationship": "bundles" + }, + { + "from": "vite", + "to": "rolldown", + "relationship": "uses" + }, + { + "from": "vite-plus-core", + "to": "rolldown", + "relationship": "bundles" + }, + { + "from": "rolldown", + "to": "oxc", + "relationship": "compiles" + }, + { + "from": "rolldown", + "to": "oxc-resolver", + "relationship": "compiles" + }, + { + "from": "vite-plus-core", + "to": "tsdown", + "relationship": "bundles" + }, + { + "from": "vite-plus", + "to": "vitest", + "relationship": "depends-on" + }, + { + "from": "vite-plus", + "to": "oxlint", + "relationship": "depends-on" + }, + { + "from": "vite-plus", + "to": "oxlint-tsgolint", + "relationship": "depends-on" + }, + { + "from": "vite-plus", + "to": "oxfmt", + "relationship": "depends-on" + }, + { + "from": "vite-plus", + "to": "vite-task", + "relationship": "compiles" + } + ] +} diff --git a/rfcs/toolchain-command.md b/rfcs/toolchain-command.md new file mode 100644 index 0000000000..ed348e2940 --- /dev/null +++ b/rfcs/toolchain-command.md @@ -0,0 +1,575 @@ +# RFC: Vite+ Toolchain Inspection Command + +- Status: Proposed +- Related: [why-package-command.md](./why-package-command.md), + [packages/core/BUNDLING.md](../packages/core/BUNDLING.md), + [packages/cli/BUNDLING.md](../packages/cli/BUNDLING.md), + [docs/guide/upgrade.md](../docs/guide/upgrade.md) + +## Summary + +Add a top-level `vp toolchain` command that reports the exact tools and engines +in the active Vite+ release: + +```bash +vp toolchain +vp toolchain vite +vp toolchain vite rolldown oxc +vp toolchain --json +vp toolchain --global +``` + +The `vite-plus` package ships a static toolchain manifest. The command reads +that file without invoking a package manager, executing dependency code, or +accessing the network. + +`vp why` keeps its package-manager behavior. For human-readable queries that +match a manifest entry, it points users to `vp toolchain`. + +## Motivation + +Vite+ pins the tools that `vp build`, `vp test`, and `vp check` compose. Peer +dependency resolution must not change those versions between projects. + +Package managers cannot inspect the full toolchain: + +- `@voidzero-dev/vite-plus-core` bundles Vite, Rolldown, and tsdown. +- Vite+ compiles Rolldown's native binding into its native addon. +- Oxc and other Rust engines may have no installed npm package. +- `pnpm why`, `npm explain`, Yarn, and Bun describe the installed package graph. +- Resolving `vite/package.json` in a migrated project returns the Vite+ core + alias. Its package version identifies Vite+, so it does not report the + bundled upstream Vite version. + +`vp --version` reports a flat summary. `vite-plus/versions` exposes the same +major versions to JavaScript. Neither surface shows ownership, composition, +Oxc, or Vite Task. + +To check whether a project can use a new transform, a maintainer may need: + +1. the Vite version that exposes it, +2. the Rolldown and Oxc versions behind that Vite release, and +3. the Vite+ release that ships those versions. + +Ship this provenance with each Vite+ release. + +## Goals + +- Report the exact toolchain selected for the current directory. +- Show how packages, bundled tools, and compiled engines relate to each other. +- Support focused queries for one or more tools. +- Include hidden versions that package managers cannot report. +- Provide versioned, machine-readable JSON. +- Work offline and without running a managed Node.js runtime. +- Generate `vp toolchain`, `vp --version`, and public exports from one manifest. + +## Non-goals + +- Replace `vp why` or reproduce package-manager dependency resolution. +- List every npm transitive dependency, Rust crate, optional peer, or platform + binding package. +- Determine whether an upstream feature exists in a particular version. +- Fetch changelogs, release notes, commits, or registry metadata. +- Allow projects to override Vite+'s bundled tool versions. +- Change Vite+ tools to peer dependencies. +- Produce a software bill of materials. + +## Manifest Scope + +The toolchain manifest includes components whose versions affect Vite+ behavior +or compatibility: + +1. Vite+ distribution packages, including `vite-plus` and + `@voidzero-dev/vite-plus-core`. +2. User-facing tools invoked or composed by Vite+, including Vite, Rolldown, + Vitest, Oxlint, Oxfmt, oxlint-tsgolint, tsdown, and Vite Task. +3. Bundled or compiled engines whose versions determine available behavior. + Version 1 includes Oxc and Oxc Resolver. + +The manifest excludes ordinary implementation dependencies such as terminal +formatting libraries, file globbers, and HTTP clients. It also excludes +platform-specific binding wrapper packages when their version is identical to +the logical tool they deliver. + +`vp toolchain` uses this bounded graph. `vp why` and `vp list` cover the +installed npm graph. + +Maintainers must update the manifest topology when Vite+ adds a user-facing +tool or a hidden engine with its own compatibility surface. + +## Command Interface + +```text +Usage: vp toolchain [OPTIONS] [TOOLS]... + +Show versions and relationships in the active Vite+ toolchain + +Arguments: + [TOOLS]... Filter by tool or package name + +Options: + --json Output the toolchain graph as JSON + --global Inspect the global Vite+ toolchain + -h, --help Print help +``` + +With no positional arguments, the command prints the complete manifest scope. +Positional arguments filter the graph to one or more components. + +Examples: + +```bash +vp toolchain # Active local-first toolchain +vp toolchain vite # Vite and its ownership/engine chain +vp toolchain rolldown oxc # Union of both matching branches +vp toolchain @voidzero-dev/vite-plus-core +vp toolchain --global # Ignore the project's local vite-plus +vp toolchain vite --json # Stable machine-readable result +``` + +The first version accepts exact names and declared aliases. It does not accept +globs. + +## Source Resolution + +By default, `vp toolchain` follows normal local-first routing: + +1. Use the installed local `vite-plus` resolved for the current directory. +2. If routing finds no local package, use the Vite+ package paired with the + running global `vp`. + +The output names the selected source. `--global` skips local resolution. + +The global binary delegates the full invocation to the selected local Vite+ +package. It reads its own manifest only when routing finds no local package or +the user passes `--global`. + +The project lockfile cannot describe code bundled into core or crates compiled +into the native addon. It may also contain unrelated copies of Vite, Rolldown, +or Oxc, so the command does not use it as release provenance. + +## Human-readable Output + +The command renders an ownership tree with relationship labels: + +```text +Vite+ toolchain (local) + +vite-plus@0.2.4 +|-- depends on @voidzero-dev/vite-plus-core@0.2.4 +| |-- bundles vite@8.1.3 +| | `-- uses rolldown@1.1.4 +| |-- bundles rolldown@1.1.4 +| | |-- compiles oxc@0.138.0 +| | `-- compiles oxc-resolver@11.22.0 +| `-- bundles tsdown@0.22.3 +|-- depends on vitest@4.1.10 +|-- depends on oxlint@1.72.0 +|-- depends on oxlint-tsgolint@0.24.0 +|-- depends on oxfmt@0.57.0 +`-- compiles vite-task@ () +``` + +These versions reflect the repository at the time of writing. They do not form +part of the command contract. + +The human tree may repeat a shared node to show two relationships. JSON uses one +entry per node ID. + +### Filtered output + +For each filter, the command keeps: + +- every ownership ancestor needed to explain how the matched component is + provided, and +- downstream `uses` and `compiles` relationships needed to expose its engine + chain. + +For example: + +```text +$ vp toolchain vite + +Vite+ toolchain (local) + +vite-plus@0.2.4 +`-- depends on @voidzero-dev/vite-plus-core@0.2.4 + `-- bundles vite@8.1.3 + `-- uses rolldown@1.1.4 + |-- compiles oxc@0.138.0 + `-- compiles oxc-resolver@11.22.0 +``` + +For multiple filters, the command returns the union of those nodes and edges. + +### Name matching + +Filters match a node's: + +- stable ID, +- canonical package or tool name, or +- declared alias. + +Initial aliases include: + +| Query | Node | +| ---------------- | ------------------------------ | +| `vite-plus-core` | `@voidzero-dev/vite-plus-core` | +| `tsgolint` | `oxlint-tsgolint` | +| `vite-task` | Vite Task | +| `oxc-resolver` | Oxc Resolver | + +Package and tool names remain case-sensitive, matching npm and Cargo naming. + +An unknown filter exits with status 1: + +```text +error: `rollup` is not part of the Vite+ toolchain manifest +hint: run `vp why rollup` to inspect project dependencies +``` + +For close matches, the error may suggest a manifest name. + +## JSON Output + +With `--json`, the command omits the Vite+ header, styling, and hints. It writes +one JSON object: + +```json +{ + "schemaVersion": 1, + "source": { + "scope": "local", + "path": "/project/node_modules/vite-plus", + "vitePlusVersion": "0.2.4" + }, + "nodes": [ + { + "id": "vite-plus", + "name": "vite-plus", + "version": "0.2.4", + "kind": "package", + "delivery": ["dependency"], + "aliases": [] + }, + { + "id": "vite-plus-core", + "name": "@voidzero-dev/vite-plus-core", + "version": "0.2.4", + "kind": "package", + "delivery": ["dependency"], + "aliases": ["vite-plus-core"] + }, + { + "id": "vite", + "name": "vite", + "version": "8.1.3", + "kind": "tool", + "delivery": ["bundled"], + "aliases": [] + }, + { + "id": "rolldown", + "name": "rolldown", + "version": "1.1.4", + "kind": "tool", + "delivery": ["bundled", "compiled"], + "aliases": [] + }, + { + "id": "oxc", + "name": "oxc", + "version": "0.138.0", + "kind": "engine", + "delivery": ["compiled"], + "aliases": [] + } + ], + "edges": [ + { + "from": "vite-plus", + "to": "vite-plus-core", + "relationship": "depends-on" + }, + { + "from": "vite-plus-core", + "to": "vite", + "relationship": "bundles" + }, + { + "from": "vite", + "to": "rolldown", + "relationship": "uses" + }, + { + "from": "rolldown", + "to": "oxc", + "relationship": "compiles" + } + ] +} +``` + +Node fields: + +| Field | Meaning | +| ---------- | --------------------------------------------------------- | +| `id` | Stable identifier referenced by edges and filters | +| `name` | Canonical package, tool, or engine name | +| `version` | Exact resolved version | +| `revision` | Optional exact source revision for git-sourced components | +| `kind` | `package`, `tool`, or `engine` | +| `delivery` | One or more of `dependency`, `bundled`, or `compiled` | +| `aliases` | Additional accepted filter names | + +Schema version 1 defines these edge relationships: + +- `depends-on`: shipped as a Vite+ package dependency, +- `bundles`: source or JavaScript output merged into another package, +- `uses`: runtime composition without ownership, +- `compiles`: linked into the Vite+ native addon. + +The renderer emits nodes and edges in manifest order. Consumers must address +nodes by ID. + +Breaking JSON changes increment `schemaVersion`. Adding optional fields, nodes, +edges, aliases, or enum values is non-breaking. + +## Published Toolchain Manifest + +The CLI package build writes: + +```text +packages/cli/dist/toolchain.json +packages/cli/dist/toolchain.js +packages/cli/dist/toolchain.d.ts +``` + +`vite-plus` exports a typed JavaScript form: + +```json +{ + "./toolchain": { + "types": "./dist/toolchain.d.ts", + "default": "./dist/toolchain.js" + } +} +``` + +The exported object contains the release graph. The CLI adds the runtime +`source` object and installation path. + +The build also derives the existing `vite-plus/versions` export from the +manifest and preserves its current keys. The build and both version commands +then share one version list. + +### Version sources + +The build resolves versions from: + +| Component type | Source | +| ----------------------------- | ------------------------------------------------------------- | +| `vite-plus` and core packages | Their generated `package.json` files | +| Bundled JS tools | Core `bundledVersions` generated during the core build | +| Managed npm tools | Resolved dependency `package.json` files | +| Compiled Rust tools/engines | `cargo metadata --locked --format-version 1` and `Cargo.lock` | +| Git-sourced Rust components | Cargo package version plus the exact resolved revision | + +Maintainers define graph topology and aliases in a small source-controlled +descriptor. The generator fills version and revision fields from the sources +above. + +Release builds fail when: + +- the generator cannot resolve a required node, +- a required node has no exact version, +- an edge references an unknown node, +- node IDs or aliases conflict, or +- the generated flat `versions` export disagrees with the graph. + +At runtime, `vp toolchain` reads the generated artifact. It does not parse +repository source files or run Cargo in an installed project. + +## Older Local Vite+ Releases + +Local-first routing sends `vp toolchain` to the selected local Vite+ package. A +local release that predates this command rejects it as an unknown command and +exits nonzero. + +The global CLI does not reconstruct a partial graph from older package +metadata. Users can upgrade the local Vite+ release or run +`vp toolchain --global` to inspect the global release. + +## Relationship to `vp --version` + +`vp --version` keeps its concise environment summary: + +- global `vp` version, +- local `vite-plus` version, +- major tool versions, +- package manager, and +- Node.js. + +It reads tool rows from the manifest. `vp toolchain` handles filtering, +relationships, and engine details. + +## Relationship to `vp why` + +`vp why` delegates to the detected package manager with its existing arguments, +output, and exit status. It explains the installed package graph. + +After a successful human-readable query, Vite+ checks each name against the +active toolchain manifest. A match adds one hint: + +```text +Vite+ also provides vite@8.1.3 through its toolchain. +Run `vp toolchain vite` to inspect its bundled version and relationships. +``` + +The hint says "also provides" because a project may also install upstream Vite. +Vite+ leaves package-manager output unchanged and omits the hint for failed or +machine-readable queries. One hint includes all matching names. + +## Implementation + +### Manifest generation + +Extend `packages/cli/build.ts` so the versions-export step generates the +toolchain graph, then derives `versions.js` and its type declaration from it. + +Core generates `bundledVersions` while it builds Vite, Rolldown, and tsdown. +The CLI generator combines that metadata with resolved npm packages and Cargo +metadata. + +### Command implementation + +Shared Rust code handles parsing, filtering, and rendering for both the global +CLI and local NAPI-backed CLI. + +Place the top-level command beside other Vite+ inspection and lifecycle +commands. `vite_pm_cli` owns commands that invoke a package manager. + +Without `--global`, the global binary delegates to a selected local Vite+ +package. The local package handles the command through its NAPI binding. With +`--global`, or when no local package exists, the global implementation reads +the global package's static manifest without starting Node.js. + +The Rust `--version` implementation reads the shared manifest and removes its +hardcoded `TOOL_SPECS` table. + +### Documentation + +Add `vp toolchain` to: + +- top-level CLI help, +- the interactive command picker, +- `README.md` and `packages/cli/README.md`, +- the guide command overview, +- upgrade and troubleshooting documentation, and +- generated project agent guidance when version inspection is discussed. + +Documentation describes `vp why` as a package-manager operation. + +## Testing + +### Unit tests + +- Manifest generation resolves all required npm and Cargo nodes. +- Invalid IDs, aliases, edges, versions, and revisions fail generation. +- The build derives `vite-plus/versions` from the graph and checks every key. +- Exact name and alias filters resolve the expected nodes. +- Filtering retains ownership ancestors and downstream engine edges. +- Multiple filters produce a stable union without duplicate JSON nodes. +- Human rendering uses stable ordering for shared nodes. +- Unknown filters return status 1 with the package-manager hint. + +### CLI snapshot tests + +New cases belong in `crates/vite_cli_snapshots/tests/cli_snapshots/`: + +| Scenario | Expected coverage | +| --------------------------------- | -------------------------------------------------------- | +| Full local manifest | Complete tree and local source | +| `vp toolchain vite` | Core, Vite, Rolldown, Oxc, and Oxc Resolver chain | +| Multiple filters | Stable union of branches | +| Alias filter | `vite-plus-core`, `vite-task`, and `tsgolint` resolution | +| `--json` | Valid JSON without header, styling, or trailing text | +| No local package | Global source selection | +| `--global` inside a local project | Global source forced | +| Old local Vite+ package | Unknown-command failure from the local CLI | +| Unknown tool | Status 1 and `vp why` hint | +| `vp why vite` | Package-manager output followed by toolchain hint | +| `vp why vite --json` | Unmodified machine-readable package-manager output | + +Release artifact tests load the same manifest with each platform binding and +compare its native versions with the compiled release inputs. + +## Performance and Security + +- Runtime work resolves the selected `vite-plus` package, reads one JSON file, + filters a small graph, and renders output. +- The command makes no network requests and executes no dependency code. +- The CLI reads the manifest from the selected `vite-plus` package. Manifest + traversal does not accept arbitrary filesystem paths. +- The manifest contains public package versions and source revisions. + +## Backward Compatibility + +The new command does not change `vp why` flags or package-manager behavior. +Machine-readable output excludes the new human hint. + +`vite-plus/versions` keeps its current flat shape. The release adds +`vite-plus/toolchain`. + +## Alternatives Considered + +### Extend `vp --version` + +`vp --version` gives users a short environment summary. Graph filtering, +relationship labels, and JSON need their own command. + +### Name the command `vp versions` + +`versions` omits the ownership relationship and overlaps with `vp env list`, +which manages Node.js versions. + +### Name the command `vp deps` or `vp tree` + +Both names suggest the installed project graph. `toolchain` identifies +Vite+-owned release metadata. + +### Change `vp why` to synthesize bundled nodes + +`vp why` promises package-manager dependency analysis. Synthetic nodes would +change its normal and JSON contracts. A human-only pointer keeps those +contracts intact. + +### Read package manifests at runtime + +Runtime package reads recover Vite, Rolldown, tsdown, and managed npm tools. +They cannot recover compiled Oxc or Vite Task inputs, and they duplicate the +generated manifest logic. + +### Query GitHub or the npm registry + +Remote lookups fail offline and describe registry metadata. The installed +manifest describes the artifact on disk. + +### Expose all Cargo and npm transitive dependencies + +A full transitive graph would duplicate package-manager and SBOM tools. The +manifest includes components whose versions affect Vite+ behavior. + +### Use peer dependencies for bundled tools + +Peer dependencies would let project resolution change Vite+ runtime behavior. +The inspection command fixes the visibility problem without changing version +ownership. + +## Rollout + +1. Generate and publish the toolchain manifest and `vite-plus/toolchain` export. +2. Derive `vite-plus/versions` and `vp --version` tool rows from the manifest. +3. Add `vp toolchain`, filtering, and JSON output. +4. Add the human-readable `vp why` discovery hint. +5. Update product documentation and generated agent guidance.