From ea0bdcc767a8818eac747c832fa40a2c1b2d97d5 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 10 Aug 2026 11:35:11 +0800 Subject: [PATCH 1/2] feat(cli): add `vp toolchain` command --- .github/actions/build-upstream/action.yml | 2 + .github/actions/build-windows-cli/action.yml | 1 + Cargo.lock | 16 + Cargo.toml | 1 + README.md | 4 +- .../snapshots/cli_helper_message.md | 5 +- .../snapshots/cli_helper_message_local.md | 1 + .../snapshots/command_helper.md | 1 + .../fixtures/command_toolchain/package.json | 6 + .../fixtures/command_toolchain/snapshots.toml | 74 ++ .../snapshots/toolchain_filters.md | 45 ++ .../snapshots/toolchain_full_global.md | 25 + .../snapshots/toolchain_full_local.md | 25 + .../toolchain_global_from_local_cli.md | 11 + .../snapshots/toolchain_json.md | 108 +++ .../snapshots/toolchain_old_local_routing.md | 88 +++ .../snapshots/toolchain_unknown.md | 22 + .../snapshots/toolchain_why_hint.global.md | 18 + .../snapshots/toolchain_why_hint.local.md | 18 + .../toolchain_why_hint_yarn.global.md | 18 + .../toolchain_why_hint_yarn.local.md | 18 + .../snapshots/command_vp_alias.md | 1 + .../fixtures/vp_help/snapshots/help.global.md | 5 +- .../fixtures/vp_help/snapshots/help.local.md | 1 + .../tests/cli_snapshots/redact.rs | 29 + crates/vp_cli_snapshots/tests/redact_unit.rs | 48 ++ crates/vp_global_cli/Cargo.toml | 1 + crates/vp_global_cli/src/cli.rs | 50 +- crates/vp_global_cli/src/command_picker.rs | 6 + crates/vp_global_cli/src/commands/mod.rs | 1 + .../vp_global_cli/src/commands/toolchain.rs | 54 ++ crates/vp_global_cli/src/commands/version.rs | 114 ++- crates/vp_global_cli/src/error.rs | 3 + crates/vp_global_cli/src/help.rs | 2 + crates/vp_global_cli/src/js_executor.rs | 26 +- crates/vp_pm_cli/src/cli.rs | 96 ++- crates/vp_pm_cli/src/dispatch.rs | 29 +- crates/vp_pm_cli/src/lib.rs | 2 +- .../vp_pm_cli/src/resolution/commands/why.rs | 18 + crates/vp_toolchain/Cargo.toml | 23 + crates/vp_toolchain/src/lib.rs | 682 ++++++++++++++++++ docs/guide/index.md | 1 + docs/guide/install.md | 5 + docs/guide/troubleshooting.md | 5 +- docs/guide/upgrade.md | 31 +- packages/cli/AGENTS.md | 7 + packages/cli/README.md | 4 +- packages/cli/binding/.gitignore | 3 +- packages/cli/binding/Cargo.toml | 1 + packages/cli/binding/index.d.cts | 4 + packages/cli/binding/src/cli/handler.rs | 2 +- packages/cli/binding/src/cli/help.rs | 1 + packages/cli/binding/src/cli/mod.rs | 86 ++- packages/cli/binding/src/cli/types.rs | 20 + packages/cli/binding/src/lib.rs | 8 + packages/cli/build.ts | 336 +++++++-- packages/cli/package.json | 4 + packages/cli/src/__tests__/toolchain.spec.ts | 101 +++ packages/cli/src/__tests__/versions.spec.ts | 2 +- packages/cli/src/bin.ts | 5 + packages/cli/src/version.ts | 2 +- packages/cli/toolchain.config.json | 208 ++++++ rfcs/toolchain-command.md | 577 +++++++++++++++ 63 files changed, 2997 insertions(+), 114 deletions(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_filters.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_full_global.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_full_local.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_global_from_local_cli.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_json.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_old_local_routing.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_unknown.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_why_hint.global.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_why_hint.local.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_why_hint_yarn.global.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_why_hint_yarn.local.md create mode 100644 crates/vp_global_cli/src/commands/toolchain.rs create mode 100644 crates/vp_toolchain/Cargo.toml create mode 100644 crates/vp_toolchain/src/lib.rs create mode 100644 packages/cli/src/__tests__/toolchain.spec.ts create mode 100644 packages/cli/toolchain.config.json create mode 100644 rfcs/toolchain-command.md diff --git a/.github/actions/build-upstream/action.yml b/.github/actions/build-upstream/action.yml index e0f9b6c21b..9c4bd0d8a3 100644 --- a/.github/actions/build-upstream/action.yml +++ b/.github/actions/build-upstream/action.yml @@ -57,6 +57,7 @@ runs: packages/cli/binding/index.d.ts packages/cli/binding/index.cjs packages/cli/binding/index.d.cts + packages/cli/binding/vite-plus.build-time ${{ steps.rust-target.outputs.dir }}/${{ inputs.target }}/release/vp ${{ steps.rust-target.outputs.dir }}/${{ inputs.target }}/release/vp.exe ${{ steps.rust-target.outputs.dir }}/${{ inputs.target }}/release/vp-shim.exe @@ -197,6 +198,7 @@ runs: packages/cli/binding/index.d.ts packages/cli/binding/index.cjs packages/cli/binding/index.d.cts + packages/cli/binding/vite-plus.build-time ${{ steps.rust-target.outputs.dir }}/${{ inputs.target }}/release/vp ${{ steps.rust-target.outputs.dir }}/${{ inputs.target }}/release/vp.exe ${{ steps.rust-target.outputs.dir }}/${{ inputs.target }}/release/vp-shim.exe diff --git a/.github/actions/build-windows-cli/action.yml b/.github/actions/build-windows-cli/action.yml index 2dbe4438cb..822d69e94b 100644 --- a/.github/actions/build-windows-cli/action.yml +++ b/.github/actions/build-windows-cli/action.yml @@ -40,6 +40,7 @@ runs: { echo 'paths< +`-- depends on @voidzero-dev/vite-plus-core@ + `-- bundles vite@ + `-- uses rolldown@ + |-- compiles oxc@ + `-- compiles oxc-resolver@ +``` + +## `vp toolchain vite vitest` + +multiple filters return a stable union + +``` +Vite+ toolchain (local) + +vite-plus@ +|-- depends on @voidzero-dev/vite-plus-core@ +| `-- bundles vite@ +| `-- uses rolldown@ +| |-- compiles oxc@ +| `-- compiles oxc-resolver@ +`-- depends on vitest@ +``` + +## `vp toolchain vite-plus-core tsgolint vite-task` + +stable IDs and declared aliases resolve + +``` +Vite+ toolchain (local) + +vite-plus@ +|-- depends on @voidzero-dev/vite-plus-core@ +|-- depends on oxlint-tsgolint@ +`-- compiles vite-task (built , revision ) +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_full_global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_full_global.md new file mode 100644 index 0000000000..e2981a7cee --- /dev/null +++ b/crates/vp_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@ +|-- depends on @voidzero-dev/vite-plus-core@ +| |-- bundles vite@ +| | `-- uses rolldown@ +| | |-- compiles oxc@ +| | `-- compiles oxc-resolver@ +| |-- bundles rolldown@ +| | |-- compiles oxc@ +| | `-- compiles oxc-resolver@ +| `-- bundles tsdown@ +|-- depends on vitest@ +|-- depends on oxlint@ +|-- depends on oxlint-tsgolint@ +|-- depends on oxfmt@ +`-- compiles vite-task (built , revision ) +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_full_local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_full_local.md new file mode 100644 index 0000000000..1373447ecd --- /dev/null +++ b/crates/vp_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@ +|-- depends on @voidzero-dev/vite-plus-core@ +| |-- bundles vite@ +| | `-- uses rolldown@ +| | |-- compiles oxc@ +| | `-- compiles oxc-resolver@ +| |-- bundles rolldown@ +| | |-- compiles oxc@ +| | `-- compiles oxc-resolver@ +| `-- bundles tsdown@ +|-- depends on vitest@ +|-- depends on oxlint@ +|-- depends on oxlint-tsgolint@ +|-- depends on oxfmt@ +`-- compiles vite-task (built , revision ) +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_global_from_local_cli.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_global_from_local_cli.md new file mode 100644 index 0000000000..d0fc2f9293 --- /dev/null +++ b/crates/vp_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: The `--global` option requires the global `vp` CLI +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_json.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_json.md new file mode 100644 index 0000000000..90e84fddd7 --- /dev/null +++ b/crates/vp_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 CLI header + +``` +{ + "schemaVersion": 1, + "source": { + "scope": "global", + "path": "/.vite-plus/current/node_modules/vite-plus", + "vitePlusVersion": "" + }, + "nodes": [ + { + "id": "vite-plus", + "name": "vite-plus", + "version": "", + "kind": "package", + "delivery": [ + "dependency" + ], + "aliases": [] + }, + { + "id": "vite-plus-core", + "name": "@voidzero-dev/vite-plus-core", + "version": "", + "kind": "package", + "delivery": [ + "dependency" + ], + "aliases": [ + "vite-plus-core" + ] + }, + { + "id": "vite", + "name": "vite", + "version": "", + "kind": "tool", + "delivery": [ + "bundled" + ], + "aliases": [] + }, + { + "id": "rolldown", + "name": "rolldown", + "version": "", + "kind": "tool", + "delivery": [ + "bundled", + "compiled" + ], + "aliases": [] + }, + { + "id": "oxc", + "name": "oxc", + "version": "", + "kind": "engine", + "delivery": [ + "compiled" + ], + "aliases": [] + }, + { + "id": "oxc-resolver", + "name": "oxc-resolver", + "version": "", + "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/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_old_local_routing.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_old_local_routing.md new file mode 100644 index 0000000000..af102f5e2d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_old_local_routing.md @@ -0,0 +1,88 @@ +# 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 +``` + +## `vpt rm -f node_modules/vite-plus/dist/bin.js` + + +## `vpt write-file node_modules/vite-plus/dist/toolchain.json '{"schemaVersion":1,"nodes":[{"id":"vite-plus","name":"vite-plus","version":"0.1.0","kind":"package","delivery":["dependency"],"aliases":[]},{"id":"vite","name":"vite","version":"0.1.0","kind":"tool","delivery":["bundled"],"aliases":[]}],"edges":[{"from":"vite-plus","to":"vite","relationship":"bundles"}]} +'` + + +## `vp toolchain` + +a package without a runnable local CLI uses the global toolchain + +``` +Vite+ toolchain (global) + +vite-plus@ +|-- depends on @voidzero-dev/vite-plus-core@ +| |-- bundles vite@ +| | `-- uses rolldown@ +| | |-- compiles oxc@ +| | `-- compiles oxc-resolver@ +| |-- bundles rolldown@ +| | |-- compiles oxc@ +| | `-- compiles oxc-resolver@ +| `-- bundles tsdown@ +|-- depends on vitest@ +|-- depends on oxlint@ +|-- depends on oxlint-tsgolint@ +|-- depends on oxfmt@ +`-- compiles vite-task (built , revision ) +``` + +## `vp why vite` + +the hint uses the global manifest when the local CLI is not runnable + +``` + +Vite+ also provides vite@ through its toolchain. +Run `vp toolchain vite` to show these versions and relationships. +``` + +## `vp toolchain --global` + +--global skips the old local package + +``` +Vite+ toolchain (global) + +vite-plus@ +|-- depends on @voidzero-dev/vite-plus-core@ +| |-- bundles vite@ +| | `-- uses rolldown@ +| | |-- compiles oxc@ +| | `-- compiles oxc-resolver@ +| |-- bundles rolldown@ +| | |-- compiles oxc@ +| | `-- compiles oxc-resolver@ +| `-- bundles tsdown@ +|-- depends on vitest@ +|-- depends on oxlint@ +|-- depends on oxlint-tsgolint@ +|-- depends on oxfmt@ +`-- compiles vite-task (built , revision ) +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_unknown.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_unknown.md new file mode 100644 index 0000000000..e36211bbeb --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_unknown.md @@ -0,0 +1,22 @@ +# toolchain_unknown + +## `vp toolchain rollup` + +readable output points to the package graph + +**Exit code:** 1 + +``` +error: `rollup` is not in the Vite+ toolchain +hint: run `vp why rollup` to show project dependencies +``` + +## `vp toolchain rollup --json` + +JSON output does not include the readable hint + +**Exit code:** 1 + +``` +error: `rollup` is not in the Vite+ toolchain +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_why_hint.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_why_hint.global.md new file mode 100644 index 0000000000..190a2f3803 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_why_hint.global.md @@ -0,0 +1,18 @@ +# toolchain_why_hint + +## `vp why vite` + +readable vp why output shows the toolchain hint + +``` + +Vite+ also provides vite@ through its toolchain. +Run `vp toolchain vite` to show these versions and relationships. +``` + +## `vp why vite --json` + +JSON package output does not include the hint + +``` +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_why_hint.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_why_hint.local.md new file mode 100644 index 0000000000..190a2f3803 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_why_hint.local.md @@ -0,0 +1,18 @@ +# toolchain_why_hint + +## `vp why vite` + +readable vp why output shows the toolchain hint + +``` + +Vite+ also provides vite@ through its toolchain. +Run `vp toolchain vite` to show these versions and relationships. +``` + +## `vp why vite --json` + +JSON package output does not include the hint + +``` +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_why_hint_yarn.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_why_hint_yarn.global.md new file mode 100644 index 0000000000..ccc417ffa0 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_why_hint_yarn.global.md @@ -0,0 +1,18 @@ +# toolchain_why_hint_yarn + +## `vpt json-edit package.json packageManager yarn@4.10.3` + + +## `vp install -- --mode=update-lockfile` + + +## `vp why vite vitest` + +the hint only includes the package Yarn queried + +``` +warn: yarn only supports checking one package at a time, using first package + +Vite+ also provides vite@ through its toolchain. +Run `vp toolchain vite` to show these versions and relationships. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_why_hint_yarn.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_why_hint_yarn.local.md new file mode 100644 index 0000000000..ccc417ffa0 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_why_hint_yarn.local.md @@ -0,0 +1,18 @@ +# toolchain_why_hint_yarn + +## `vpt json-edit package.json packageManager yarn@4.10.3` + + +## `vp install -- --mode=update-lockfile` + + +## `vp why vite vitest` + +the hint only includes the package Yarn queried + +``` +warn: yarn only supports checking one package at a time, using first package + +Vite+ also provides vite@ through its toolchain. +Run `vp toolchain vite` to show these versions and relationships. +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vp_alias/snapshots/command_vp_alias.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vp_alias/snapshots/command_vp_alias.md index 404a127ddb..f06b23f7db 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vp_alias/snapshots/command_vp_alias.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_vp_alias/snapshots/command_vp_alias.md @@ -26,6 +26,7 @@ Core Commands: config Configure hooks and agent integration hooks Manage the Git hook dispatcher staged Run linters on staged files + toolchain Show Vite+ tool versions and relationships Package Manager Commands: install Install all dependencies, or add packages if package names are provided diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.global.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.global.md index 5d591abceb..89680db7a3 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.global.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.global.md @@ -52,8 +52,9 @@ Manage Dependencies: pm Forward a command to the package manager Maintain: - upgrade Update vp itself to the latest version - implode Remove vp and all related data + toolchain Show active Vite+ tools, versions, and relationships + upgrade Update vp itself to the latest version + implode Remove vp and all related data Documentation: https://viteplus.dev/guide/ diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.local.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.local.md index c9c06048ec..aef30c124e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.local.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/vp_help/snapshots/help.local.md @@ -26,6 +26,7 @@ Core Commands: config Configure hooks and agent integration hooks Manage the Git hook dispatcher staged Run linters on staged files + toolchain Show Vite+ tool versions and relationships Package Manager Commands: install Install all dependencies, or add packages if package names are provided diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs b/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs index 96c2543d31..f8ddee50c9 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs @@ -22,6 +22,11 @@ static DURATION_RE: LazyLock = static VERSION_RE: LazyLock = LazyLock::new(|| { regex::Regex::new(r"\bv\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\b").unwrap() }); +static TOOLCHAIN_VERSION_RE: LazyLock = LazyLock::new(|| { + regex::Regex::new(r"\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\b").unwrap() +}); +static TOOLCHAIN_REVISION_RE: LazyLock = + LazyLock::new(|| regex::Regex::new(r"\b[0-9a-f]{40}\b").unwrap()); static THREAD_RE: LazyLock = LazyLock::new(|| regex::Regex::new(r"\d+ threads").unwrap()); // `vp pack`'s clean step reports how many stale files it removed from dist, @@ -279,6 +284,15 @@ static START_AT_TIME_RE: LazyLock = // published-at timestamps in `vp view`) stay verbatim. static INSTALLED_DATE_RE: LazyLock = LazyLock::new(|| regex::Regex::new(r"(Installed:\s+)\d{4}-\d{2}-\d{2}").unwrap()); +// The toolchain manifest records the package build time for compiled tools +// whose Cargo version is only a placeholder. Builds produce a fresh timestamp, +// so mask it while preserving both the human `built` label and JSON field. +static TOOLCHAIN_BUILD_TIME_RE: LazyLock = LazyLock::new(|| { + regex::Regex::new( + r#"((?:vite-task \(built |"builtAt":\s*"))\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z"#, + ) + .unwrap() +}); #[expect( clippy::disallowed_types, @@ -399,6 +413,18 @@ pub fn redact_output( // Redact semver-shaped versions (bundled tool versions, Node versions). output = VERSION_RE.replace_all(&output, "").into_owned(); + // Toolchain output is generated from the bundled manifest, so every + // version and compiled revision changes when that manifest is refreshed. + // Detect the command's human, hint, and JSON forms before masking bare + // semver values, which remain assertable in other snapshot output. + if output.contains("Vite+ toolchain (") + || output.contains("through its toolchain.") + || output.contains("\"vitePlusVersion\"") + { + output = TOOLCHAIN_VERSION_RE.replace_all(&output, "").into_owned(); + output = TOOLCHAIN_REVISION_RE.replace_all(&output, "").into_owned(); + } + // Redact bare runtime-tool versions by name context (see TOOL_VERSION_RE) output = TOOL_VERSION_RE.replace_all(&output, "$1$2").into_owned(); @@ -533,6 +559,9 @@ pub fn redact_output( // Mask the calendar-dependent install date in `vp env which` output output = INSTALLED_DATE_RE.replace_all(&output, "${1}").into_owned(); + // Mask the generated toolchain build timestamp. + output = TOOLCHAIN_BUILD_TIME_RE.replace_all(&output, "${1}").into_owned(); + // Remove ^C echo that Unix terminal drivers emit when ETX (0x03) is written // to the PTY. Windows ConPTY does not echo it. { diff --git a/crates/vp_cli_snapshots/tests/redact_unit.rs b/crates/vp_cli_snapshots/tests/redact_unit.rs index cd7ad175e9..d96eebffa0 100644 --- a/crates/vp_cli_snapshots/tests/redact_unit.rs +++ b/crates/vp_cli_snapshots/tests/redact_unit.rs @@ -288,6 +288,54 @@ fn replaces_paths_with_labels() { ); } +#[test] +fn masks_toolchain_build_time_in_human_and_json_output() { + let input = concat!( + "vite-task (built 2026-08-06T09:30:00Z, revision ebe5837)\n", + " \"builtAt\": \"2026-08-06T09:30:00Z\",\n", + ) + .to_owned(); + assert_eq!( + redact_output(input, &[], true), + concat!( + "vite-task (built , revision ebe5837)\n", + " \"builtAt\": \"\",\n", + ) + ); +} + +#[test] +fn masks_toolchain_versions_and_revisions_in_human_hint_and_json_output() { + let revision = "ebe583739b0b1e7828199b9ee9dd52273fa2fd20"; + let human = format!( + "Vite+ toolchain (global)\n\nvite-plus@0.2.8\n|-- bundles vite@8.2.1\n`-- compiles vite-task (revision {revision})\n" + ); + assert_eq!( + redact_output(human, &[], true), + "Vite+ toolchain (global)\n\nvite-plus@\n|-- bundles vite@\n`-- compiles vite-task (revision )\n" + ); + + let hint = "Vite+ also provides vite@8.2.1 through its toolchain.\n".to_owned(); + assert_eq!( + redact_output(hint, &[], true), + "Vite+ also provides vite@ through its toolchain.\n" + ); + + let json = format!( + "{{\n \"schemaVersion\": 1,\n \"source\": {{\"vitePlusVersion\": \"0.2.8\"}},\n \"nodes\": [{{\"version\": \"8.2.1\", \"revision\": \"{revision}\"}}],\n \"edges\": []\n}}\n" + ); + assert_eq!( + redact_output(json, &[], true), + "{\n \"schemaVersion\": 1,\n \"source\": {\"vitePlusVersion\": \"\"},\n \"nodes\": [{\"version\": \"\", \"revision\": \"\"}],\n \"edges\": []\n}\n" + ); +} + +#[test] +fn keeps_unrelated_build_times_visible() { + let input = "plugin built 2026-08-06T09:30:00Z\n".to_owned(); + assert_eq!(redact_output(input.clone(), &[], true), input); +} + #[test] fn redacts_forward_slash_windows_path_variants() { // Windows children also print file:// and stack-frame forms with forward diff --git a/crates/vp_global_cli/Cargo.toml b/crates/vp_global_cli/Cargo.toml index 20e8275562..3ea56b81f2 100644 --- a/crates/vp_global_cli/Cargo.toml +++ b/crates/vp_global_cli/Cargo.toml @@ -38,6 +38,7 @@ vt_path = { workspace = true } vp_command = { workspace = true } vp_setup = { workspace = true } vp_shared = { workspace = true } +vp_toolchain = { workspace = true } vt_str = { workspace = true } vt_workspace = { workspace = true } uuid = { workspace = true, features = ["v4"] } diff --git a/crates/vp_global_cli/src/cli.rs b/crates/vp_global_cli/src/cli.rs index fa347d9a36..5337894919 100644 --- a/crates/vp_global_cli/src/cli.rs +++ b/crates/vp_global_cli/src/cli.rs @@ -202,6 +202,21 @@ pub enum Commands { args: Vec, }, + /// Show active Vite+ tools, versions, and relationships + Toolchain { + /// Tool or package names to show + #[arg(value_name = "TOOLS")] + tools: Vec, + + /// Print the graph as JSON + #[arg(long)] + json: bool, + + /// Use the global Vite+ toolchain + #[arg(long)] + global: bool, + }, + /// Manage Node.js versions Env(EnvArgs), @@ -253,6 +268,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()) @@ -618,7 +634,35 @@ async fn run_package_manager_command( } commands::prepend_js_runtime_to_path_env(&cwd).await?; - Ok(vp_pm_cli::dispatch(&cwd, command).await?) + let hint_command = command.clone(); + let result = vp_pm_cli::dispatch_with_metadata(&cwd, command).await?; + if result.status.success() + && let Some(packages) = hint_command.why_hint_packages(result.package_manager) + { + print_toolchain_why_hint(&cwd, packages); + } + Ok(result.status) +} + +fn print_toolchain_why_hint(cwd: &vt_path::AbsolutePath, packages: &[String]) { + let Some(manifest) = active_toolchain_manifest(cwd) else { + return; + }; + let Some(hint) = vp_toolchain::why_hint(&manifest, packages) else { + return; + }; + output::raw_stderr(""); + output::raw_stderr(&hint); +} + +fn active_toolchain_manifest(cwd: &vt_path::AbsolutePath) -> Option { + let manifest_path = + if let Some(bin_js) = crate::js_executor::JsExecutor::resolve_local_vite_plus(cwd) { + bin_js.parent()?.join("toolchain.json") + } else { + crate::js_executor::JsExecutor::new(None).get_scripts_dir().ok()?.join("toolchain.json") + }; + vp_toolchain::load_manifest(&manifest_path).ok() } async fn managed_install( @@ -1042,6 +1086,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/vp_global_cli/src/command_picker.rs b/crates/vp_global_cli/src/command_picker.rs index 0f9ddaa581..815fe9d88f 100644 --- a/crates/vp_global_cli/src/command_picker.rs +++ b/crates/vp_global_cli/src/command_picker.rs @@ -112,6 +112,12 @@ const COMMANDS: &[CommandEntry] = &[ summary: "Manage Node.js versions.", append_help: false, }, + CommandEntry { + label: "toolchain", + command: "toolchain", + summary: "Show Vite+ tool versions and relationships.", + append_help: false, + }, CommandEntry { label: "help", command: "help", diff --git a/crates/vp_global_cli/src/commands/mod.rs b/crates/vp_global_cli/src/commands/mod.rs index 381e637314..a07b462ca4 100644 --- a/crates/vp_global_cli/src/commands/mod.rs +++ b/crates/vp_global_cli/src/commands/mod.rs @@ -143,6 +143,7 @@ pub mod create; pub mod hooks; pub mod migrate; pub mod staged; +pub mod toolchain; pub mod version; // Category D: Environment Management diff --git a/crates/vp_global_cli/src/commands/toolchain.rs b/crates/vp_global_cli/src/commands/toolchain.rs new file mode 100644 index 0000000000..0f118cc56c --- /dev/null +++ b/crates/vp_global_cli/src/commands/toolchain.rs @@ -0,0 +1,54 @@ +use std::process::ExitStatus; + +use vt_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(&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 = vp_toolchain::load_manifest(&manifest_path)?; + let version = vp_toolchain::root_version(&manifest) + .ok_or_else(|| Error::Other("toolchain manifest does not contain vite-plus".into()))?; + let source = vp_toolchain::Source { + scope: vp_toolchain::Scope::Global, + path: package_dir.as_path().to_string_lossy().into_owned().into(), + vite_plus_version: version.into(), + }; + let report = match vp_toolchain::build_report(&manifest, &tools, source) { + Ok(report) => report, + Err(vp_toolchain::ToolchainError::UnknownFilter(filter)) => { + let message = format!("`{filter}` is not in the Vite+ toolchain"); + if json { + vp_shared::output::raw_stderr(&format!("error: {message}")); + } else { + vp_shared::output::error(&message); + vp_shared::output::raw_stderr(&format!( + "hint: run `vp why {filter}` to show project dependencies" + )); + } + return Ok(crate::cli::exit_status(1)); + } + Err(error) => return Err(error.into()), + }; + + let rendered = + if json { vp_toolchain::render_json(&report)? } else { vp_toolchain::render(&report) }; + vp_shared::output::raw_inline(&rendered); + Ok(ExitStatus::default()) +} diff --git a/crates/vp_global_cli/src/commands/version.rs b/crates/vp_global_cli/src/commands/version.rs index 4413adfd0e..e121dd71f7 100644 --- a/crates/vp_global_cli/src/commands/version.rs +++ b/crates/vp_global_cli/src/commands/version.rs @@ -30,32 +30,28 @@ struct LocalVitePlus { #[derive(Debug, Clone, Copy)] struct ToolSpec { - display_name: &'static str, + id: &'static str, package_name: &'static str, bundled_version_key: Option<&'static str>, } const TOOL_SPECS: [ToolSpec; 7] = [ ToolSpec { - display_name: "vite", + id: "vite", package_name: "@voidzero-dev/vite-plus-core", bundled_version_key: Some("vite"), }, ToolSpec { - display_name: "rolldown", + id: "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 { id: "vitest", package_name: "vitest", bundled_version_key: None }, + ToolSpec { id: "oxfmt", package_name: "oxfmt", bundled_version_key: None }, + ToolSpec { id: "oxlint", package_name: "oxlint", bundled_version_key: None }, + ToolSpec { id: "oxlint-tsgolint", package_name: "oxlint-tsgolint", bundled_version_key: None }, ToolSpec { - display_name: "oxlint-tsgolint", - package_name: "oxlint-tsgolint", - bundled_version_key: None, - }, - ToolSpec { - display_name: "tsdown", + id: "tsdown", package_name: "@voidzero-dev/vite-plus-core", bundled_version_key: Some("tsdown"), }, @@ -84,6 +80,12 @@ fn find_local_vite_plus(start: &Path) -> Option { None } +fn read_toolchain_manifest(local: &LocalVitePlus) -> Option { + let manifest_path = local.package_dir.join("dist").join("toolchain.json"); + let manifest_path = vt_path::AbsolutePath::new(&manifest_path)?; + vp_toolchain::load_manifest(manifest_path).ok() +} + fn resolve_package_json(base_dir: &Path, package_name: &str) -> Option { let mut current = Some(base_dir); while let Some(dir) = current { @@ -96,7 +98,7 @@ fn resolve_package_json(base_dir: &Path, package_name: &str) -> Option Option { +fn resolve_legacy_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) @@ -106,6 +108,19 @@ fn resolve_tool_version(local: &LocalVitePlus, tool: ToolSpec) -> Option Some(pkg.version) } +fn resolve_tool_version( + local: Option<&LocalVitePlus>, + manifest: Option<&vp_toolchain::Manifest>, + tool: ToolSpec, +) -> Option { + match manifest { + Some(manifest) => vp_toolchain::node_by_id(manifest, tool.id) + .and_then(|node| node.version.as_deref()) + .map(str::to_owned), + None => local.and_then(|local| resolve_legacy_tool_version(local, tool)), + } +} + fn print_rows(title: &str, rows: &[(&str, String)]) { println!("{}", help::render_heading(title)); let label_width = rows.iter().map(|(label, _)| label.chars().count()).max().unwrap_or(0); @@ -165,12 +180,12 @@ pub async fn execute(cwd: AbsolutePathBuf) -> Result { ); println!(); + let manifest = local.as_ref().and_then(read_toolchain_manifest); let tool_rows = TOOL_SPECS .iter() .map(|tool| { - let version = - local.as_ref().and_then(|local_pkg| resolve_tool_version(local_pkg, *tool)); - (tool.display_name, format_version(version)) + let version = resolve_tool_version(local.as_ref(), manifest.as_ref(), *tool); + (tool.id, format_version(version)) }) .collect::>(); print_rows("Tools", &tool_rows); @@ -216,7 +231,7 @@ mod tests { use serial_test::serial; #[cfg(unix)] - use super::{ToolSpec, find_local_vite_plus, resolve_tool_version}; + use super::{TOOL_SPECS, find_local_vite_plus, read_toolchain_manifest, resolve_tool_version}; use super::{detect_system_node_version, format_version}; #[cfg(unix)] @@ -245,7 +260,59 @@ 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.join("dist")).unwrap(); + fs::write(pnpm_pkg_dir.join("package.json"), r#"{"version":"1.0.0"}"#).unwrap(); + fs::write( + 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(); + + let node_modules_dir = project.join("node_modules"); + fs::create_dir_all(&node_modules_dir).unwrap(); + symlink_dir( + Path::new(".pnpm/vite-plus@1.0.0/node_modules/vite-plus"), + &node_modules_dir.join("vite-plus"), + ); + + let local = find_local_vite_plus(project).expect("expected local vite-plus to resolve"); + let manifest = read_toolchain_manifest(&local).expect("expected manifest to resolve"); + assert_eq!( + resolve_tool_version(Some(&local), Some(&manifest), TOOL_SPECS[0]).as_deref(), + Some("8.0.0"), + ); + } + + #[cfg(unix)] + #[test] + fn resolves_legacy_tool_versions_when_manifest_is_missing() { let temp = tempfile::tempdir().unwrap(); let project = temp.path(); @@ -271,12 +338,9 @@ 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")); + assert_eq!( + resolve_tool_version(Some(&local), None, TOOL_SPECS[0]).as_deref(), + Some("8.0.0"), + ); } } diff --git a/crates/vp_global_cli/src/error.rs b/crates/vp_global_cli/src/error.rs index ad906aee07..1cd52d92aa 100644 --- a/crates/vp_global_cli/src/error.rs +++ b/crates/vp_global_cli/src/error.rs @@ -57,6 +57,9 @@ pub enum Error { #[error(transparent)] PmCli(#[from] vp_pm_cli::Error), + + #[error(transparent)] + Toolchain(#[from] vp_toolchain::ToolchainError), } impl Error { diff --git a/crates/vp_global_cli/src/help.rs b/crates/vp_global_cli/src/help.rs index 62a1b5aed3..919184211f 100644 --- a/crates/vp_global_cli/src/help.rs +++ b/crates/vp_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, @@ -452,6 +453,7 @@ pub fn top_level_help_doc() -> HelpDoc { section_rows( "Maintain", vec![ + row("toolchain", "Show active Vite+ tools, versions, and relationships"), row("upgrade", "Update vp itself to the latest version"), row("implode", "Remove vp and all related data"), ], diff --git a/crates/vp_global_cli/src/js_executor.rs b/crates/vp_global_cli/src/js_executor.rs index 053110cab9..7273cca8c6 100644 --- a/crates/vp_global_cli/src/js_executor.rs +++ b/crates/vp_global_cli/src/js_executor.rs @@ -400,8 +400,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 { @@ -412,11 +414,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. + pub(crate) 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 @@ -426,14 +434,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/vp_pm_cli/src/cli.rs b/crates/vp_pm_cli/src/cli.rs index d062d08692..343b5e78ad 100644 --- a/crates/vp_pm_cli/src/cli.rs +++ b/crates/vp_pm_cli/src/cli.rs @@ -8,7 +8,7 @@ use clap::Subcommand; use crate::{ - Error, PackageManager, + Error, PackageManager, PackageManagerType, resolution::{ AddArgs, ApproveBuildsArgs, AuditArgs, CacheArgs, CiArgs, ConfigCommand, DedupeArgs, DeprecateArgs, DistTagCommand, DlxArgs, FundArgs, InstallArgs, LinkArgs, ListArgs, @@ -284,7 +284,7 @@ impl PackageManagerCommand { Self::Outdated(args) => { matches!(args.format, Some(OutdatedFormat::Json | OutdatedFormat::List)) } - Self::Why(args) => args.json || args.parseable, + Self::Why(args) => args.is_machine_readable(), Self::Info(args) => args.json, Self::Pm(command) => command.is_quiet_or_machine_readable(), _ => false, @@ -314,6 +314,23 @@ impl PackageManagerCommand { _ => None, } } + + /// Package names for the Vite+ toolchain hint. + /// + /// Do not add the hint to JSON or parseable `why` output. + #[must_use] + pub fn why_hint_packages(&self, manager: PackageManagerType) -> Option<&[String]> { + match self { + Self::Why(args) if !args.is_machine_readable() => { + if manager == PackageManagerType::Yarn { + args.packages.get(..1) + } else { + Some(&args.packages) + } + } + _ => None, + } + } } impl PmCommand { @@ -597,6 +614,9 @@ mod tests { &["dlx", "--silent", "tsx"][..], &["outdated", "--format", "json"][..], &["why", "react", "--parseable"][..], + &["why", "react", "--", "--json"][..], + &["why", "react", "--", "--json=true"][..], + &["why", "react", "--", "--json=0"][..], &["info", "react", "--json"][..], &["pm", "list", "--json"][..], &["pm", "version", "patch", "--json"][..], @@ -606,9 +626,81 @@ mod tests { ] { assert!(parse(args).unwrap().is_quiet_or_machine_readable(), "{args:?}"); } + for args in [ + &["why", "react", "--", "--json=false"][..], + &["why", "react", "--", "--parseable=false"][..], + ] { + assert!(!parse(args).unwrap().is_quiet_or_machine_readable(), "{args:?}"); + } assert!(!parse(&["install"]).unwrap().is_quiet_or_machine_readable()); } + #[test] + fn why_hint_packages_excludes_json_and_parseable_output() { + let readable = parse(&["why", "vite", "vitest"]).unwrap(); + assert_eq!( + readable.why_hint_packages(PackageManagerType::Pnpm), + Some(["vite".to_string(), "vitest".to_string()].as_slice()) + ); + assert_eq!( + readable.why_hint_packages(PackageManagerType::Yarn), + Some(["vite".to_string()].as_slice()) + ); + + assert_eq!( + parse(&["why", "vite", "--json"]).unwrap().why_hint_packages(PackageManagerType::Pnpm), + None + ); + assert_eq!( + parse(&["why", "vite", "--parseable"]) + .unwrap() + .why_hint_packages(PackageManagerType::Pnpm), + None + ); + assert_eq!( + parse(&["why", "vite", "--", "--json"]) + .unwrap() + .why_hint_packages(PackageManagerType::Pnpm), + None + ); + assert_eq!( + parse(&["why", "vite", "--", "--parseable"]) + .unwrap() + .why_hint_packages(PackageManagerType::Pnpm), + None + ); + assert_eq!( + parse(&["why", "vite", "--", "--json=true"]) + .unwrap() + .why_hint_packages(PackageManagerType::Pnpm), + None + ); + assert_eq!( + parse(&["why", "vite", "--", "--json=0"]) + .unwrap() + .why_hint_packages(PackageManagerType::Npm), + None + ); + assert_eq!( + parse(&["why", "vite", "--", "--parseable=true"]) + .unwrap() + .why_hint_packages(PackageManagerType::Pnpm), + None + ); + assert_eq!( + parse(&["why", "vite", "--", "--json=false"]) + .unwrap() + .why_hint_packages(PackageManagerType::Pnpm), + Some(["vite".to_string()].as_slice()) + ); + assert_eq!( + parse(&["why", "vite", "--", "--parseable=false"]) + .unwrap() + .why_hint_packages(PackageManagerType::Pnpm), + Some(["vite".to_string()].as_slice()) + ); + } + #[test] fn suppresses_diagnostics_only_for_explicit_silent_modes() { for args in [ diff --git a/crates/vp_pm_cli/src/dispatch.rs b/crates/vp_pm_cli/src/dispatch.rs index 5a630e9357..b38e19867b 100644 --- a/crates/vp_pm_cli/src/dispatch.rs +++ b/crates/vp_pm_cli/src/dispatch.rs @@ -8,13 +8,19 @@ use std::process::ExitStatus; use vt_path::AbsolutePath; use crate::{ - PackageManager, + PackageManager, PackageManagerType, cli::{PackageManagerCommand, PmCommand}, error::Error, helpers::{build_package_manager, build_package_manager_or_npm_default, ensure_package_json}, resolution::{DlxArgs, StageCommand, run_resolution}, }; +#[derive(Debug)] +pub struct DispatchResult { + pub status: ExitStatus, + pub package_manager: PackageManagerType, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ManagerPolicy { CreateIfMissing, @@ -26,6 +32,13 @@ pub async fn dispatch( cwd: &AbsolutePath, command: PackageManagerCommand, ) -> Result { + Ok(dispatch_with_metadata(cwd, command).await?.status) +} + +pub async fn dispatch_with_metadata( + cwd: &AbsolutePath, + command: PackageManagerCommand, +) -> Result { let render_diagnostics = command.should_render_diagnostics(); let command = match command { PackageManagerCommand::Dlx(args) => { @@ -43,22 +56,28 @@ pub async fn dispatch( ManagerPolicy::AllowNpmFallback => build_package_manager_or_npm_default(cwd).await?, }; + let package_manager = manager.client; let resolution = command.resolve_for_manager(&manager)?; - run_resolution(cwd, resolution, render_diagnostics).await + let status = run_resolution(cwd, resolution, render_diagnostics).await?; + Ok(DispatchResult { status, package_manager }) } async fn dispatch_dlx( cwd: &AbsolutePath, args: DlxArgs, render_diagnostics: bool, -) -> Result { +) -> Result { match PackageManager::builder(cwd).build_with_default().await { Ok(manager) => { + let package_manager = manager.client; let resolution = PackageManagerCommand::Dlx(args).resolve_for_manager(&manager)?; - run_resolution(cwd, resolution, render_diagnostics).await + let status = run_resolution(cwd, resolution, render_diagnostics).await?; + Ok(DispatchResult { status, package_manager }) } Err(vp_error::Error::WorkspaceError(vt_workspace::Error::PackageJsonNotFound(_))) => { - run_resolution(cwd, args.resolve_npx_fallback(), render_diagnostics).await + let status = + run_resolution(cwd, args.resolve_npx_fallback(), render_diagnostics).await?; + Ok(DispatchResult { status, package_manager: PackageManagerType::Npm }) } Err(error) => Err(Error::Install(error)), } diff --git a/crates/vp_pm_cli/src/lib.rs b/crates/vp_pm_cli/src/lib.rs index aaace5b2e1..de091c6f2a 100644 --- a/crates/vp_pm_cli/src/lib.rs +++ b/crates/vp_pm_cli/src/lib.rs @@ -19,7 +19,7 @@ mod shim; pub use cli::{ManagedGlobalCommand, PackageManagerCommand, PmCommand}; pub use config::npm_registry; -pub use dispatch::dispatch; +pub use dispatch::{DispatchResult, dispatch, dispatch_with_metadata}; pub use error::Error; pub use package_manager::{ PackageManager, PackageManagerBuilder, PackageManagerResolution, PackageManagerSource, diff --git a/crates/vp_pm_cli/src/resolution/commands/why.rs b/crates/vp_pm_cli/src/resolution/commands/why.rs index 4ec6d97b0e..b92132829b 100644 --- a/crates/vp_pm_cli/src/resolution/commands/why.rs +++ b/crates/vp_pm_cli/src/resolution/commands/why.rs @@ -64,6 +64,24 @@ pub struct WhyArgs { pub(crate) pass_through_args: Vec, } +impl WhyArgs { + pub(crate) fn is_machine_readable(&self) -> bool { + self.json + || self.parseable + || self.pass_through_args.iter().any(|arg| is_machine_readable_arg(arg)) + } +} + +fn is_machine_readable_arg(arg: &str) -> bool { + if matches!(arg, "--json" | "--parseable") { + return true; + } + let Some((flag, value)) = arg.split_once('=') else { + return false; + }; + matches!(flag, "--json" | "--parseable") && !value.eq_ignore_ascii_case("false") +} + impl Resolve for Pnpm { fn resolve(&self, args: &WhyArgs, _diag: &mut Diagnostics) -> CommandResolution { let mut cmd = CommandBuilder::new("pnpm"); diff --git a/crates/vp_toolchain/Cargo.toml b/crates/vp_toolchain/Cargo.toml new file mode 100644 index 0000000000..6e6c96f3de --- /dev/null +++ b/crates/vp_toolchain/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "vp_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 } +vt_path = { workspace = true } +vt_str = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/crates/vp_toolchain/src/lib.rs b/crates/vp_toolchain/src/lib.rs new file mode 100644 index 0000000000..6a99381f6c --- /dev/null +++ b/crates/vp_toolchain/src/lib.rs @@ -0,0 +1,682 @@ +use std::{collections::VecDeque, fs}; + +use rustc_hash::{FxHashMap, FxHashSet}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use vt_path::AbsolutePath; +use vt_str::Str; + +#[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub revision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub built_at: 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 in the Vite+ toolchain")] + UnknownFilter(Str), + #[error("failed to create toolchain JSON: {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 node_ids_by_label = FxHashMap::default(); + for node in &manifest.nodes { + if node.id.is_empty() || node.name.is_empty() { + return Err(ToolchainError::InvalidManifest( + "node IDs and names must not be empty".into(), + )); + } + if node.version.as_ref().is_some_and(|version| version.is_empty()) + || node.revision.as_ref().is_some_and(|revision| revision.is_empty()) + || node.built_at.as_ref().is_some_and(|built_at| built_at.is_empty()) + { + return Err(ToolchainError::InvalidManifest(vt_str::format!( + "node `{}` has an empty version, revision, or build time", + node.id + ))); + } + if node.built_at.as_ref().is_some_and(|built_at| !is_utc_build_time(built_at)) { + return Err(ToolchainError::InvalidManifest(vt_str::format!( + "node `{}` has an invalid UTC build time", + node.id + ))); + } + if node.version.is_some() && node.built_at.is_some() { + return Err(ToolchainError::InvalidManifest(vt_str::format!( + "node `{}` must not have both a version and build time", + node.id + ))); + } + if node.version.is_none() && node.revision.is_none() && node.built_at.is_none() { + return Err(ToolchainError::InvalidManifest(vt_str::format!( + "node `{}` must have a version, revision, or build time", + node.id + ))); + } + if !ids.insert(node.id.as_str()) { + return Err(ToolchainError::InvalidManifest(vt_str::format!( + "duplicate node ID `{}`", + node.id + ))); + } + for label in node_labels(node) { + if label.is_empty() { + return Err(ToolchainError::InvalidManifest(vt_str::format!( + "node `{}` has an empty name or alias", + node.id + ))); + } + if let Some(existing_node) = node_ids_by_label.insert(label, node.id.as_str()) + && existing_node != node.id.as_str() + { + return Err(ToolchainError::InvalidManifest(vt_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(vt_str::format!( + "edge references an unknown node: {} -> {}", + edge.from, + edge.to + ))); + } + } + + Ok(()) +} + +fn is_utc_build_time(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == 20 + && bytes[4] == b'-' + && bytes[7] == b'-' + && bytes[10] == b'T' + && bytes[13] == b':' + && bytes[16] == b':' + && bytes[19] == b'Z' + && bytes.iter().enumerate().all(|(index, byte)| { + matches!(index, 4 | 7 | 10 | 13 | 16 | 19) || byte.is_ascii_digit() + }) +} + +pub fn node_by_id<'a>(manifest: &'a Manifest, id: &str) -> Option<&'a Node> { + manifest.nodes.iter().find(|node| node.id == id) +} + +fn node_labels(node: &Node) -> impl Iterator { + [node.id.as_str(), node.name.as_str()].into_iter().chain(node.aliases.iter().map(Str::as_str)) +} + +fn node_by_label<'a>(manifest: &'a Manifest, label: &str) -> Option<&'a Node> { + manifest.nodes.iter().find(|node| node_labels(node).any(|candidate| candidate == label)) +} + +pub fn root_version(manifest: &Manifest) -> Option<&str> { + node_by_id(manifest, "vite-plus").and_then(|node| node.version.as_deref()) +} + +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 mut node_indices = FxHashMap::default(); + let mut node_indices_by_label = FxHashMap::default(); + for (index, node) in manifest.nodes.iter().enumerate() { + node_indices.insert(node.id.as_str(), index); + for label in node_labels(node) { + node_indices_by_label.insert(label, 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) = node_indices_by_label.get(filter) else { + return Err(ToolchainError::UnknownFilter(filter.into())); + }; + keep_nodes[matched] = true; + + let mut visited_ancestors = vec![false; manifest.nodes.len()]; + visited_ancestors[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()]; + keep_nodes[parent] = true; + if !visited_ancestors[parent] { + visited_ancestors[parent] = true; + ancestors.push_back(parent); + } + } + } + + let mut visited_downstream = vec![false; manifest.nodes.len()]; + visited_downstream[matched] = true; + 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()]; + keep_nodes[child] = true; + if !visited_downstream[child] { + visited_downstream[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(report: &Report) -> Str { + let mut output = vt_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(&vt_str::format!( + "{prefix}{connector}{} {}", + edge.relationship.display(), + display_node(node) + )); + output.push('\n'); + + if path.insert(node.id.as_str()) { + let child_prefix = vt_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.version, &node.built_at, &node.revision) { + (_, Some(built_at), Some(revision)) => { + vt_str::format!("{} (built {built_at}, revision {revision})", node.name) + } + (_, Some(built_at), None) => vt_str::format!("{} (built {built_at})", node.name), + (Some(version), None, Some(revision)) => { + vt_str::format!("{}@{} ({revision})", node.name, version) + } + (Some(version), None, None) => vt_str::format!("{}@{}", node.name, version), + (None, None, Some(revision)) => vt_str::format!("{} (revision {revision})", node.name), + (None, None, None) => node.name.clone(), + } +} + +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 Some(node) = node_by_label(manifest, query.as_ref()) 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(vt_str::format!( + "Vite+ also provides {provided} through its toolchain.\nRun `vp toolchain {filters}` to show these 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":"oxc-resolver","name":"oxc-resolver","version":"1.0.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":"rolldown","to":"oxc-resolver","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", "oxc-resolver"] + ); + 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"), + ("rolldown", "oxc-resolver") + ] + ); + } + + #[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", "oxc-resolver", "vitest"] + ); + } + + #[test] + fn overlapping_filters_are_order_independent() { + let oxc_then_vite = filter_manifest(&manifest(), &["oxc", "vite"]).unwrap(); + let vite_then_oxc = filter_manifest(&manifest(), &["vite", "oxc"]).unwrap(); + + assert_eq!(oxc_then_vite, vite_then_oxc); + assert!(oxc_then_vite.edges.iter().any(|edge| { + edge.from == "rolldown" + && edge.to == "oxc-resolver" + && edge.relationship == Relationship::Compiles + })); + } + + #[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[6].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 in the Vite+ toolchain"); + } + + #[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(&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\ + | | `-- compiles oxc-resolver@1.0.0\n\ + | `-- bundles rolldown@1.0.0\n\ + | |-- compiles oxc@0.1.0\n\ + | `-- compiles oxc-resolver@1.0.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 show these 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 show these versions and relationships." + ) + ); + } + + #[test] + fn build_time_replaces_a_placeholder_version_in_human_output() { + let node = Node { + id: "vite-task".into(), + name: "vite-task".into(), + version: None, + revision: Some("ebe583739b0b1e7828199b9ee9dd52273fa2fd20".into()), + built_at: Some("2026-08-06T09:30:00Z".into()), + kind: NodeKind::Tool, + delivery: vec![Delivery::Compiled], + aliases: Vec::new(), + }; + assert_eq!( + display_node(&node), + "vite-task (built 2026-08-06T09:30:00Z, revision ebe583739b0b1e7828199b9ee9dd52273fa2fd20)" + ); + } + + #[test] + fn manifest_accepts_a_revision_before_the_native_build_exists() { + let mut manifest = manifest(); + let node = node_by_id(&manifest, "vite").unwrap().clone(); + manifest.nodes.push(Node { + id: "vite-task".into(), + name: "vite-task".into(), + version: None, + revision: Some("ebe583739b0b1e7828199b9ee9dd52273fa2fd20".into()), + built_at: None, + kind: NodeKind::Tool, + delivery: vec![Delivery::Compiled], + aliases: Vec::new(), + }); + manifest.edges.push(Edge { + from: node.id, + to: "vite-task".into(), + relationship: Relationship::Compiles, + }); + validate_manifest(&manifest).unwrap(); + } + + #[test] + fn manifest_rejects_invalid_or_ambiguous_build_times() { + let mut manifest = manifest(); + manifest.nodes[0].built_at = Some("2026-08-06 09:30:00Z".into()); + assert!(matches!( + validate_manifest(&manifest), + Err(ToolchainError::InvalidManifest(message)) + if message.contains("invalid UTC build time") + )); + + manifest.nodes[0].built_at = Some("2026-08-06T09:30:00Z".into()); + assert!(matches!( + validate_manifest(&manifest), + Err(ToolchainError::InvalidManifest(message)) + if message.contains("both a version and build time") + )); + } + + #[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 = vt_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 461b9d7530..7cd00d7905 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -127,6 +127,7 @@ Vite+ can handle the entire local frontend development cycle from starting a pro ### Maintain +- [`vp toolchain`](/guide/upgrade#show-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 0e2930c517..8d1bb42523 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 show the packages that the package manager installed. They do +not show tools that Vite+ bundles or compiles. Run `vp toolchain [tool]` to show +these tools, including Vite, Rolldown, and Oxc. For readable output, `vp why` +shows a hint when Vite+ also provides the package. + #### 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 529e8c8b48..ce88a329c5 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 show the versions from the local Vite+ package. +Run `vp toolchain --global` to show the versions from the global Vite+ 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` @@ -111,7 +114,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..83bdb64ae2 100644 --- a/docs/guide/upgrade.md +++ b/docs/guide/upgrade.md @@ -11,6 +11,29 @@ There are two parts to upgrading Vite+: You can upgrade both of them independently. +## Show the Toolchain + +Run `vp toolchain` to show the components 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. Use +`--global` to show the release for the global `vp` command: + +```bash +vp toolchain --global +``` + +`vp why ` shows the dependency graph from the package manager. It +cannot show code bundled into `@voidzero-dev/vite-plus-core`. It also cannot +show engines compiled into Vite+. Use `vp toolchain` to show those versions and +relationships. + ## Global `vp` ```bash @@ -53,7 +76,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 +109,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 uses the registry bridge to resolve the ref to a `0.0.0-commit.` build. It installs this build like other versions. Run `vp toolchain --global` to show the active build and tool versions. After testing, run `vp upgrade --force` to restore the published release. You can also run the installer without `VP_PR_VERSION`. ### Local `vite-plus` Preview @@ -96,6 +119,6 @@ After installing the preview global CLI above, run migrate in the project to mov 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. +Migrate writes the bridge registry to `.npmrc`. For Yarn Berry, it writes the registry to `.yarnrc.yml`. It pins `vite-plus` and the `vite` alias to the matching `0.0.0-commit.` version. The `vite` alias points to `@voidzero-dev/vite-plus-core`. Commit the registry line if the project CI must test the preview. -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 the install, run `vp toolchain` to show the selected versions. After testing, set `vite-plus` to `latest`. Remove the bridge `registry` line from `.npmrc` or `.yarnrc.yml`. Then run `vp install`. diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 693c7933c9..e9e6326519 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. +## Tool Versions + +Run `vp toolchain` to show versions and relationships in the active Vite+ +release. Add a tool name to select part of the graph. For example, run +`vp toolchain vite`. Use `--global` to ignore the local `vite-plus` package. Use +`vp why ` to show the package-manager 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 2b8563dcda..8d1f418426 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`:** Show the versions of Vite+, Vite, Rolldown, Oxc, and other tools - **`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 @@ -193,7 +195,7 @@ If you are manually migrating a project to Vite+, install these dev dependencies vp install -D vite-plus ``` -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): +Add package-manager overrides so that other packages use the Vite+ versions. Alias `vite` to `@voidzero-dev/vite-plus-core`. Pin `vitest` to the version from `vp toolchain vitest`. The project and `vp test` then use the same Vitest copy. Without the pin, a dependency or workspace package can install a different Vitest version. The two versions can use separate mocks, `expect` functions, and runner states: ```json "overrides": { diff --git a/packages/cli/binding/.gitignore b/packages/cli/binding/.gitignore index 0292f6491b..1ed212e00a 100644 --- a/packages/cli/binding/.gitignore +++ b/packages/cli/binding/.gitignore @@ -1,2 +1,3 @@ *.node -*.wasm \ No newline at end of file +*.wasm +vite-plus.build-time diff --git a/packages/cli/binding/Cargo.toml b/packages/cli/binding/Cargo.toml index 1f12548451..6db406c6dc 100644 --- a/packages/cli/binding/Cargo.toml +++ b/packages/cli/binding/Cargo.toml @@ -35,6 +35,7 @@ vt_path = { workspace = true } vt_select = { workspace = true } vp_shared = { workspace = true } vp_static_config = { workspace = true } +vp_toolchain = { workspace = true } vt_str = { workspace = true } vt = { workspace = true } vt_workspace = { workspace = true } diff --git a/packages/cli/binding/index.d.cts b/packages/cli/binding/index.d.cts index 00bee183d8..54660f8ba8 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 d63ef7decc..0811dd5091 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 2dc2d1df62..558890f7c2 100644 --- a/packages/cli/binding/src/cli/help.rs +++ b/packages/cli/binding/src/cli/help.rs @@ -204,6 +204,7 @@ pub(super) fn print_help() { {bold}config{reset} Configure hooks and agent integration {bold}hooks{reset} Manage the Git hook dispatcher {bold}staged{reset} Run linters on staged files + {bold}toolchain{reset} Show Vite+ tool versions and relationships {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 713cc7b275..f5df698019 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 vp_error::Error; @@ -275,6 +275,74 @@ async fn execute_vite_task_command( Ok(status) } +fn execute_toolchain_command( + args: ToolchainArgs, + options: Option<&CliOptions>, +) -> Result { + if args.global { + vp_shared::output::error("The `--global` option requires the global `vp` CLI"); + return Ok(ExitStatus(1)); + } + + let options = options.ok_or_else(|| { + Error::Anyhow(anyhow::anyhow!("this CLI does not include toolchain metadata")) + })?; + let manifest_path = toolchain_manifest_path(options).ok_or_else(|| { + Error::Anyhow(anyhow::anyhow!("the toolchain manifest path must be absolute")) + })?; + let manifest = vp_toolchain::load_manifest(&manifest_path).map_err(anyhow::Error::new)?; + let version = vp_toolchain::root_version(&manifest).ok_or_else(|| { + Error::Anyhow(anyhow::anyhow!("toolchain manifest does not contain vite-plus")) + })?; + let source = vp_toolchain::Source { + scope: vp_toolchain::Scope::Local, + path: options.vite_plus_package_path.clone().into(), + vite_plus_version: version.into(), + }; + let report = match vp_toolchain::build_report(&manifest, &args.tools, source) { + Ok(report) => report, + Err(vp_toolchain::ToolchainError::UnknownFilter(filter)) => { + let message = format!("`{filter}` is not in the Vite+ toolchain"); + if args.json { + vp_shared::output::raw_stderr(&format!("error: {message}")); + } else { + vp_shared::output::error(&message); + vp_shared::output::raw_stderr(&format!( + "hint: run `vp why {filter}` to show project dependencies" + )); + } + return Ok(ExitStatus(1)); + } + Err(error) => return Err(anyhow::Error::new(error).into()), + }; + + let rendered = if args.json { + vp_toolchain::render_json(&report).map_err(anyhow::Error::new)? + } else { + vp_toolchain::render(&report) + }; + vp_shared::output::raw_inline(&rendered); + Ok(ExitStatus::SUCCESS) +} + +fn toolchain_manifest_path(options: &CliOptions) -> Option { + AbsolutePathBuf::new(options.toolchain_manifest_path.clone().into()) +} + +fn print_toolchain_why_hint(options: Option<&CliOptions>, packages: &[String]) { + let Some(manifest) = options + .and_then(toolchain_manifest_path) + .and_then(|path| vp_toolchain::load_manifest(&path).ok()) + else { + return; + }; + let Some(hint) = vp_toolchain::why_hint(&manifest, packages) else { + return; + }; + vp_shared::output::raw_stderr(""); + vp_shared::output::raw_stderr(&hint); +} + /// Main entry point for vite-plus CLI. /// /// # Arguments @@ -317,8 +385,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 +396,7 @@ pub async fn main( async fn execute_pm_command( command: vp_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,8 +407,9 @@ 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 status = match vp_pm_cli::dispatch(cwd, command).await { - Ok(status) => status, + let hint_command = command.clone(); + let result = match vp_pm_cli::dispatch_with_metadata(cwd, command).await { + Ok(result) => result, // Render `UserMessage` cleanly (no `error:` prefix) and exit non-zero — // matches the global CLI's `is_user_message()` branch in main.rs so the // friendly version-gate / usage errors look the same on both surfaces. @@ -348,7 +419,12 @@ async fn execute_pm_command( } Err(e) => return Err(Error::Anyhow(anyhow::Error::new(e))), }; - Ok(types::exit_status_from(status)) + if result.status.success() + && let Some(packages) = hint_command.why_hint_packages(result.package_manager) + { + print_toolchain_why_hint(options, packages); + } + Ok(types::exit_status_from(result.status)) } #[cfg(test)] diff --git a/packages/cli/binding/src/cli/types.rs b/packages/cli/binding/src/cli/types.rs index d9193b94b6..c2fda6c821 100644 --- a/packages/cli/binding/src/cli/types.rs +++ b/packages/cli/binding/src/cli/types.rs @@ -95,6 +95,21 @@ pub enum SynthesizableSubcommand { }, } +#[derive(Debug, clap::Args)] +pub struct ToolchainArgs { + /// Tool or package names to show + #[arg(value_name = "TOOLS")] + pub tools: Vec, + + /// Print the graph as JSON + #[arg(long)] + pub json: bool, + + /// Use 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)] @@ -113,6 +128,9 @@ pub(super) enum CLIArgs { /// Execute a command from local node_modules/.bin Exec(crate::exec::ExecArgs), + + /// Show active Vite+ tools, versions, and relationships + Toolchain(ToolchainArgs), } /// Type alias for boxed async resolver function @@ -136,6 +154,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 0c48c33e7f..4c5b3a8f2f 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 f41c2c1f16..2621a61b97 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'; @@ -35,6 +35,8 @@ import corePkg from '../core/package.json' with { type: 'json' }; const projectDir = dirname(fileURLToPath(import.meta.url)); const TEST_PACKAGE_NAME = 'vitest'; const CORE_PACKAGE_NAME = '@voidzero-dev/vite-plus-core'; +const NATIVE_BUILD_TIME_PATH = join(projectDir, 'binding', 'vite-plus.build-time'); +const UTC_BUILD_TIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/; // Browser providers projected under ./test/* and ./test/browser/providers/* so the // public surface matches what the deleted `@voidzero-dev/vite-plus-test` wrapper exposed. @@ -137,7 +139,7 @@ if (!skipNative) { if (!skipTs) { await syncCorePackageExports(); await syncTestPackageExports(); - await syncVersionsExport(); + await syncToolchainExports(); } await copyBundledDocs(); await syncReadmeFromRoot(); @@ -166,6 +168,7 @@ async function buildNapiBinding() { }); const outputs = await task; + await writeFile(NATIVE_BUILD_TIME_PATH, `${resolveBuildTime()}\n`); // --skip-format: importing the repo vite config pulls in the built // vite-plus dist, which doesn't exist in CI jobs that cross-compile only // the native binding (build-windows-cli); formatting the generated @@ -602,71 +605,308 @@ 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; builtAt?: 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; + builtAt?: 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 resolveBuildTime(): string { + const sourceDateEpoch = process.env.SOURCE_DATE_EPOCH; + if (sourceDateEpoch !== undefined && !/^(?:0|[1-9]\d*)$/.test(sourceDateEpoch)) { + throw new Error(`Invalid SOURCE_DATE_EPOCH: ${JSON.stringify(sourceDateEpoch)}`); + } + const seconds = sourceDateEpoch === undefined ? undefined : Number(sourceDateEpoch); + if (seconds !== undefined && !Number.isSafeInteger(seconds)) { + throw new Error(`Invalid SOURCE_DATE_EPOCH: ${JSON.stringify(sourceDateEpoch)}`); + } + const date = seconds === undefined ? new Date() : new Date(seconds * 1_000); + if (Number.isNaN(date.getTime())) { + throw new Error(`Invalid SOURCE_DATE_EPOCH: ${JSON.stringify(sourceDateEpoch)}`); + } + return date.toISOString().replace(/\.\d{3}Z$/, 'Z'); +} + +async function resolveNativeBuildTime(): Promise { + if (process.env.SOURCE_DATE_EPOCH !== undefined) { + return resolveBuildTime(); + } + if (!existsSync(NATIVE_BUILD_TIME_PATH)) { + return undefined; + } + const buildTime = (await readFile(NATIVE_BUILD_TIME_PATH, 'utf8')).trim(); + if (!UTC_BUILD_TIME_RE.test(buildTime)) { + throw new Error(`Invalid native build time in ${NATIVE_BUILD_TIME_PATH}`); + } + return buildTime; +} + +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, + buildTime: string | undefined, +): Promise { + let version: string | undefined; + let revision: string | undefined; + let builtAt: string | undefined; + const source = node.versionSource; + + switch (source.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?.[source.key]; + break; + } + case 'npm-dependency': + version = await readPackageVersion( + join(projectDir, 'node_modules', source.package, 'package.json'), + `${source.package}/package.json`, + ); + break; + case 'cargo': { + const pkg = resolveCargoPackage(cargoMetadata, source.package); + revision = sourceRevision(pkg.source); + if (source.revision && !revision) { + throw new Error(`Expected an exact source revision for Cargo package ${pkg.name}`); + } + if (source.builtAt) { + builtAt = buildTime; + } else { + version = pkg.version; + } + break; + } + } + + if (!version && !builtAt && !revision) { + throw new Error(`Could not resolve identity for toolchain node ${node.id}`); + } + + return { + id: node.id, + name: node.name, + ...(version ? { version } : {}), + ...(revision ? { revision } : {}), + ...(builtAt ? { builtAt } : {}), + 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 buildTime = await resolveNativeBuildTime(); + const nodes = await Promise.all( + config.nodes.map((node) => resolveToolchainNode(node, cargoMetadata, cliVersion, buildTime)), + ); + const toolchain = { + schemaVersion: config.schemaVersion, + nodes, + edges: config.edges, }; + const serializedToolchain = JSON.stringify(toolchain, null, 2); - // 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'), `${serializedToolchain}\n`); + await writeFile( + join(distDir, 'toolchain.js'), + `export const toolchain = ${serializedToolchain};\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 builtAt?: 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}`); + } + if (!node.version) { + throw new Error(`Toolchain node ${id} has no version for the versions export`); + } + 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 9c95ad68b2..493cb2dacc 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..2760da0820 --- /dev/null +++ b/packages/cli/src/__tests__/toolchain.spec.ts @@ -0,0 +1,101 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import url from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import type { ToolchainManifest } from '../../dist/toolchain.js'; + +const cliPkgDir = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '../..'); +const distDir = path.join(cliPkgDir, 'dist'); + +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'); + 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()) { + if (node.id === 'vite-task') { + continue; + } + expect(node.version, `${node.id} should have an exact version`).toMatch(/^\d+\.\d+\.\d+/); + } + expect(nodes.get('vite-task')?.version).toBeUndefined(); + expect(nodes.get('vite-task')?.revision).toMatch(/^[0-9a-f]{40}$/); + const nativeBuildTimePath = path.join(cliPkgDir, 'binding', 'vite-plus.build-time'); + const sourceDateEpoch = process.env.SOURCE_DATE_EPOCH; + const expectedBuildTime = + sourceDateEpoch !== undefined + ? new Date(Number(sourceDateEpoch) * 1_000).toISOString().replace(/\.\d{3}Z$/, 'Z') + : fs.existsSync(nativeBuildTimePath) + ? fs.readFileSync(nativeBuildTimePath, 'utf8').trim() + : undefined; + expect(nodes.get('vite-task')?.builtAt).toBe(expectedBuildTime); + }); + + it('uses unambiguous filter labels', async () => { + const { toolchain } = await import('../../dist/toolchain.js'); + 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'), + import('../../dist/versions.js'), + ]); + 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('readonly builtAt?: string'); + 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 15cb294d0a..3e77bc9b83 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. @@ -139,6 +142,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..b683853d57 --- /dev/null +++ b/packages/cli/toolchain.config.json @@ -0,0 +1,208 @@ +{ + "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": "vt", + "revision": true, + "builtAt": 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..221dfcdda1 --- /dev/null +++ b/rfcs/toolchain-command.md @@ -0,0 +1,577 @@ +# 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. It shows 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 includes a static toolchain manifest. The command reads +this file. It does not run a package manager or dependency code. It does not use +the network. + +`vp why` keeps its package-manager behavior. For readable output, it checks each +query against the manifest. If a query matches, it shows a `vp toolchain` hint. + +## Motivation + +Vite+ pins the tools that `vp build`, `vp test`, and `vp check` use. A project's +peer dependencies must not change these versions. + +Package managers cannot show 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. This package version identifies the Vite+ release. It does not identify + the bundled Vite version. + +`vp --version` shows a flat summary. `vite-plus/versions` gives the same major +versions to JavaScript. Neither output shows relationships, 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. + +Each Vite+ release must include this version information. + +## Goals + +- Show 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 show. +- Provide JSON with a schema version. +- 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 that 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 affect tool behavior. + Version 1 includes Oxc and Oxc Resolver. + +The manifest excludes ordinary implementation dependencies. Examples include +terminal-formatting libraries, file globbers, and HTTP clients. The manifest +also excludes platform binding packages when they have the same version as the +tool that they provide. + +`vp toolchain` uses this limited graph. Use `vp why` and `vp list` for the +installed npm graph. + +Maintainers must update the graph when Vite+ adds a user-facing tool. They must +also update it when Vite+ adds a hidden engine that affects compatibility. + +## Command Interface + +```text +Usage: vp toolchain [OPTIONS] [TOOLS]... + +Show active Vite+ tools, versions, and relationships + +Arguments: + [TOOLS]... Tool or package names to show + +Options: + --json Print the graph as JSON + --global Use the global Vite+ toolchain + -h, --help Print help +``` + +With no tool names, the command prints the complete graph. Tool names select +one or more parts of the graph. + +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 JSON result +``` + +Version 1 accepts exact names and defined 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 identifies the selected source. `--global` skips local resolution. + +The global binary sends the full command to the selected local Vite+ package. +It reads the global manifest only when no local package exists. It also reads +the global manifest when the user passes `--global`. + +The project lockfile cannot describe code bundled into core. It also cannot +describe crates compiled into the native addon. The lockfile can contain +unrelated copies of Vite, Rolldown, or Oxc. Thus, the command does not use the +lockfile as release version information. + +## Readable Output + +The command prints 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 (built 2026-08-06T09:30:00Z, revision ) +``` + +These versions show the repository state when this RFC was written. They are +not part of the command contract. + +The readable tree can repeat a shared node to show two relationships. JSON has +one entry for each node ID. + +### Filtered output + +For each filter, the command keeps: + +- each parent node that shows how Vite+ provides the matched component, and +- each downstream `uses` or `compiles` relationship in 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 are case-sensitive. This matches npm and Cargo names. + +If a filter is unknown, the command exits with status 1: + +```text +error: `rollup` is not in the Vite+ toolchain +hint: run `vp why rollup` to show project dependencies +``` + +For a close match, the error can suggest a name from the manifest. + +## JSON Output + +With `--json`, the command does not show the Vite+ header, styles, or 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 used by edges and filters | +| `name` | Canonical package, tool, or engine name | +| `version` | Exact version, when available | +| `revision` | Exact source revision, when available | +| `builtAt` | UTC native build time when a version is not useful | +| `kind` | `package`, `tool`, or `engine` | +| `delivery` | One or more: `dependency`, `bundled`, or `compiled` | +| `aliases` | Other filter names | + +Schema version 1 defines these edge relationships: + +- `depends-on`: Vite+ ships the component as a package dependency. +- `bundles`: Vite+ merges the source or JavaScript output into another package. +- `uses`: A tool uses the component at runtime but does not own it. +- `compiles`: Vite+ links the component into the native addon. + +The renderer writes nodes and edges in manifest order. Consumers must select +nodes by ID. + +Increment `schemaVersion` for a breaking JSON change. Optional fields, nodes, +edges, aliases, and enum values do not require an increment. + +## 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. At runtime, the CLI adds the +`source` object and the installation path. + +The build also creates the existing `vite-plus/versions` export from the +manifest. It keeps the current keys. The build and both version commands use +one version list. + +### Version sources + +The build reads 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 | Exact revision and, after native compilation, its UTC build time | + +Maintainers define the graph and aliases in a small source file. The native +build records its completion time. The manifest generator combines this time +with the versions and revisions above. A TypeScript-only build uses an existing +native timestamp. If no timestamp exists, the manifest shows only the revision. +`SOURCE_DATE_EPOCH` sets the timestamp for a reproducible build. + +Release builds fail when: + +- the generator cannot resolve a required node, +- a required node has no exact version, revision, or build time, +- 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 file. It does not read repository +source files. It does not run Cargo in an installed project. + +## Older Local Vite+ Releases + +Local-first routing sends `vp toolchain` to the selected local Vite+ package. An +old local release rejects the command and exits with a nonzero status. + +The global CLI does not create a partial graph from old package data. Upgrade +the local Vite+ release to use the command. To show the global release, run +`vp toolchain --global`. + +## 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. Use `vp toolchain` to select tools and show +relationships or engine details. + +## Relationship to `vp why` + +`vp why` sends the command to the detected package manager. It keeps the +existing arguments, output, and exit status. It shows the installed package +graph. + +After a successful query with readable output, 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 show this version and its relationships. +``` + +The hint says "also provides" because a project may also install upstream Vite. +Vite+ does not change the package-manager output. It does not show the hint for +a failed query. It also omits the hint for JSON or parseable output. One hint +includes all matching names. + +## Implementation + +### Manifest generation + +Change `packages/cli/build.ts`. The versions-export step first generates the +toolchain graph. It then creates `versions.js` and its type declaration from the +graph. + +Core generates `bundledVersions` while it builds Vite, Rolldown, and tsdown. +The CLI generator combines these versions with npm package data and Cargo data. + +### Command implementation + +Shared Rust code parses, filters, and renders the graph. The global CLI and the +local NAPI CLI use this code. + +Place the top-level command with the other Vite+ version and lifecycle commands. +`vite_pm_cli` owns commands that run a package manager. + +Without `--global`, the global binary sends the command to the selected local +Vite+ package. The local package runs the command through its NAPI binding. With +`--global`, the global implementation reads the static global manifest. It also +does this when no local package exists. It does not start Node.js. + +The Rust `--version` implementation reads the shared manifest. It no longer +uses a 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 that discusses tool versions. + +The documentation states that `vp why` is 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. +- Readable output uses a stable order 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 JSON package-manager output | + +Release artifact tests load the same manifest with each platform binding. The +tests compare native versions with the compiled release inputs. + +## Performance and Security + +- The command resolves the selected `vite-plus` package. +- It reads one JSON file, filters a small graph, and writes the output. +- It does not use the network. +- It does not run dependency code. +- The CLI reads the manifest from the selected `vite-plus` package. +- The command does not use tool filters as 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. +JSON output does not include the new 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. It does not select +parts of the graph or show relationships. JSON output also needs a separate +command. + +### Name the command `vp versions` + +`versions` does not identify ownership. It also 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 release +data that Vite+ owns. + +### Change `vp why` to synthesize bundled nodes + +`vp why` shows package-manager dependency data. Synthetic nodes would change its +readable and JSON output. The package-manager output stays unchanged because +Vite+ prints the hint separately. + +### Read package manifests at runtime + +Runtime package reads can find Vite, Rolldown, tsdown, and managed npm tools. +They cannot find compiled Oxc or Vite Task inputs. They also duplicate the +manifest generator. + +### Query GitHub or the npm registry + +Remote lookups fail when the user is offline. They describe registry data, not +the installed files. The manifest describes the installed release. + +### Expose all Cargo and npm transitive dependencies + +A full transitive graph would duplicate package-manager and SBOM tools. The +manifest includes only components that affect Vite+ behavior. + +### Use peer dependencies for bundled tools + +Peer dependencies would let project resolution change Vite+ runtime behavior. +The command shows the versions without changing their 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 readable `vp why` hint. +5. Update product documentation and generated agent guidance. From 16c5cfc3c3b031b0d8af673c058e1ff56b57a5c7 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 10 Aug 2026 22:34:51 +0800 Subject: [PATCH 2/2] ci: refresh Cargo lockfile after version stamping --- .github/workflows/reusable-release-build.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/reusable-release-build.yml b/.github/workflows/reusable-release-build.yml index c2f0f26ae6..73be1db82b 100644 --- a/.github/workflows/reusable-release-build.yml +++ b/.github/workflows/reusable-release-build.yml @@ -69,6 +69,9 @@ jobs: for f in crates/vp_global_cli/Cargo.toml packages/cli/binding/Cargo.toml; do node -e 'const fs=require("fs"),f=process.argv[1];fs.writeFileSync(f,fs.readFileSync(f,"utf8").replace(/^version = "[^"]*"/m,`version = "${process.env.VERSION}"`))' "$f" done + # Keep the workspace package entries in Cargo.lock consistent with + # the stamped versions before later commands use --locked. + cargo update --workspace # Stamp the package.json files too: # - packages/cli: the bundled JS dist inlines ${VERSION} as # cliPkg.version (VITE_PLUS_VERSION). Without this a preview