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< + `-- bundles vite@8.2.0 + `-- uses rolldown@1.2.2 + |-- compiles oxc@0.142.0 + `-- compiles oxc-resolver@11.24.2 +``` + +## `vp toolchain vite vitest` + +multiple filters return a stable union + +``` +Vite+ toolchain (local) + +vite-plus@0.2.8 +|-- depends on @voidzero-dev/vite-plus-core@ +| `-- bundles vite@8.2.0 +| `-- uses rolldown@1.2.2 +| |-- compiles oxc@0.142.0 +| `-- compiles oxc-resolver@11.24.2 +`-- depends on vitest@4.1.10 +``` + +## `vp toolchain vite-plus-core tsgolint vite-task` + +stable IDs and declared aliases resolve + +``` +Vite+ toolchain (local) + +vite-plus@0.2.8 +|-- depends on @voidzero-dev/vite-plus-core@ +|-- depends on oxlint-tsgolint@7.0.2001 +`-- compiles vite-task (built , revision ebe583739b0b1e7828199b9ee9dd52273fa2fd20) +``` 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..e0324f9025 --- /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@0.2.8 +|-- depends on @voidzero-dev/vite-plus-core@ +| |-- bundles vite@8.2.0 +| | `-- uses rolldown@1.2.2 +| | |-- compiles oxc@0.142.0 +| | `-- compiles oxc-resolver@11.24.2 +| |-- bundles rolldown@1.2.2 +| | |-- compiles oxc@0.142.0 +| | `-- compiles oxc-resolver@11.24.2 +| `-- bundles tsdown@0.22.14 +|-- depends on vitest@4.1.10 +|-- depends on oxlint@1.76.0 +|-- depends on oxlint-tsgolint@7.0.2001 +|-- depends on oxfmt@0.61.0 +`-- compiles vite-task (built , revision ebe583739b0b1e7828199b9ee9dd52273fa2fd20) +``` 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..ed79d00575 --- /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@0.2.8 +|-- depends on @voidzero-dev/vite-plus-core@ +| |-- bundles vite@8.2.0 +| | `-- uses rolldown@1.2.2 +| | |-- compiles oxc@0.142.0 +| | `-- compiles oxc-resolver@11.24.2 +| |-- bundles rolldown@1.2.2 +| | |-- compiles oxc@0.142.0 +| | `-- compiles oxc-resolver@11.24.2 +| `-- bundles tsdown@0.22.14 +|-- depends on vitest@4.1.10 +|-- depends on oxlint@1.76.0 +|-- depends on oxlint-tsgolint@7.0.2001 +|-- depends on oxfmt@0.61.0 +`-- compiles vite-task (built , revision ebe583739b0b1e7828199b9ee9dd52273fa2fd20) +``` 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..8b748eb990 --- /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": "0.2.8" + }, + "nodes": [ + { + "id": "vite-plus", + "name": "vite-plus", + "version": "0.2.8", + "kind": "package", + "delivery": [ + "dependency" + ], + "aliases": [] + }, + { + "id": "vite-plus-core", + "name": "@voidzero-dev/vite-plus-core", + "version": "0.2.8", + "kind": "package", + "delivery": [ + "dependency" + ], + "aliases": [ + "vite-plus-core" + ] + }, + { + "id": "vite", + "name": "vite", + "version": "8.2.0", + "kind": "tool", + "delivery": [ + "bundled" + ], + "aliases": [] + }, + { + "id": "rolldown", + "name": "rolldown", + "version": "1.2.2", + "kind": "tool", + "delivery": [ + "bundled", + "compiled" + ], + "aliases": [] + }, + { + "id": "oxc", + "name": "oxc", + "version": "0.142.0", + "kind": "engine", + "delivery": [ + "compiled" + ], + "aliases": [] + }, + { + "id": "oxc-resolver", + "name": "oxc-resolver", + "version": "11.24.2", + "kind": "engine", + "delivery": [ + "compiled" + ], + "aliases": [] + } + ], + "edges": [ + { + "from": "vite-plus", + "to": "vite-plus-core", + "relationship": "depends-on" + }, + { + "from": "vite-plus-core", + "to": "vite", + "relationship": "bundles" + }, + { + "from": "vite", + "to": "rolldown", + "relationship": "uses" + }, + { + "from": "rolldown", + "to": "oxc", + "relationship": "compiles" + }, + { + "from": "rolldown", + "to": "oxc-resolver", + "relationship": "compiles" + } + ] +} +``` diff --git a/crates/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..ebc8bb7ce4 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_toolchain/snapshots/toolchain_old_local_routing.md @@ -0,0 +1,47 @@ +# toolchain_old_local_routing + +## `vpt mkdir -p node_modules/vite-plus/dist` + + +## `vpt write-file node_modules/vite-plus/package.json '{"name":"vite-plus","version":"0.1.0"} +'` + + +## `vpt write-file node_modules/vite-plus/dist/bin.js 'console.error("error: Command '\''toolchain'\'' not found"); +process.exitCode = 2; +'` + + +## `vp toolchain` + +the global binary delegates and lets an old local CLI reject the command + +**Exit code:** 2 + +``` +error: Command 'toolchain' not found +``` + +## `vp toolchain --global` + +--global skips the old local package + +``` +Vite+ toolchain (global) + +vite-plus@0.2.8 +|-- depends on @voidzero-dev/vite-plus-core@ +| |-- bundles vite@8.2.0 +| | `-- uses rolldown@1.2.2 +| | |-- compiles oxc@0.142.0 +| | `-- compiles oxc-resolver@11.24.2 +| |-- bundles rolldown@1.2.2 +| | |-- compiles oxc@0.142.0 +| | `-- compiles oxc-resolver@11.24.2 +| `-- bundles tsdown@0.22.14 +|-- depends on vitest@4.1.10 +|-- depends on oxlint@1.76.0 +|-- depends on oxlint-tsgolint@7.0.2001 +|-- depends on oxfmt@0.61.0 +`-- compiles vite-task (built , revision ebe583739b0b1e7828199b9ee9dd52273fa2fd20) +``` 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..9bd1975c25 --- /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@8.2.0 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..9bd1975c25 --- /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@8.2.0 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_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 c9202244ec..d81bf5acf6 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 @@ -25,6 +25,7 @@ Core Commands: cache Manage the task cache config Configure hooks and agent integration staged Run linters on staged files + toolchain Show 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 fc51e24490..a0a5d4d20b 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 @@ -50,6 +50,9 @@ Manage Dependencies: rebuild Rebuild native modules pm Forward a command to the package manager +Inspect: + toolchain Show active Vite+ tools, versions, and relationships + Maintain: upgrade Update vp itself to the latest version implode Remove vp and all related data 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 166ac74621..542672a31b 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 @@ -25,6 +25,7 @@ Core Commands: cache Manage the task cache config Configure hooks and agent integration staged Run linters on staged files + toolchain Show 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..82170ddd4e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs @@ -279,6 +279,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, @@ -533,6 +542,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..89044beb2f 100644 --- a/crates/vp_cli_snapshots/tests/redact_unit.rs +++ b/crates/vp_cli_snapshots/tests/redact_unit.rs @@ -288,6 +288,28 @@ 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 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 ce60057396..6b1f0ad32f 100644 --- a/crates/vp_global_cli/src/cli.rs +++ b/crates/vp_global_cli/src/cli.rs @@ -195,6 +195,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), @@ -246,6 +261,7 @@ impl Commands { pub fn is_quiet_or_machine_readable(&self) -> bool { match self { Self::PackageManager(pm) => pm.is_quiet_or_machine_readable(), + Self::Toolchain { json, .. } => *json, Self::Upgrade { silent, .. } => *silent, Self::Env(args) => { args.command.as_ref().is_some_and(|sub| sub.is_quiet_or_machine_readable()) @@ -565,6 +581,7 @@ async fn run_package_manager_command( cwd: AbsolutePathBuf, command: PackageManagerCommand, ) -> Result { + let why_hint_packages = command.why_hint_packages().map(<[String]>::to_vec); match command.managed_global_command() { Some(ManagedGlobalCommand::Install { packages, node, force, concurrency }) => { return managed_install(packages, node, force, concurrency).await; @@ -611,7 +628,27 @@ async fn run_package_manager_command( } commands::prepend_js_runtime_to_path_env(&cwd).await?; - Ok(vp_pm_cli::dispatch(&cwd, command).await?) + let status = vp_pm_cli::dispatch(&cwd, command).await?; + if status.success() + && let Some(packages) = why_hint_packages + && let Some(manifest) = active_toolchain_manifest(&cwd) + && let Some(hint) = vp_toolchain::why_hint(&manifest, &packages) + { + output::raw_stderr(""); + output::raw_stderr(&hint); + } + Ok(status) +} + +fn active_toolchain_manifest(cwd: &vt_path::AbsolutePath) -> Option { + let manifest_path = if let Some(package_dir) = + crate::js_executor::JsExecutor::resolve_local_vite_plus_package_dir(cwd) + { + package_dir.join("dist").join("toolchain.json") + } else { + crate::js_executor::JsExecutor::new(None).get_scripts_dir().ok()?.join("toolchain.json") + }; + vp_toolchain::load_manifest(&manifest_path).ok() } async fn managed_install( @@ -1033,6 +1070,10 @@ pub async fn run_command_with_options( commands::delegate::execute(cwd, "cache", &args, raw_subcommand).await } + Commands::Toolchain { tools, json, global } => { + commands::toolchain::execute(cwd, tools, json, global, raw_subcommand).await + } + Commands::Env(args) => commands::env::execute(cwd, args).await, // Self-Management diff --git a/crates/vp_global_cli/src/command_picker.rs b/crates/vp_global_cli/src/command_picker.rs index ae2ce31ca9..6e6e847dad 100644 --- a/crates/vp_global_cli/src/command_picker.rs +++ b/crates/vp_global_cli/src/command_picker.rs @@ -106,6 +106,12 @@ const COMMANDS: &[CommandEntry] = &[ summary: "Manage Node.js versions.", append_help: false, }, + CommandEntry { + label: "toolchain", + command: "toolchain", + summary: "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 47b97046a1..b05a485dda 100644 --- a/crates/vp_global_cli/src/commands/mod.rs +++ b/crates/vp_global_cli/src/commands/mod.rs @@ -93,6 +93,7 @@ pub mod config; pub mod create; pub mod migrate; pub mod staged; +pub mod toolchain; pub mod version; // Category D: Environment Management diff --git a/crates/vp_global_cli/src/commands/toolchain.rs b/crates/vp_global_cli/src/commands/toolchain.rs new file mode 100644 index 0000000000..cfcee9fda3 --- /dev/null +++ b/crates/vp_global_cli/src/commands/toolchain.rs @@ -0,0 +1,57 @@ +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_package_dir(&cwd).is_some() { + let mut args = tools; + if json { + args.push("--json".to_string()); + } + return delegate::execute(cwd, "toolchain", &args, raw_subcommand).await; + } + + let scripts_dir = JsExecutor::new(None).get_scripts_dir()?; + let package_dir = scripts_dir.parent().ok_or(Error::JsScriptsDirNotFound)?; + let manifest_path = scripts_dir.join("toolchain.json"); + let manifest = 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_human(&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 5b171b2d1c..b1ef87dadc 100644 --- a/crates/vp_global_cli/src/commands/version.rs +++ b/crates/vp_global_cli/src/commands/version.rs @@ -1,7 +1,6 @@ //! Version command. use std::{ - collections::BTreeMap, fs, path::{Path, PathBuf}, process::ExitStatus, @@ -19,8 +18,6 @@ use crate::{commands::env::config::resolve_version, error::Error, help}; #[serde(rename_all = "camelCase")] struct PackageJson { version: String, - #[serde(default)] - bundled_versions: BTreeMap, } #[derive(Debug)] @@ -29,39 +26,6 @@ struct LocalVitePlus { package_dir: PathBuf, } -#[derive(Debug, Clone, Copy)] -struct ToolSpec { - display_name: &'static str, - package_name: &'static str, - bundled_version_key: Option<&'static str>, -} - -const TOOL_SPECS: [ToolSpec; 7] = [ - ToolSpec { - display_name: "vite", - package_name: "@voidzero-dev/vite-plus-core", - bundled_version_key: Some("vite"), - }, - ToolSpec { - display_name: "rolldown", - package_name: "@voidzero-dev/vite-plus-core", - bundled_version_key: Some("rolldown"), - }, - ToolSpec { display_name: "vitest", package_name: "vitest", bundled_version_key: None }, - ToolSpec { display_name: "oxfmt", package_name: "oxfmt", bundled_version_key: None }, - ToolSpec { display_name: "oxlint", package_name: "oxlint", bundled_version_key: None }, - ToolSpec { - display_name: "oxlint-tsgolint", - package_name: "oxlint-tsgolint", - bundled_version_key: None, - }, - ToolSpec { - display_name: "tsdown", - package_name: "@voidzero-dev/vite-plus-core", - bundled_version_key: Some("tsdown"), - }, -]; - const NOT_FOUND: &str = "Not found"; fn read_package_json(package_json_path: &Path) -> Option { @@ -85,26 +49,10 @@ fn find_local_vite_plus(start: &Path) -> Option { None } -fn resolve_package_json(base_dir: &Path, package_name: &str) -> Option { - let mut current = Some(base_dir); - while let Some(dir) = current { - let package_json_path = dir.join("node_modules").join(package_name).join("package.json"); - if let Some(pkg) = read_package_json(&package_json_path) { - return Some(pkg); - } - current = dir.parent(); - } - None -} - -fn resolve_tool_version(local: &LocalVitePlus, tool: ToolSpec) -> Option { - let pkg = resolve_package_json(&local.package_dir, tool.package_name)?; - if let Some(key) = tool.bundled_version_key - && let Some(version) = pkg.bundled_versions.get(key) - { - return Some(version.clone()); - } - Some(pkg.version) +fn read_toolchain_manifest(local: &LocalVitePlus) -> Option { + let manifest_path = local.package_dir.join("dist").join("toolchain.json"); + let manifest_path = vt_path::AbsolutePath::new(&manifest_path)?; + vp_toolchain::load_manifest(manifest_path).ok() } fn accent(text: &str) -> String { @@ -170,12 +118,16 @@ pub async fn execute(cwd: AbsolutePathBuf) -> Result { ); println!(); - let tool_rows = TOOL_SPECS + let manifest = local.as_ref().and_then(read_toolchain_manifest); + let tool_rows = vp_toolchain::VERSION_SUMMARY_IDS .iter() - .map(|tool| { - let version = - local.as_ref().and_then(|local_pkg| resolve_tool_version(local_pkg, *tool)); - (tool.display_name, format_version(version)) + .map(|id| { + let version = manifest + .as_ref() + .and_then(|manifest| vp_toolchain::node_by_id(manifest, id)) + .and_then(|node| node.version.as_ref()) + .map(ToString::to_string); + (*id, format_version(version)) }) .collect::>(); print_rows("Tools", &tool_rows); @@ -220,9 +172,9 @@ mod tests { use serial_test::serial; - #[cfg(unix)] - use super::{ToolSpec, find_local_vite_plus, resolve_tool_version}; use super::{detect_system_node_version, format_version}; + #[cfg(unix)] + use super::{find_local_vite_plus, read_toolchain_manifest}; #[cfg(unix)] fn symlink_dir(src: &Path, dst: &Path) { @@ -250,21 +202,38 @@ mod tests { #[cfg(unix)] #[test] - fn resolves_tool_versions_from_pnpm_symlink_layout() { + fn resolves_toolchain_manifest_from_pnpm_symlink_layout() { let temp = tempfile::tempdir().unwrap(); let project = temp.path(); let pnpm_pkg_dir = project.join("node_modules/.pnpm/vite-plus@1.0.0/node_modules/vite-plus"); - fs::create_dir_all(&pnpm_pkg_dir).unwrap(); + fs::create_dir_all(pnpm_pkg_dir.join("dist")).unwrap(); fs::write(pnpm_pkg_dir.join("package.json"), r#"{"version":"1.0.0"}"#).unwrap(); - - let core_pkg_dir = project - .join("node_modules/.pnpm/vite-plus@1.0.0/node_modules/@voidzero-dev/vite-plus-core"); - fs::create_dir_all(&core_pkg_dir).unwrap(); fs::write( - core_pkg_dir.join("package.json"), - r#"{"version":"1.0.0","bundledVersions":{"vite":"8.0.0"}}"#, + pnpm_pkg_dir.join("dist/toolchain.json"), + r#"{ + "schemaVersion": 1, + "nodes": [ + { + "id": "vite-plus", + "name": "vite-plus", + "version": "1.0.0", + "kind": "package", + "delivery": ["dependency"], + "aliases": [] + }, + { + "id": "vite", + "name": "vite", + "version": "8.0.0", + "kind": "tool", + "delivery": ["bundled"], + "aliases": [] + } + ], + "edges": [] + }"#, ) .unwrap(); @@ -276,12 +245,10 @@ mod tests { ); let local = find_local_vite_plus(project).expect("expected local vite-plus to resolve"); - let tool = ToolSpec { - display_name: "vite", - package_name: "@voidzero-dev/vite-plus-core", - bundled_version_key: Some("vite"), - }; - let resolved = resolve_tool_version(&local, tool); - assert_eq!(resolved.as_deref(), Some("8.0.0")); + let manifest = read_toolchain_manifest(&local).expect("expected manifest to resolve"); + assert_eq!( + vp_toolchain::node_by_id(&manifest, "vite").and_then(|node| node.version.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 bf0a56a1d3..5e9acc7c9b 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, @@ -440,6 +441,10 @@ pub fn top_level_help_doc() -> HelpDoc { row("pm", "Forward a command to the package manager"), ], ), + section_rows( + "Inspect", + vec![row("toolchain", "Show active Vite+ tools, versions, and relationships")], + ), section_rows( "Maintain", vec![ diff --git a/crates/vp_global_cli/src/js_executor.rs b/crates/vp_global_cli/src/js_executor.rs index 66c263ec49..c986b82192 100644 --- a/crates/vp_global_cli/src/js_executor.rs +++ b/crates/vp_global_cli/src/js_executor.rs @@ -381,8 +381,10 @@ impl JsExecutor { Ok(output) } - /// Resolve the local vite-plus package's `dist/bin.js` from the project directory. - fn resolve_local_vite_plus(project_path: &AbsolutePath) -> Option { + /// Resolve the local vite-plus package root from the project directory. + pub(crate) fn resolve_local_vite_plus_package_dir( + project_path: &AbsolutePath, + ) -> Option { use oxc_resolver::{ResolveOptions, Resolver}; let resolver = Resolver::new(ResolveOptions { @@ -393,11 +395,17 @@ impl JsExecutor { // Resolve vite-plus/package.json from the project directory to find the package root let resolved = resolver.resolve(project_path, "vite-plus/package.json").ok()?; let pkg_dir = resolved.path().parent()?; + AbsolutePathBuf::new(pkg_dir.to_path_buf()) + } + + /// Resolve the local vite-plus package's `dist/bin.js` from the project directory. + fn resolve_local_vite_plus(project_path: &AbsolutePath) -> Option { + let pkg_dir = Self::resolve_local_vite_plus_package_dir(project_path)?; let bin_js = pkg_dir.join("dist").join("bin.js"); - if bin_js.exists() { + if bin_js.as_path().exists() { tracing::debug!("Found local vite-plus at {:?}", bin_js); - AbsolutePathBuf::new(bin_js) + Some(bin_js) } else { tracing::debug!("Local vite-plus found but dist/bin.js missing at {:?}", bin_js); None @@ -407,14 +415,8 @@ impl JsExecutor { /// Resolve the version of the project-local `vite-plus`, if one is installed. fn resolve_local_vite_plus_version(project_path: &AbsolutePath) -> Option { - use oxc_resolver::{ResolveOptions, Resolver}; - - let resolver = Resolver::new(ResolveOptions { - condition_names: vec!["import".into(), "node".into()], - ..ResolveOptions::default() - }); - let resolved = resolver.resolve(project_path, "vite-plus/package.json").ok()?; - read_package_json_version(resolved.path()) + let package_dir = JsExecutor::resolve_local_vite_plus_package_dir(project_path)?; + read_package_json_version(package_dir.join("package.json")) } /// Read the top-level `version` string from a package.json. Returns `None` when diff --git a/crates/vp_pm_cli/src/cli.rs b/crates/vp_pm_cli/src/cli.rs index d062d08692..8484e78d4b 100644 --- a/crates/vp_pm_cli/src/cli.rs +++ b/crates/vp_pm_cli/src/cli.rs @@ -314,6 +314,17 @@ 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) -> Option<&[String]> { + match self { + Self::Why(args) if !args.json && !args.parseable => Some(&args.packages), + _ => None, + } + } } impl PmCommand { @@ -609,6 +620,15 @@ mod tests { assert!(!parse(&["install"]).unwrap().is_quiet_or_machine_readable()); } + #[test] + fn why_hint_packages_excludes_json_and_parseable_output() { + let readable = parse(&["why", "vite"]).unwrap(); + assert_eq!(readable.why_hint_packages(), Some(["vite".to_string()].as_slice())); + + assert_eq!(parse(&["why", "vite", "--json"]).unwrap().why_hint_packages(), None); + assert_eq!(parse(&["why", "vite", "--parseable"]).unwrap().why_hint_packages(), None); + } + #[test] fn suppresses_diagnostics_only_for_explicit_silent_modes() { for args in [ diff --git a/crates/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..9155cf02ec --- /dev/null +++ b/crates/vp_toolchain/src/lib.rs @@ -0,0 +1,662 @@ +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; + +pub const VERSION_SUMMARY_IDS: [&str; 7] = + ["vite", "rolldown", "vitest", "oxfmt", "oxlint", "oxlint-tsgolint", "tsdown"]; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Manifest { + pub schema_version: u32, + pub nodes: Vec, + pub edges: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Node { + pub id: Str, + pub name: Str, + #[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 labels = 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 std::iter::once(node.id.as_str()) + .chain(std::iter::once(node.name.as_str())) + .chain(node.aliases.iter().map(Str::as_str)) + { + if label.is_empty() { + return Err(ToolchainError::InvalidManifest(vt_str::format!( + "node `{}` has an empty name or alias", + node.id + ))); + } + if let Some(existing_node) = labels.insert(label, node.id.as_str()) + && existing_node != node.id.as_str() + { + return Err(ToolchainError::InvalidManifest(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) +} + +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 node_indices = manifest + .nodes + .iter() + .enumerate() + .map(|(index, node)| (node.id.as_str(), index)) + .collect::>(); + let mut names = FxHashMap::default(); + for (index, node) in manifest.nodes.iter().enumerate() { + names.insert(node.id.as_str(), index); + names.insert(node.name.as_str(), index); + for alias in &node.aliases { + names.insert(alias.as_str(), index); + } + } + + let mut keep_nodes = vec![false; manifest.nodes.len()]; + let mut keep_edges = vec![false; manifest.edges.len()]; + for filter in filters { + let filter = filter.as_ref(); + let Some(&matched) = names.get(filter) else { + return Err(ToolchainError::UnknownFilter(filter.into())); + }; + keep_nodes[matched] = true; + + let mut ancestors = VecDeque::from([matched]); + while let Some(node_index) = ancestors.pop_front() { + let node_id = manifest.nodes[node_index].id.as_str(); + for (edge_index, edge) in manifest.edges.iter().enumerate() { + if edge.to != node_id { + continue; + } + keep_edges[edge_index] = true; + let parent = node_indices[edge.from.as_str()]; + if !keep_nodes[parent] { + keep_nodes[parent] = true; + ancestors.push_back(parent); + } + } + } + + let mut downstream = VecDeque::from([matched]); + while let Some(node_index) = downstream.pop_front() { + let node_id = manifest.nodes[node_index].id.as_str(); + for (edge_index, edge) in manifest.edges.iter().enumerate() { + if edge.from != node_id || !edge.relationship.is_downstream_engine_relationship() { + continue; + } + keep_edges[edge_index] = true; + let child = node_indices[edge.to.as_str()]; + if !keep_nodes[child] { + keep_nodes[child] = true; + downstream.push_back(child); + } + } + } + } + + Ok(Manifest { + schema_version: manifest.schema_version, + nodes: manifest + .nodes + .iter() + .enumerate() + .filter(|(index, _)| keep_nodes[*index]) + .map(|(_, node)| node.clone()) + .collect(), + edges: manifest + .edges + .iter() + .enumerate() + .filter(|(index, _)| keep_edges[*index]) + .map(|(_, edge)| edge.clone()) + .collect(), + }) +} + +pub fn render_human(report: &Report) -> Str { + let mut output = 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 query = query.as_ref(); + let Some(node) = manifest.nodes.iter().find(|node| { + node.id == query + || node.name == query + || node.aliases.iter().any(|alias| alias == query) + }) else { + continue; + }; + if matched_ids.insert(node.id.as_str()) { + matched_nodes.push(node); + } + } + if matched_nodes.is_empty() { + return None; + } + + let mut provided = Str::default(); + let mut filters = Str::default(); + for (index, node) in matched_nodes.iter().enumerate() { + if index > 0 { + provided.push_str(", "); + filters.push(' '); + } + provided.push_str(&display_node(node)); + filters.push_str(node.name.as_str()); + } + Some(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":"vitest","name":"vitest","version":"4.0.0","kind":"tool","delivery":["dependency"],"aliases":[]} + ], + "edges": [ + {"from":"vite-plus","to":"core","relationship":"depends-on"}, + {"from":"core","to":"vite","relationship":"bundles"}, + {"from":"vite","to":"rolldown","relationship":"uses"}, + {"from":"core","to":"rolldown","relationship":"bundles"}, + {"from":"rolldown","to":"oxc","relationship":"compiles"}, + {"from":"vite-plus","to":"vitest","relationship":"depends-on"} + ] + }"#, + ) + .unwrap() + } + + #[test] + fn vite_filter_keeps_ownership_and_engine_chain_without_unrelated_edges() { + let filtered = filter_manifest(&manifest(), &["vite"]).unwrap(); + assert_eq!( + filtered.nodes.iter().map(|node| node.id.as_str()).collect::>(), + ["vite-plus", "core", "vite", "rolldown", "oxc"] + ); + assert_eq!( + filtered + .edges + .iter() + .map(|edge| (edge.from.as_str(), edge.to.as_str())) + .collect::>(), + [("vite-plus", "core"), ("core", "vite"), ("vite", "rolldown"), ("rolldown", "oxc")] + ); + } + + #[test] + fn multiple_filters_form_a_stable_union() { + let filtered = filter_manifest(&manifest(), &["vite", "vitest"]).unwrap(); + assert_eq!( + filtered.nodes.iter().map(|node| node.id.as_str()).collect::>(), + ["vite-plus", "core", "vite", "rolldown", "oxc", "vitest"] + ); + } + + #[test] + fn aliases_match() { + let filtered = filter_manifest(&manifest(), &["core"]).unwrap(); + assert!(filtered.nodes.iter().any(|node| node.id == "core")); + } + + #[test] + fn validation_allows_a_nodes_alias_to_repeat_its_id() { + let mut manifest = manifest(); + manifest.nodes[1].aliases.push("core".into()); + validate_manifest(&manifest).unwrap(); + } + + #[test] + fn validation_rejects_filter_labels_shared_by_different_nodes() { + let mut manifest = manifest(); + manifest.nodes[5].aliases.push("vite".into()); + let error = validate_manifest(&manifest).unwrap_err(); + assert_eq!( + error.to_string(), + "invalid toolchain manifest: filter label `vite` is shared by `vite` and `vitest`" + ); + } + + #[test] + fn unknown_filter_is_an_error() { + let error = filter_manifest(&manifest(), &["rollup"]).unwrap_err(); + assert_eq!(error.to_string(), "`rollup` is not 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_human(&report), + "Vite+ toolchain (local)\n\ + \n\ + vite-plus@1.0.0\n\ + |-- depends on @scope/core@1.0.0\n\ + | |-- bundles vite@8.0.0\n\ + | | `-- uses rolldown@1.0.0\n\ + | | `-- compiles oxc@0.1.0\n\ + | `-- bundles rolldown@1.0.0\n\ + | `-- compiles oxc@0.1.0\n\ + `-- depends on vitest@4.0.0\n" + ); + } + + #[test] + fn why_hint_uses_all_matching_queries() { + assert_eq!( + why_hint(&manifest(), &["vite", "vitest"]).as_deref(), + Some( + "Vite+ also provides vite@8.0.0, vitest@4.0.0 through its toolchain.\n\ + Run `vp toolchain vite vitest` to 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 1ee398444a..5c18b79dac 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -126,6 +126,7 @@ Vite+ can handle the entire local frontend development cycle from starting a pro ### Maintain +- [`vp toolchain`](/guide/upgrade#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 7eb21a015a..4cb62adf8b 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 68bc1ba79c..960222b8ef 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` @@ -106,7 +109,7 @@ If you are stuck, please reach out: When reporting a problem, please include: -- The full output of `vp env current` and `vp --version` +- The full output of `vp env current`, `vp --version`, and `vp toolchain` - The package manager used by the project - The exact steps needed to reproduce the problem and your `vite.config.ts` - A minimal reproduction repository or runnable sandbox diff --git a/docs/guide/upgrade.md b/docs/guide/upgrade.md index 647e865c9e..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 56f6bd17f4..036074008a 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 @@ -189,7 +191,7 @@ If you are manually migrating a project to Vite+, install these dev dependencies npm install -D vite-plus @voidzero-dev/vite-plus-core@latest ``` -You need to add overrides to your package manager so that other packages resolve the Vite+ versions: alias `vite` to `@voidzero-dev/vite-plus-core`, and pin `vitest` to the version Vite+ bundles (run `vp --version`) so the whole project shares a single Vitest copy with `vp test`. Without the `vitest` pin, a dependency or workspace package can pull a different Vitest than the bundled runner, splitting Vitest's internals (mocks, `expect`, runner state): +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 58db52c68a..61781d5afb 100644 --- a/packages/cli/binding/src/cli/help.rs +++ b/packages/cli/binding/src/cli/help.rs @@ -203,6 +203,7 @@ pub(super) fn print_help() { {bold}cache{reset} Manage the task cache {bold}config{reset} Configure hooks and agent integration {bold}staged{reset} Run linters on staged files + {bold}toolchain{reset} Show 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..241fcfc5da 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,59 @@ 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 = + vt_path::AbsolutePathBuf::new(options.toolchain_manifest_path.clone().into()).ok_or_else( + || Error::Anyhow(anyhow::anyhow!("the toolchain manifest path must be absolute")), + )?; + let manifest = vp_toolchain::load_manifest(&manifest_path) + .map_err(|error| Error::Anyhow(anyhow::Error::new(error)))?; + 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(Error::Anyhow(anyhow::Error::new(error))), + }; + + let rendered = if args.json { + vp_toolchain::render_json(&report) + .map_err(|error| Error::Anyhow(anyhow::Error::new(error)))? + } else { + vp_toolchain::render_human(&report) + }; + vp_shared::output::raw_inline(&rendered); + Ok(ExitStatus::SUCCESS) +} + /// Main entry point for vite-plus CLI. /// /// # Arguments @@ -317,8 +370,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 +381,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,6 +392,7 @@ async fn execute_pm_command( "Global package operations (`-g`/`--global`) are only supported by the globally-installed `vp` CLI. See https://viteplus.dev/guide/ to install it, then run the same command via the global `vp` binary.", ))); } + let why_hint_packages = command.why_hint_packages().map(<[String]>::to_vec); let status = match vp_pm_cli::dispatch(cwd, command).await { Ok(status) => status, // Render `UserMessage` cleanly (no `error:` prefix) and exit non-zero — @@ -348,6 +404,17 @@ async fn execute_pm_command( } Err(e) => return Err(Error::Anyhow(anyhow::Error::new(e))), }; + if status.success() + && let Some(packages) = why_hint_packages + && let Some(options) = options + && let Some(manifest_path) = + vt_path::AbsolutePathBuf::new(options.toolchain_manifest_path.clone().into()) + && let Ok(manifest) = vp_toolchain::load_manifest(&manifest_path) + && let Some(hint) = vp_toolchain::why_hint(&manifest, &packages) + { + vp_shared::output::raw_stderr(""); + vp_shared::output::raw_stderr(&hint); + } Ok(types::exit_status_from(status)) } diff --git a/packages/cli/binding/src/cli/types.rs b/packages/cli/binding/src/cli/types.rs index 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 2414446856..f86d319667 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,303 @@ 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 readNativeBuildTime(): Promise { + 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; + + switch (node.versionSource.type) { + case 'cli-package': + version = cliVersion; + break; + case 'core-package': + version = corePkg.version; + break; + case 'core-bundled': { + const bundledVersions = (corePkg as { bundledVersions?: Record }) + .bundledVersions; + version = bundledVersions?.[node.versionSource.key] ?? ''; + break; + } + case 'npm-dependency': + version = await readPackageVersion( + join(projectDir, 'node_modules', node.versionSource.package, 'package.json'), + `${node.versionSource.package}/package.json`, + ); + break; + case 'cargo': { + const pkg = resolveCargoPackage(cargoMetadata, node.versionSource.package); + revision = sourceRevision(pkg.source); + if (node.versionSource.revision && !revision) { + throw new Error(`Expected an exact source revision for Cargo package ${pkg.name}`); + } + if (node.versionSource.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 readNativeBuildTime(); + const nodes = await Promise.all( + config.nodes.map((node) => resolveToolchainNode(node, cargoMetadata, cliVersion, buildTime)), + ); + const toolchain = { + schemaVersion: config.schemaVersion, + nodes, + edges: config.edges, }; - // Read versions from CLI dependencies' installed package.json files - // (these packages don't export ./package.json, so node_modules is the source of truth) - const depTools = ['oxlint', 'oxfmt', 'oxlint-tsgolint', 'vitest'] as const; - for (const name of depTools) { - const version = await readDepVersion(name); - if (version) { - versions[name] = version; - } - } + await writeFile(join(distDir, 'toolchain.json'), `${JSON.stringify(toolchain, null, 2)}\n`); + await writeFile( + join(distDir, 'toolchain.js'), + `export const toolchain = ${JSON.stringify(toolchain, null, 2)};\nexport default toolchain;\n`, + ); + await writeFile( + join(distDir, 'toolchain.d.ts'), + `export type ToolchainNodeKind = 'package' | 'tool' | 'engine'; +export type ToolchainDelivery = 'dependency' | 'bundled' | 'compiled'; +export type ToolchainRelationship = 'depends-on' | 'bundles' | 'uses' | 'compiles'; +export interface ToolchainNode { + readonly id: string; + readonly name: string; + readonly version?: string; + readonly revision?: string; + readonly 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..890ac50439 --- /dev/null +++ b/packages/cli/src/__tests__/toolchain.spec.ts @@ -0,0 +1,97 @@ +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 nativeBuildTime = fs.existsSync(nativeBuildTimePath) + ? fs.readFileSync(nativeBuildTimePath, 'utf8').trim() + : undefined; + expect(nodes.get('vite-task')?.builtAt).toBe(nativeBuildTime); + }); + + 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 60fc0b7ae8..0054b41854 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -11,6 +11,7 @@ */ import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { ensureBlockingStdio, run } from '../binding/index.js'; import { maybePrintCommandHelp } from './help.ts'; @@ -101,6 +102,8 @@ if (args[0] === 'help' && args[1]) { } const command = args[0]; +const cliDistDir = path.dirname(fileURLToPath(import.meta.url)); +const vitePlusPackagePath = path.dirname(cliDistDir); if (maybePrintCommandHelp(args)) { // Help is rendered by the local CLI so it matches the installed toolchain. @@ -137,6 +140,8 @@ if (maybePrintCommandHelp(args)) { vite, test, doc, + toolchainManifestPath: path.join(cliDistDir, 'toolchain.json'), + vitePlusPackagePath, resolveUniversalViteConfig, args: rustCliArgs, }); diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts index c6fe41a114..49019c7ff4 100644 --- a/packages/cli/src/version.ts +++ b/packages/cli/src/version.ts @@ -62,7 +62,7 @@ function isVitePlusDeclaredInAncestors(cwd: string): boolean { /** * Resolve all tool versions from the locally installed vite-plus package. - * Uses the `vite-plus/versions` export generated by `syncVersionsExport()`. + * Uses the `vite-plus/versions` export generated by `syncToolchainExports()`. */ async function resolveToolVersions(localPackagePath: string): Promise> { try { diff --git a/packages/cli/toolchain.config.json b/packages/cli/toolchain.config.json new file mode 100644 index 0000000000..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.